1

I have a parent state that might exist:

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

If that state exists, I want to show a child view. In this example, showing name.

If name is visible, I'd like it to be shown and editable. I'd like this to be two-way editable, that means if Model.name changes, I'd like it to push to the ChildUI, if the ChildUI edits this, I'd like it to reflect back to Model.name.

However, if Model.name becomes nil, I'd like ChildUI to hide.

When I do this, via unwrapping of the Model.name, then only the first value is captured by the Child who is now in control of that state. Subsequent changes will not push upstream because it is not a Binding.

Question

Can I have a non-optional upstream bind to an optional when it exists? (are these the right words?)

Complete Example

import SwiftUI

struct Child: View {
    // within Child, I'd like the value to be NonOptional
    @State var text: String
    
    var body: some View {
        TextField("OK: ", text: $text).multilineTextAlignment(.center)
    }
}

class Model: ObservableObject {
    // within the parent, value is Optional
    @Published var name: String? = nil
}

struct Parent: View {
    @ObservedObject var model: Model = .init()
    
    var body: some View {
        VStack(spacing: 12) {
            Text("Demo..")

            // whatever Child loads the first time will retain
            // even on change of model.name
            if let text = model.name {
                Child(text: text)
            }
            
            // proof that model.name changes are in fact updating other state
            Text("\(model.name ?? "<waiting>")")
        }
        .onAppear {
            model.name = "first change of optionality works"
            loop()
        }
    }
    
    @State var count = 0
    func loop() {
        async(after: 1) {
            count += 1
            model.name = "updated: \(count)"
            loop()
        }
    }
}

func async(_ queue: DispatchQueue = .main,
           after: TimeInterval,
           run work: @escaping () -> Void) {
    queue.asyncAfter(deadline: .now() + after, execute: work)
}

struct OptionalEditingPreview: PreviewProvider {
    static var previews: some View {
        Parent()
    }
}
Logan
  • 52,262
  • 20
  • 99
  • 128
  • If I'm perhaps missing something about proper SwiftUI architecture, would appreciate also pointers to docs or architectures that cover this – Logan Oct 22 '21 at 18:28

2 Answers2

13

Child should take a Binding to the non-optional string, rather than using @State, because you want it to share state with its parent:

struct Child: View {
    // within Child, I'd like the value to be NonOptional
    @Binding var text: String

    var body: some View {
        TextField("OK: ", text: $text).multilineTextAlignment(.center)
    }
}

Binding has an initializer that converts a Binding<V?> to Binding<V>?, which you can use like this:

            if let binding = Binding<String>($model.name) {
                Child(text: binding)
            }

If you're getting crashes from that, it's a bug in SwiftUI, but you can work around it like this:

            if let text = model.name {
                Child(text: Binding(
                    get: { model.name ?? text },
                    set: { model.name = $0 }
                ))
            }
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • thanks, rob, going to award this one since it has the original if let mostly maintained – Logan Oct 22 '21 at 18:45
  • heyoo, this will cause crashes if the binding is later set to `nil`, needs to wrap a binding for some reason like one of the things here: https://stackoverflow.com/questions/63958106/exception-when-setting-an-optional-binding-to-nil-in-swiftui-after-checking – Logan Oct 22 '21 at 21:00
  • I've added a workaround you can try. – rob mayoff Oct 22 '21 at 21:06
  • oh shoot yea I meant to tell you that was the workaround! – Logan Oct 22 '21 at 21:08
  • hey, just commenting here that there are some sort of weird quirks around this and for users to be careful. My current theory is that when we instantiate this way, outside of the swiftui context, it's no longer bound to the view and creates multiple binding instances. The bindings properly point back to `model.name`, but some views may disconnect from the binding chain and not receive updates which can occasionally manifest as strange behavior. – Logan Nov 02 '21 at 10:23
  • 1
    `if let binding = Binding($model.name)` — great solution for Optimal data type. Thank's Rob! – Nick Rossik Jul 06 '23 at 12:52
2

Bind your var like this. Using custom binding and make your child view var @Binding.

struct Child: View {
    @Binding var text: String //<-== Here
   // Other Code 

if model.name != nil {
   Child(text: Binding($model.name)!)
}
Raja Kishan
  • 16,767
  • 2
  • 26
  • 52
  • 1
    Swift has a better mechanism for dealing with unwrapping optionals: `if let`. – Cristik Oct 22 '21 at 18:59
  • 1
    hey, noting here that this will create crashes if the value is set to `nil` elsewhere.. your original answer with the `get {} set` is correct https://stackoverflow.com/questions/63958106/exception-when-setting-an-optional-binding-to-nil-in-swiftui-after-checking – Logan Oct 22 '21 at 20:59
  • With Xcode 12.5.1 it's not crashing. – Raja Kishan Oct 23 '21 at 10:56
  • @RajaKishan it does crash if later set to nil, which is what Logan is referring to here. – Rick Nov 06 '22 at 19:52