Tikhonov Aleksandr
Tikhonov Aleksandr

Reputation: 14349

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?

Upvotes: 1

Views: 615

Answers (4)

malhal
malhal

Reputation: 30736

This bug in the OP's code can be worked around with this:

if let name = Binding(nameBinding) { 
    ChildView(selectedName: name) 
        .transition(.identity) // no more crash
}

However it might not fix the crash in all kinds of child views.

@iampatbrown figured out animations was a contributing factor and came up with the workaround while discussing FB8367784 - a similar problem with Toggle. It seems Apple has fixed it with Toggle but the same crash happens with TextField and probably other controls that take bindings.

I've made this simple example that recreates the crash and submitted it to Apple as FB16555225 (refererencing FB8367784) so hopefully they can fix it so we won't need the workaround:

struct ContentView: View {
    @State var optionalText: String?
    
    var body: some View {
        
        Button(optionalText == nil ? "Show" : "Hide") {
            optionalText = optionalText == nil ? "Text" : nil
        }
        if let $text = Binding($optionalText) {
            TextField("", text: $text)
                // .transition(.identity) // workaround to prevent crash
        }
    }
}

Upvotes: 1

Tikhonov Aleksandr
Tikhonov Aleksandr

Reputation: 14349

Thanks to @workingdogsupportUkraine and @loremipsum for help to investigate the issue.

At the moment it looks like SwiftUI bug.

There are some workarounds using a default value which I'm not happy with because in case of complex data structure it can be annoying to create placeholder instance for such purpose. I prefer another approach where we convert Binding<Optional<Value>> to Optional<Binding<Value>>.

var nameBinding: Binding<String>? {
    guard let name = viewModel.name else { return nil }
    return Binding {
        name
    } set: { value in
        viewModel.name = value
    }
}

...
   if let name = nameBinding {
       ChildView(selectedName: name)
   }
... 

Upvotes: 1

lorem ipsum
lorem ipsum

Reputation: 29614

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 == ""

Upvotes: 1

You could try this alternative approach to have a binding to your optional name: String?. It uses Binding<String>(...) as shown in the code. Works for me.

struct ContentView: View {
    var body: some View {
        ParentView()
    }
}

class Model: ObservableObject {
    @Published var name: String? = "name"
}

struct ParentView: View {
    @StateObject var viewModel = Model()
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Name is \(viewModel.name ?? "nil")")
            Button("Make name nil") {
                viewModel.name = nil // <-- here
            }
            // -- here
            if viewModel.name != nil {
                ChildView(selectedName: Binding<String>(
                    get: { viewModel.name ?? "nil" },
                    set: { viewModel.name = $0 })
                )
            }
        }
        .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)
            }
        }
    }
}

EDIT-1

You can of course use this example of code, closer to your original code. Since nameBinding is already a binding (modified now with String), having if let name = Binding(nameBinding) ... , that is, a binding of a binding optional, is not correct.

struct ParentView: View {
    @StateObject var viewModel = Model()
    
    var nameBinding: Binding<String> {  // <-- here
        Binding {
            viewModel.name ?? "nil"  // <-- here
        } 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.name = nil
            }
            ChildView(selectedName: nameBinding)  // <-- here
        }
        .padding()
    }
}

Upvotes: 1

Related Questions