2

I'm curious, how do we specify a binding to State data that is part of an optional? For instance:

struct NameRecord {
    var name = ""
    var isFunny = false
}

class AppData: ObservableObject {
    @Published var nameRecord: NameRecord?
}

struct NameView: View {
    @StateObject var appData = AppData()
    
    var body: some View {
        Form {
            if appData.nameRecord != nil {
                // At this point, I *know* that nameRecord is not nil, so
                // I should be able to bind to it.
                TextField("Name", text: $appData.nameRecord.name)
                Toggle("Is Funny", isOn: $appData.nameRecord.isFunny)
            } else {
                // So far as I can tell, this should never happen, but
                // if it does, I will catch it in development, when
                // I see the error message in the constant binding.
                TextField("Name", text: .constant("ERROR: Data is incomplete!"))
                Toggle("Is Funny", isOn: .constant(false))
            }
        }
        .onAppear {
            appData.nameRecord = NameRecord(name: "John")
        }
    }
}

I can certainly see that I'm missing something. Xcode gives errors like Value of optional type 'NameRecord?' must be unwrapped to refer to member 'name' of wrapped base type 'NameRecord') and offers some FixIts that don't help.

Based on the answer from the user "workingdog support Ukraine" I now know how to make a binding to the part I need, but the solution doesn't scale well for a record that has many fields of different type.

Given that the optional part is in the middle of appData.nameRecord.name, it seems that there might be a solution that does something like what the following function in the SwiftUI header might be doing:

public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> Binding<Subject> { get }

My SwiftFu is insufficient, so I don't know how this works, but I suspect it's what is doing the work for something like $appData.nameRecord.name when nameRecord is not an optional. I would like to have something where this function would result in a binding to .constant when anything in the keyPath is nil (or even if it did a fatalError that I would avoid with conditionals as above). It would be great if there was a way to get a solution that was as elegant as Jonathan's answer that was also suggested by workingdog for a similar situation. Any pointers in that area would be much appreciated!

