iosswiftswiftui

Why is my binding in a closure called from Button.disabled() not updating?


I have some simple code with a TextField, Picker, and Button. The Button is only enabled when the TextField has text and the Picker has a selected value.

I have it structured such that the TextField, Button and a ViewBuilder closure called pickerContent are in the class TestView. Then in the class TestViewWithPicker, I call TestView and implement the Picker in its pickerContent closure. If it seems like a convoluted set up, it's because it's an MRE of the real code from my app where it makes more sense. Here is the code:

import SwiftUI

struct TestView<PickerContent: View>: View {
    @ViewBuilder var additionalEnterButtonDisabledConditions: () -> Bool
    @ViewBuilder var pickerContent: () -> PickerContent

    @State private var userInput: String = ""

    var body: some View {
        HStack {
            TextField("", text: $userInput)
                .textFieldStyle(.roundedBorder)
                .padding()

            pickerContent()

            Button("Enter") {}
                .disabled(userInput.isEmpty || additionalEnterButtonDisabledConditions())
                .buttonStyle(.borderedProminent)
                .padding()
        }
    }
}

struct TestViewWithPicker: View {
    @State private var selectedPickerItem: String? = nil

    var body: some View {
        TestView(
            additionalEnterButtonDisabledConditions: {
                return selectedPickerItem == nil
            },
            pickerContent: {
                PickerView(selectedPickerItem: $selectedPickerItem)
            }
        )
    }
}

struct PickerView: View {
    @Binding var selectedPickerItem: String?

    private let pickerItems: [String] = ["Option A", "Option B", "Option C"]

    var body: some View {
        Picker("", selection: $selectedPickerItem) {
            Text("Select Picker Item").tag(nil as String?)

            ForEach(pickerItems, id: \.self) { pickerItem in
                Text(pickerItem).tag(pickerItem as String?)
            }
        }
        .pickerStyle(.menu)
        .fixedSize(horizontal: true, vertical: true)
    }
}

struct HomeView: View {
    var body: some View {
        TestViewWithPicker()
    }
}

The problem I'm having is that my Button's .disabled condition is not activating unless a selection is made on the Picker:

.disabled(self.userInput.isEmpty || self.additionalEnterButtonDisabledConditions())

To reproduce the bug, if you run the code and select a non-nil item on the Picker, then type some text into the TextField, the Button will not become enabled like it should. But if you select another item on the Picker, it will.

If you look at the code, you'll see that self.additionalEnterButtonDisabledConditions() is implemented in TextViewWithPicker to check if self.selectedPickerItem is nil. The reason the issue is occurring is because I'm sending self.selectedPickerItem as a Binding to PickerView. If I don't implement the Picker in a separate class and put it directly in the pickerContent closure (and hence, don't send self.selectedPickerItem as a binding), the issue does not occur.

Does anyone know how to fix this so I can still use the current code structure?


Solution

  • This is due to the short-circuiting nature of ||.

    The right hand side of || is not evaluated at all, when the text field is empty. Therefore, SwiftUI fails to find selectedPickerItem as a dependency of TestView. When you enter text in the text field, only TestView is updated. TestViewWithPicker is not. Therefore, TestView does not get a new additionalEnterButtonDisabledConditions closure that captures the new value of selectedPickerItem. It still has the old closure that captured the old value of selectedPickerItem, which is nil.

    So a simple fix is always evaluate the closure:

    let additionalConditions = additionalEnterButtonDisabledConditions()
    Button("Enter") {}
        .disabled(userInput.isEmpty || additionalConditions)
        .buttonStyle(.borderedProminent)
        .padding()
    

    I would also recommend just taking a simple Bool, not () -> Bool, and evaluating the picker body in init.