3

Currently creating a SwiftUI app, I got stuck trying to solve a problem with a ObservedObject and a picker. My ContentView:

struct ContentView: View {
    @ObservedObject var timerManager = TimerManager()
...
var body: some View{
                 Circle()
                    .onTapGesture(perform: {  
                                self.timerManager.setTimerLength(seconds: self.settings.selectedSecondPickerIndex, minutes: self.settings.selectedMinutePickerIndex)
                                self.timerManager.start()
                    })

 Picker(selection: self.$settings.selectedSecondPickerIndex, label: Text("")) {
                            ForEach(0 ..< 60) {
                                Text("\(secondsToString(seconds: self.availableTimeInterval[$0]))")
                            }
                        }
}
...
}

The TimerManager manages a timer. The class:

class TimerManager: ObservableObject {
    @Published var secondsLeft : Int = -1
    var secondsTotal : Int = -1
    var timer = Timer()

func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { timer in
        if secondsLeft == 0 {
          self.secondsLeft = secondsTotal
          timer.invalidate()
        }
        else{
          self.secondsLeft -= 1
        }
       }
      RunLoop.current.add(timer, forMode: RunLoop.Mode.common)
}
  func setTimerLength(seconds: Int, minutes: Int) {
        let totalSeconds: Int = seconds + minutes * 60
        secondsTotal = totalSeconds
        secondsLeft = totalSeconds
    }
...
}

I need to access the secondsLeft variable of the timer class from ContentView to display the remaining time. When the timer runs, the secondsLeft variable gets updated every second and ContentView gets rerendered. The problem is that while the timer is running, I can't "flick" my picker, it always resets, also pointed out here: https://forums.developer.apple.com/thread/127218. But unlike in that post, the "selection" variable of the picker doesn't have any effect on the problem. If I remove the @Public from the secondsLeft variable, everything works just fine (problem is that I can't display the remaining time then).

Does anybody have an idea how to solve this?

Thanks for answering!

leonboe1
  • 1,004
  • 9
  • 27
  • Maybe an enviornmentObject will help? We need a little more context, it would be helpful if you can create a reproducible example: https://stackoverflow.com/help/minimal-reproducible-example – Muhand Jumah Jun 10 '20 at 19:43
  • Thank you! I just added some more code. – leonboe1 Jun 10 '20 at 19:57
  • Great, I was able to write a simple demo using your code; However, when I flick the picker it doesn't reset the timer but the timer label "freezes" until I let go off the picker. Is this your problem? or am I missing something? – Muhand Jumah Jun 10 '20 at 20:09
  • Just to be sure: what do you mean with "timer label"? The picker? – leonboe1 Jun 10 '20 at 20:26
  • no, I created a `Text("Time remaining: \(self.timerManager.secondsLeft)")` that displays the time remaining, and it counts down. – Muhand Jumah Jun 10 '20 at 20:30
  • No, that is not what I mean. I created a short video, maybe this explains it better: https://youtu.be/WLOhJnrMXPA – leonboe1 Jun 10 '20 at 20:33
  • Now I get why the problem exists. To let the timer continue while scrolling the picker, I added RunLoop.current.add(timer, forMode: RunLoop.Mode.common) to the timer. If I remove that line, it works even with the published var. Do you have any idea why? I also edited the code above. – leonboe1 Jun 10 '20 at 20:35
  • Got it. Unfortunately, I am not able to replicate that behavior. Your code above worked for me and I don't see this bug. – Muhand Jumah Jun 10 '20 at 20:35
  • Great, please post your answer so others can benefit from it! – Muhand Jumah Jun 10 '20 at 20:36
  • Unfortunately, I still doesn't know how to fix both problems. Do you have an idea? – leonboe1 Jun 10 '20 at 20:38
  • Got it, you are blocking the main thread, that's the reason. Try the following: `DispatchQueue.global(qos: .background).async { RunLoop.current.add(self.timer, forMode: RunLoop.Mode.common) }` – Muhand Jumah Jun 10 '20 at 20:45
  • Thanks! I tried your suggestion and it does scroll flawless now, however the timer stops when I move the picker. Any idea? – leonboe1 Jun 10 '20 at 20:51
  • How about when you let go off the picker? does it continue? or does it restart? or does it completely stop? – Muhand Jumah Jun 10 '20 at 20:53
  • The timer continues after I let go. – leonboe1 Jun 10 '20 at 20:58
  • I think pickers in swiftui blocks the ui thread but I am not 100% certain, not sure if there is anything to be done here. – Muhand Jumah Jun 10 '20 at 21:10
  • That’s very unfortunate. But there just has to be way! – leonboe1 Jun 11 '20 at 09:01

1 Answers1

2

The problem can be fixed with a custom UIPickerView:

import SwiftUI

struct ContentView: View {

    @State var selection = 0

    var body: some View {

        VStack {

            FixedPicker(selection: $selection, rowCount: 10) { row in
                "\(row)"
            }
        }
    }
}

struct FixedPicker: UIViewRepresentable {
    class Coordinator : NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        @Binding var selection: Int
        
        var initialSelection: Int?
        var titleForRow: (Int) -> String
        var rowCount: Int

        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            1
        }
        
        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            rowCount
        }
        
        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            titleForRow(row)
        }
        
        func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
            self.selection = row
        }
        
        init(selection: Binding<Int>, titleForRow: @escaping (Int) -> String, rowCount: Int) {
            self.titleForRow = titleForRow
            self._selection = selection
            self.rowCount = rowCount
        }
    }
    
    @Binding var selection: Int
    
    var rowCount: Int
    let titleForRow: (Int) -> String

    func makeCoordinator() -> FixedPicker.Coordinator {
        return Coordinator(selection: $selection, titleForRow: titleForRow, rowCount: rowCount)
    }

    func makeUIView(context: UIViewRepresentableContext<FixedPicker>) -> UIPickerView {
        let view = UIPickerView()
        view.delegate = context.coordinator
        view.dataSource = context.coordinator
        return view
    }
    
    func updateUIView(_ uiView: UIPickerView, context: UIViewRepresentableContext<FixedPicker>) {
        
        context.coordinator.titleForRow = self.titleForRow
        context.coordinator.rowCount = rowCount

        //only update selection if it has been changed
        if context.coordinator.initialSelection != selection {
            uiView.selectRow(selection, inComponent: 0, animated: true)
            context.coordinator.initialSelection = selection
        }
    }
}

I found this solution here: https://mbishop.name/2019/11/odd-behaviors-in-the-swiftui-picker-view/

Hope this helps everyone having the same problem!

leonboe1
  • 1,004
  • 9
  • 27