Search code examples
iosswiftswiftuibinding

Binding with optional Value causes runtime crash


I have a binding with optional String as a type and in the parent view I have if condition which checks whether it is has value or not. Depending on this condition I show or hide the child view. When I make name value nil the app is crashing, below you find code example.

class Model: ObservableObject {

    @Published var name: String? = "name"
    
    func makeNameNil() {
        name = nil
    }
    
}

struct ParentView: View {
    
    @StateObject var viewModel = Model()
    
    var nameBinding: Binding<String?> {
        Binding {
            viewModel.name
        } set: { value in
            viewModel.name = value
        }
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Name is \(viewModel.name ?? "nil")")
            Button("Make name nil") {
                viewModel.makeNameNil()
            }
            if let name = Binding(nameBinding) {  /* looks like */
                ChildView(selectedName: name) /* this causes the crash*/
            }
        }
        .padding()
    }
}

struct ChildView: View {
    
    @Binding var selectedName: String
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Selected name: \(selectedName)")
            HStack {
                Text("Edit:")
                TextField("TF", text: $selectedName)
            }
        }
    }
}

Here is stack of the crash.

Thread 1: EXC_BREAKPOINT (code=1, subcode=0x107e1745c)

AG::Graph::UpdateStack::update() ()
AG::Graph::update_attribute(AG::data::ptr<AG::Node>, unsigned int) ()
AG::Subgraph::update(unsigned int) ()

call stack

Looks like a switfui bug for me, should I avoid using such constructions?


Solution

  • There is an undocumented method by Apple that allows you to see how, what, when SwiftUI Views are loaded.

    let _ = Self._printChanges()
    

    If you add this to the body of both Views

    struct BindingCheckView: View {
        @StateObject var viewModel = Model()
        var nameBinding: Binding<String?> {
            Binding {
                viewModel.name
            } set: { value in
                viewModel.name = value
            }
        }
        var body: some View {
            let _ = Self._printChanges()
            VStack(alignment: .leading, spacing: 8) {
                Text("Name is \(viewModel.name ?? "nil")")
                Button("Make name nil") {
                    viewModel.makeNameNil()
                }
                if viewModel.name != nil{
                    ChildView(selectedName: $viewModel.name ?? "")
                }
            }
            .padding()
        }
    }
    
    struct ChildView: View {
        @Binding var selectedName: String
        var body: some View {
            let _ = Self._printChanges()
            VStack(alignment: .leading, spacing: 8) {
                Text("Selected name: \(selectedName)")
                HStack {
                    Text("Edit:")
                    TextField("TF", text: $selectedName)
                }
            }
        }
    }
    

    You will see something like

    enter image description here

    You will notice that the child is being redrawn before the parent.

    So for a split second you are trying to set a non-Optional String to an Optional<String>

    I would submit this as a bug report because Apple has addressed similar issues before in order to stabilize Binding but to address your immediate issue I would use an optional binding solution from here or a combination of both.

    Or a little bit different set of solutions that combines the solutions from there

    ///This method returns nil if the `description` `isEmpty` instead of `rhs` or `default`
    func ??<T: CustomStringConvertible>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
        Binding(
            get: { lhs.wrappedValue ?? rhs },
            set: {
                lhs.wrappedValue = $0.description.isEmpty ? nil : $0
            }
        )
    }
    

    with the option above if name == "" it will change to name == nil

    ///This is for everything that doesn't conform to `CustomStringConvertible` there is no way to set `nil` from here. Same from link above.
    func ??<T>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
        Binding(
            get: { lhs.wrappedValue ?? rhs },
            set: { lhs.wrappedValue = $0 }
        )
    }
    

    with the option above if name == "" it will stay name == "" and name == nil will look like name == ""