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
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
+ }
}
}