SwiftUI Picker strange behavior when in an EquatableView

190 Views Asked by At

I post the minimum code to reproduce the behavior. Tested on latest macOS and Xcode.

The picker in this example is just a wrapper for the default picker and conforms to Equatable (this is a must to prevent the view from updating when properties doesn't change in the real world view) and categories:

enum Category: Int, Identifiable, CaseIterable {
    case one, two, three
    
    var id: Self { self }
    
    var name: String {
        switch self {
        case .one:
            return "First"
        case .two:
            return "Second"
        case .three:
            return "Third"
        }
    }
}
struct CustomPicker: View, Equatable {
    
    static func == (lhs: CustomPicker, rhs: CustomPicker) -> Bool {
        lhs.selectedCategory == rhs.selectedCategory
    }
    
    @Binding var selectedCategory: Category
    
    var body: some View {
        VStack {
            Picker("Picker", selection: $selectedCategory) {
                ForEach(Category.allCases) { category in
                    Text(category.name)
                }
            }
        }
    }
}

And a simple model to bind to:

final class Model: ObservableObject {
    @Published var selectedCategory: Category = .two
}

Now in ContentView:

struct ContentView: View {
    
    @StateObject private var model = Model()
    @State private var selectedCategory: Category = .two
    
    var body: some View {
        VStack {
            Text("Picker bug")
            HStack {
                CustomPicker(selectedCategory: $selectedCategory)
                CustomPicker(selectedCategory: $model.selectedCategory)
            }
        }
        .padding()
    }
}

Here is the problem. If I bind the CustomPicker to the @Stateproperty it works as expected. However, if bound to the model's @Published property the value doesn't change when interacting with the control. When not using the Equatable conformance it works as expected.

What's more interesting is that if I change the pickerStyle to something else like segmented or inline it does work again.

Any idea why this happens? Probably a bug?

EDIT:

I found a hack/workaround... the thing is if the CustomPicker is inside a regular TabView it works fine.

TabView {
    CustomPicker(selectedCategory: $model.selectedCategory)
     .tabItem {
        Text("Hack")
     }
}

Strange behavior...

1

There are 1 best solutions below

3
On

Since this only happens with one picker type (and then, only on macOS, iOS is fine), it does look like a bug with this specific picker style. If you add extra logging you can see that for other picker styles, it performs more equality checks, perhaps because there has been user interaction and the other options are visible, so different mechanisms are marking the view as dirty.

When the equality check is happening for this type of picker, it has the new value from the binding for both the left and right hand sides. If you move from a binding to passing the whole model as an observed object, then it works (because it ignores equatable at this level, it seems), but since you're interested in minimising redraws, that's probably not a great solution.

Note that according to the documentation you need to wrap views in EquatableView or use the .equatable() modifier to take advantage of using your own diffing. You might be better off working out where the performance problems you're trying to avoid are coming from, and fixing those instead.