Search code examples
iosswiftswiftuipickerobservableobject

SwiftUI picker jumps while scrolling during ObservedObject public var update


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!


Solution

  • 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!