Hello everyone!
I'm having an issue where I would like to use a picker, while having state updates coming in from an external source, e.g. a timer. When I do this with ForEach() inside the Picker, I find that the picker 'jumps' when scrolling and an unrelated state var changes. If I list several static choices in the picker, it works correctly.
Is this an issue with Picker? Any ideas on how to work around this?
Here's some sample code that shows this issue (in Xcode 11.3):
import SwiftUI
struct ContentView: View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State private var counter = 0
@State private var pickerSelection = 0
var body: some View {
VStack {
Text("Counter: \(counter)")
.onReceive(timer) { input in
self.counter += 1
print(input)
}
Picker("Snappy Picker", selection: self.$pickerSelection) {
// This does not work - picker snaps to original value when counter changes:
ForEach(0..<8) { i in
Text("Item \(i)")
}
// This works - picker doesn't snap when counter changes:
/*
Text("Item 0")
Text("Item 1")
Text("Item 2")
Text("Item 3")
Text("Item 4")
Text("Item 5")
Text("Item 6")
Text("Item 7")
Text("Item 8")
*/
}
.labelsHidden()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
This is generally happening when spinning & releasing the picker's scroller. In that case, it's still coming to rest when the counter updates, which is what causes it to reset.
When your counter state variable changes, it causes your entire body to be re-evaluated. This in turn causes the picker to be re-evaluated, since it's declared in your body. The same underlying UIPickerView (or similar) is still onscreen, but when the $pickerSelection binding is queried, it returns the old value—the scroll hasn't finished, and isn't ongoing since you've lifted your finger. The picker is assigned the value from the binding and, since it's not being interacted with at this moment, it sets that value, resetting position. This is generally what you'd expect when setting the value of a UIPickerView programatically as well. Note that if you keep your finger down and moving slowly, you'll see the UI flicker back to the old value for at most a single frame, since the gesture is ongoing and that is keeping the view content updated to match. If you let go on a number a moment after the counter changes, it has time to settle and set the $pickerSelection before the counter is updated, causing another redraw.
I'll note that when you use a list of static Text views inside your picker, the issue doesn't occur because the Picker doesn't know how to map the value of $pickerSelection to one of its subviews. When you use a ForEach, there's an implicit .tag() modifier attached to each Text view you create. When you just list out your own Text views, you'd need to tag each one to make it correspond to (and set) the value of $pickerSelection. If you added those modifiers, I believe you'll see the same issue as when using the ForEach.
The ultimate issue is that the entire ContentView is being updated when that one property changes. This is intentional, but in your setup it's causing a re-evaluation of a VStack, one Text (the only thing referencing the changed state), one Picker, one ForEach, and eight Text items. The way to decouple the initial Text view's updates from the Picker and the rest of your hierarchy here is to separate out that first Text view and use a Binding to your counter property. This way you can retain ownership of the counter and the timer in ContentView, but only the view *using* the counter will be updated. While the ContentView's body referenced the counter property, that was the entire ContentView, but now it's just a single small subview. This makes the render tree simpler, and narrowly defines the dependencies.
Here's a version of your code where the Picker behaves itself. Note that all I've done is create a new View that wraps the counter Text and uses a binding to read the counter value; the onReceive() call is still within the ContentView, and management of the counter property and timer remain there. Since only the body of FrequentlyUpdateView reads from the counter property, only that view is re-evaluated when the counter changes.
struct ContentView: View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@State private var counter = 0
@State private var pickerSelection = 0
var body: some View {
VStack {
FrequentlyUpdatedView(counter: $counter)
.onReceive(timer) {
self.counter += 1
print($0)
}
Picker("Snappy Picker", selection: $pickerSelection) {
ForEach(0..<8) { i in
Text("Item \(i)")
}
}
.labelsHidden()
}
}
}
struct FrequentlyUpdatedView: View {
@Binding fileprivate var counter: Int
var body: some View {
Text("Counter: \(counter)")
}
}
Ultimately this is one of the reasons why using lots of small and tightly-constrained custom views is recommended in SwiftUI. There's not really any extra resource cost for doing so (unlike traditional views in UIKit or AppKit), and it means the framework can determine and perform the smallest amount of work necessary when some state changes.