Odd behaviors in the SwiftUI Picker View

November 27, 2019
swiftui development

With new technologies released from Apple, we all have a sense of excitement and an flash of an idea for how this will improve our app. Cool demos are designed like that. Usually after we start using the technology, the honeymoon wears off, and we are forced to grapple with the realization that we have solved many problems… but also gained some.

SwiftUI stands out from others with the number of new problems we developers must grapple with as we figure out how best to take advantage of it.

I’m working on an app that receives constant state updates from a remote bluetooth device which means that my app continually is updating its state in the background in response to the messages it receives from the bluetooth device. Unfortunately, this has exposed a bug in the SwiftUI Picker design when it’s being used as a “wheel” picker

Code Available Here

Overview

When you specify Picker in your View, you bind its selection property to a piece of state in your app which contains the selection. While the wheel is in motion, it does not update the selection property. It’s only when the wheel settles down and stops, does the bound selection value update.

For example, let’s pretend the selected value of the picker is 3 and then we spin the picker to come to rest at 7. These are the states.

  • At rest: Selection is 3
  • Spinning: Selection remains unchanged
  • Rests: Selection is now 7

In SwiftUI, if your View hierarchy is refreshed in response to your application’s state while the wheel is still spinning, the Picker will ensure the chosen value reflects the value in the selection binding. However, if that happens while the wheel is spinning, it will be immediately “reset” back to the selection value.

Because my app is regularly receiving state updates when receiving bluetooth messages, the picker continually resets to its starting value, making it it nearly impossible to choose something else.

Solution

We can get around this by creating our own picker which “remembers” the original value it started with and ignores any attempts to update it back to the original value.

To do this, we’ll need to use UIViewRepresentable to show a UIPickerView. Because our UIPickerView needs to have a data source and a delegate to show its items, we also add a Coordinator to be associated with our picker view and to handle those roles.

/**
 iOS Picker class with the update bug which can cause the SwiftUI picker to reset.
 */
struct FixedPicker: UIViewRepresentable {
    class Coordinator : NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        @Binding var selection: 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

        uiView.selectRow(selection, inComponent: 0, animated: true)
    }
}

Now we have our own picker implementation, but it has the same problem as the SwiftUI one. When updateUIView() is called we are receiving the current selection value (via the selection property), but we immediately call uiView.selectRow(), which resets the picker back to the starting value.

One way to fix this is to store the starting selection value on the coordinator itself and then only update the picker selection when the new selection differs from the original.

/**
 iOS Picker class with the update bug which can cause the SwiftUI picker to reset.
 */
struct FixedPicker: UIViewRepresentable {
    class Coordinator : NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        @Binding var selection: Int
        
+        var initialSelection: Int?
        var titleForRow: (Int) -> String
        var rowCount: Int
    func updateUIView(_ uiView: UIPickerView, context: UIViewRepresentableContext<FixedPicker>) {
        
        context.coordinator.titleForRow = self.titleForRow
        context.coordinator.rowCount = rowCount

+        // This is the key part. If the updated value is the same as the one
+        // we started with, we just ignore it.
+        if context.coordinator.initialSelection != selection {
            uiView.selectRow(selection, inComponent: 0, animated: true)
+            context.coordinator.initialSelection = selection
+        }

    }
}

Code Available Here

comments powered by Disqus