0

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?

Tikhonov Aleksandr
  • 13,945
  • 6
  • 39
  • 53
  • It looks like you have caused infinite recursion. Why are you using a binding with a published property? By not just use the publish property directly? – Paulw11 May 13 '23 at 05:40
  • There is no infinite recursion, crash stack says something wrong view update (probably because Binding is `nil`). Having `if let name = Binding($viewModel.name) {}` also produces the same crash. – Tikhonov Aleksandr May 13 '23 at 05:48
  • 1) Listen to Paulw11, you don't need to bind an `@Published` property. 2) The error is because you are binding a constant: in `ChildView(selectedName: name)`, `name` is a `let`. And it won't work if you make it a `var`, because you lose the binding when you change from `nameBinding` to `name`. Using `ChildView(selectedName: .constant(name))` will work, but again, you lose the binding. – HunterLion May 13 '23 at 06:01
  • @HunterLion why do I lose binding when assign `name` to `Binding(nameBinding)`? If you tap on text field in child view you will see that parent reflects changes. – Tikhonov Aleksandr May 13 '23 at 06:16
  • @malhal apple designed `@StateObject` to be used in such way, it goes through wwdc sessions, samples and so on. Anyway, the questions is about unexpected crash rather than choosing the "right" architecture :) – Tikhonov Aleksandr May 13 '23 at 09:45
  • 1
    What is the point of the name binding? Why not just use $viewModel.name – lorem ipsum May 13 '23 at 11:56
  • @loremipsum because in my origin code I don't have any @Published variables in `Model` and I need this Binding to connect `Views` and `Model`. It doesn't matter to use `nameBinding` or `$viewModel.name`, the app is crashing in both ways. – Tikhonov Aleksandr May 13 '23 at 13:20

3 Answers3

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()
    }
}
  • This is a one workaround, I have the similar approach with `var nameBinding: Binding? { ... }` and `if let name = nameBinding { ChildView(selectedName: name) }`. And both work, but I wonder why more right and direct approach such in the question doesn't work :) – Tikhonov Aleksandr May 13 '23 at 06:13
1

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

lorem ipsum
  • 21,175
  • 5
  • 24
  • 48
  • Thanks, I have the same understanding as you that child view is rendered/updated before parent view and it results the crash (inconsistence with binding) – Tikhonov Aleksandr May 13 '23 at 15:35
0

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)
   }
... 
Tikhonov Aleksandr
  • 13,945
  • 6
  • 39
  • 53