Curious Jorge
  • 354
  • 1
  • 9
  • Does this answer your question? [SwiftUI Optional TextField](https://stackoverflow.com/questions/57021722/swiftui-optional-textfield) – workingdog support Ukraine Feb 06 '23 at 02:27
  • It's useful, and perhaps heading in the right direction. But it's not dealing with the keyPath aspect of the problem. I will edit the question to make it clearer. But thanks for the interesting link that I hadn't found! – Curious Jorge Feb 06 '23 at 03:10
  • 1
    `I don't think the solutions I've seen so far on StackOverflow would work.`, give it a try, there are a few alternatives to choose from. – workingdog support Ukraine Feb 06 '23 at 03:54
  • A simple solution is a non-optional placeholder: `@Published var nameRecord = NameRecord()` – vadian Feb 06 '23 at 21:02
  • SwiftUI encourages you to use non-optional types as much as possible. There are helper patterns like PropertyWrapper or enum with associated values. – vadian Feb 06 '23 at 21:35
  • Thanks @vadian, that would work. It's actually where I started but because I'm generalising using generics, when [enquiring about a protocol for it](http://stackoverflow.com/q/75356268/14840926), I was convinced that using an optional was a better expression of my data - so here I am exploring how to work around swiftUI's binding issues when I express the data correctly using an optional. – Curious Jorge Feb 06 '23 at 22:05

2 Answers2

3

Binding has a failable initializer that transforms a Binding<Value?>.

if let nameRecord = Binding($appData.nameRecord) {
  TextField("Name", text: nameRecord.name)
  Toggle("Is Funny", isOn: nameRecord.isFunny)
} else {
  Text("Data is incomplete")
  TextField("Name", text: .constant(""))
  Toggle("Is Funny", isOn: .constant(false))
}

Or, with less repetition:

if appData.nameRecord == nil {
  Text("Data is incomplete")
}

let bindings = Binding($appData.nameRecord).map { nameRecord in
  ( name: nameRecord.name,
    isFunny: nameRecord.isFunny
  )
} ?? (
  name: .constant(""),
  isFunny: .constant(false)
)

TextField("Name", text: bindings.name)
Toggle("Is Funny", isOn: bindings.isFunny)
  • 1
    Oh, that's perfect! I think that solves it quite nicely! It would be even more concise for me, because I wouldn't bother with the TextField and the Toggle in the `else` part. In practice, I should never have a nil, so the main roadblock was that the `$` notation couldn't get past the optional. – Curious Jorge Feb 06 '23 at 21:31
  • Actually, working with it a bit more, considering that getting a nil is impossible (so far as I can tell) in my situation, I can avoid a level of "if" bracing by doing the following: `let bindings = (Binding($appData.nameRecord) ?? .constant(NameRecord()))!` I hope that's helpful. – Curious Jorge Feb 10 '23 at 01:13
  • I'm not terribly happy with that force-unwrap, by the way. If someone can suggest a more readable way of getting the same result, I'd greatly appreciate it! – Curious Jorge Feb 10 '23 at 01:15
  • The `!` isn't doing anything though… . `let nameRecord = Binding($appData.nameRecord) ?? .constant(.init())` –  Feb 10 '23 at 04:30
  • You are correct! I thought I needed the `!` because of a subtle mistake (that I've now found) in my larger code base. But this is great! I wonder if you could edit your answer so that the first solution you suggest just starts with `let nameBinding = Binding($appData.nameRecord) ?? .constant(.init(name: "Data is incomplete"))` and dropped the if-else. That would give us a super compact and elegant solution. (Of course, there's a bit more editing in code and text to accommodate that.) – Curious Jorge Feb 11 '23 at 19:09
  • That wouldn’t make sense unless you amended your question, as well. It might be a good solution but it’s not the same result. –  Feb 11 '23 at 23:49
  • I've changed the code in the question. Is that what you meant? – Curious Jorge Feb 12 '23 at 03:00
  • Hey @Jessy, in case you're too busy to be bothered with this, would it be OK if I edited your answer to something that I feel will give the best/quickest information to readers? I don't know what the etiquette is on SO for that sort of thing. It looks like I can do the edits myself, so I'm considering this path. – Curious Jorge Feb 18 '23 at 22:32
0

First of all, full props to @Jessy for the essence of the solution in his responses. I'm posting this answer as an alternative that gives a concise answer to the question, but also includes other information for readers.

Essence of Solution

Binding has a failing initializer for an optional value, that fails (ie returns a nil) if the optional value is nil, but otherwise returns a normal binding to the wrapped value.

For example:

let nameBinding = Binding($appData.nameRecord)
if let nameBinding {
    TextField("Name", text: nameBinding.name)    // note absence of $ here
} else {
    Text("SwiftUI cannot bind to appData.nameRecord, because it is nil!")
}

All that remains is to decide on a good way to use this solution in our code.

Concise Implementation

struct NameView: View {
    @StateObject var appData = AppData()
    
    var body: some View {
        let nameBinding = Binding($appData.nameRecord) ?? .constant(.init(name: "DATA ERROR!"))
        Form {
            TextField("Name", text: nameBinding.name)
            Toggle("Is Funny", isOn: nameBinding.isFunny)
        }
        .onAppear {
            appData.nameRecord = NameRecord(name: "John")
        }
    }
}

To explain, in this particular situation, it is pretty much guaranteed that the optional is never nil (given the code in .onAppear), so we don't have to spend much code dealing with the edge-case of the failed binding. So we just structure the code so that the nil-case is dealt with immediately by using a binding to a constant NameRecord with an error message inserted into the data whenever data-that-should-never-be-nil is (somehow) nil. You can view what this would look like to the user if you comment out the .onAppear code.

This method makes the code concise and lets you clearly see the SwiftUI structures that are most relevant to you.

Alternate Implementation

The above method is pretty safe, assuming that onAppear works as we expect. But perhaps you want to account for the possibility that it doesn't (eg due to an unexpected change in how onAppear works in the future) or simply because your data structures don't lend themselves to the above solution. In those cases, you could consider something like this:

struct NameView: View {
    @StateObject var appData = AppData()
    
    var body: some View {
        let nameBinding = Binding($appData.nameRecord)
        Form {
            if let nameBinding {
                TextField("Name", text: nameBinding.name)
                Toggle("Is Funny", isOn: nameBinding.isFunny)
            } else {
                Text("DATA ERROR!")
            }
        }
        .onAppear {
            appData.nameRecord = NameRecord(name: "John")
        }
    }
}

This alternate implementation gives us an elegant way of replacing our entire UI with whatever message we want to give the user in the failing situation. The only price we have to pay is the additional noise from an extra conditional in our code.

Further Reading

If this is the first time you are seeing a constant binding, please watch the 2020 WWDC video Structure your app for SwiftUI previews

Make sure you are comfortable with adding custom bindings, as they will be the solution to a whole class of tricky swiftUI binding situations, including the above situation (though less favoured as too complicated, here).

For situations where you've got @State data that is optional, there's an even simpler solution you can use.

Curious Jorge
  • 354
  • 1
  • 9