3

The problem

TL;DR: A String I'm trying to bind to inside TextField is nested in an Optional type, therefore I cannot do that in a straightforward manner. I've tried various fixes listed below.

I'm a simple man and my use case is rather simple - I want to be able to use TextField to edit my object's name.
The difficulty arises due to the fact that the object might not exist.

The code

Stripping the code bare, the code looks like this.
Please note that that the example View does not take Optional into account

model

struct Foo {
  var name: String
}

extension Foo {
  var sampleData: [Foo] = [
    Foo(name: "Bar")
  ]
}

view

again, in the perfect world without Optionals it would look like this

struct Ashwagandha: View {
  @StateObject var ashwagandhaVM = AshwagandhaVM()
  var body: some View {
    TextField("", text: $ashwagandhaVM.currentFoo.name)
  }
}

view model

I'm purposely not unwrapping the optional, making the currentFoo: Foo?

class AshwagandhaVM: ObservableObject {
  @Published var currentFoo: Foo?

  init() {
    self.currentFoo = Foo.sampleData.first
  }
}

The trial and error

Below are the futile undertakings to make the TextField and Foo.name friends, with associated errors.

Optional chaining

The 'Xcode fix' way

TextField("", text: $ashwagandhaVM.currentFoo?.name)
gets into the cycle of fixes on adding/removing "?"/"!"

The desperate way

TextField("Change chatBot's name", text: $(ashwagandhaVM.currentFoo!.name)
"'$' is not an identifier; use backticks to escape it"

Forced unwrapping

The dumb way

TextField("", text: $ashwagandhaVM.currentFoo!.name)
"Cannot force unwrap value of non-optional type 'Binding<Foo?>'"

The smarter way

if let asparagus = ashwagandhaVM.currentFoo.name {
  TextField("", text: $asparagus.name)
}

"Cannot find $asparagus in scope"

Workarounds

My new favorite quote's way

No luck, as the String is nested inside an Optional; I just don't think there should be so much hassle with editing a String.

The rationale behind it all

i.e. why this question might be irrelevant

I'm re-learning about the usage of MVVM, especially how to work with nested data types. I want to check how far I can get without writing an extra CRUD layer for every property in every ViewModel in my app. If you know any better way to achieve this, hit me up.

  • A number of strange attempts in here, but for starters if you do `if let asparagus = ashwagandhaVM.currentFoo.name` then `asparagus` already represents the `name` property, so you can't use `asparagus.name`. Also there is no binding to `$asparagus` as it's a local variable you've just created. – flanker Aug 25 '22 at 19:00
  • Yes, my mistake. However, the results in this case are equal for ‘asparagus: Foo’ and ‘asparagus: String’. – Jordan Niedzielski Aug 25 '22 at 19:06
  • I think your intent is unclear, you have a view where you want to edit a property on a type but what part is responsible for creating an instance of that type (Foo)? And what does it mean when currentFoo is nil, is that a specific state for the view or view model? – Joakim Danielson Aug 25 '22 at 20:41
  • The creation of the ‘Foo’ instance is irrelevant here. Assume that there is something taking care of it, as well as that there are cases in which the ‘currentFoo’ is ‘nil’. – Jordan Niedzielski Aug 25 '22 at 21:19
  • 1
    You say that you want to use MVVM but then your code seems to be trying to bypass the view model part. You are trying to bind a text field directly to your model. Implement the view model and have it mediate the update to the model. Also, it seems that you may even have a problem with the navigational structure of your app. You shouldn't end up in a screen where you can change foo's name if there is no foo. It means the user is entering information that cannot be saved. You shouldn't have let them get here. – Paulw11 Aug 25 '22 at 21:36
  • 1
    Ok, on re-reading, it seems that you have an optional in your view model. Don't do this. Your view model should suit your view, not your model. There is no reason for it to contain an optional. You can have your view model create the missing object and provide it back to the model or, as in my last comment, change your app so that you can't get here if the object that the view needs doesn't exist. You could also move the text field into a sub view that you hide or disable if the object is null – Paulw11 Aug 25 '22 at 21:40
  • 3
    Working with optionals is difficult in SwiftUI, but it really shouldn't arise because you should structure your code so that you don't need to. It is the UI layer, so why would you ever ask the user to interact with something that doesn't exist? – Paulw11 Aug 25 '22 at 21:41

4 Answers4

1

Folks in the question comments are giving good advice. Don't do this: change your view model to provide a non-optional property to bind instead.

But... maybe you're stuck with an optional property, and for some reason you just need to bind to it. In that case, you can create a Binding and unwrap by hand:

class MyModel: ObservableObject {
    @Published var name: String? = nil
    
    var nameBinding: Binding<String> {
        Binding {
            self.name ?? "some default value"
        } set: {
            self.name = $0
        }
    }
}

struct AnOptionalBindingView: View {
    @StateObject var model = MyModel()
    
    var body: some View {
        TextField("Name", text: model.nameBinding)
    }
}

That will let you bind to the text field. If the backing property is nil it will supply a default value. If the backing property changes, the view will re-render (as long as it's a @Published property of your @StateObject or @ObservedObject).

asyncawait
  • 575
  • 1
  • 9
1

There is a handy Binding constructor that converts an optional binding to non-optional, use as follows:

struct ContentView: View {
   @StateObject var store = Store()

   var body: some View {
    if let nonOptionalStructBinding = Binding($store.optionalStruct) {
        TextField("Name", text: nonOptionalStructBinding.name)
    }
    else {
        Text("optionalStruct is nil")
    }
  }
}

Also, MVVM in SwiftUI is a bad idea because the View data struct is better than a view model object.

malhal
  • 26,330
  • 7
  • 115
  • 133
  • Would you care to elaborate on this statement? In my SwiftUI infancy, I tried to accomplish all the business logic in Views. That was a terrible idea that pointed me to actual app architecture. What do you mean by a 'View data struct'? – Jordan Niedzielski Aug 26 '22 at 10:45
  • 1
    `View` is a data struct, i.e. a value type, negligble overheards. When there is a state change, SwiftUI recreates all of these structs, diffs them from the last time and uses the differences to create/update actual UIViews on screen. Because these structs are constantly created and thrown away they provide property wrappers like `@State` that allow the structs to have memory, essentially making the View struct behave like a reference type, i.e. object. This is the actual architecture, worth putting some time into learning it. – malhal Aug 26 '22 at 11:03
  • 2
    I believe the problem you faced can simply be solved by putting your logic into a custom `@State` struct, you need to learn about `mutating func` though. This is the approach Apple recommend in Data Essentials in SwiftUI WWDC 2020 around 4mins in. – malhal Aug 26 '22 at 11:05
  • 1
    By the way I'm talking about view data here. For model data, that should go in one `environmentObject` usually called a store that has all the logic for persisting (or syncing) the model structs that are `@Published` properties. This is why I renamed your `@StateObject` to be a store. – malhal Aug 26 '22 at 11:08
  • The reason I've created this example is to find out how to mutate the data the good way. In one of my projects, I've put the updating logic into store-ish objects. The properties of store were `@Published`. This logic called `mutating func` of types and changed their properties' values. I hit a roadblock, however, due to the UI not updating predictably. I will take a look at the recommended WWDC video. – Jordan Niedzielski Aug 26 '22 at 11:13
0

I think you should change approach, the control of saving should remain inside the model, in the view you should catch just the new name and intercept the save button coming from the user:

enter image description here

class AshwagandhaVM: ObservableObject {
    @Published var currentFoo: Foo?

    init() {
        self.currentFoo = Foo.sampleData.first
    }
    func saveCurrentName(_ name: String) {
        if currentFoo == nil {
            Foo.sampleData.append(Foo(name: name))
            self.currentFoo = Foo.sampleData.first(where: {$0.name == name})
        }
        else {
            self.currentFoo?.name = name
        }
    }
}

struct ContentView: View {
    @StateObject var ashwagandhaVM = AshwagandhaVM()
    @State private var textInput = ""
    @State private var showingConfirmation = false
    
    var body: some View {
        VStack {
            TextField("", text: $textInput)
                .padding()
                .textFieldStyle(.roundedBorder)
            Button("save") {
                showingConfirmation = true
            }
            .padding()
            .buttonStyle(.bordered)
            .controlSize(.large)
            .tint(.green)
            .confirmationDialog("are you sure?", isPresented: $showingConfirmation, titleVisibility: .visible) {
                Button("Yes") {
                    confirmAndSave()
                }
                Button("No", role: .cancel) { }
            }
            //just to check
            if let name = ashwagandhaVM.currentFoo?.name {
                Text("in model: \(name)")
                    .font(.largeTitle)
            }
        }
        .onAppear() {
            textInput = ashwagandhaVM.currentFoo?.name ?? "default"
        }
    }
    
    func confirmAndSave() {
        ashwagandhaVM.saveCurrentName(textInput)
    }
}

UPDATE

do it with whole struct

struct ContentView: View {
    @StateObject var ashwagandhaVM = AshwagandhaVM()
    @State private var modelInput = Foo(name: "input")
    @State private var showingConfirmation = false
    
    var body: some View {
        VStack {
            TextField("", text: $modelInput.name)
                .padding()
                .textFieldStyle(.roundedBorder)
            Button("save") {
                showingConfirmation = true
            }
            .padding()
            .buttonStyle(.bordered)
            .controlSize(.large)
            .tint(.green)
            .confirmationDialog("are you sure?", isPresented: $showingConfirmation, titleVisibility: .visible) {
                Button("Yes") {
                    confirmAndSave()
                }
                Button("No", role: .cancel) { }
            }
            //just to check
            if let name = ashwagandhaVM.currentFoo?.name {
                Text("in model: \(name)")
                    .font(.largeTitle)
            }
        }
        .onAppear() {
            modelInput = ashwagandhaVM.currentFoo ?? Foo(name: "input")
        }
    }
    
    func confirmAndSave() {
        ashwagandhaVM.saveCurrentName(modelInput.name)
    }
}
Simone Pistecchia
  • 2,746
  • 3
  • 18
  • 30
  • This is the kind of solution that potentially creates an additional CRUD on every property, and I was looking to avoid that. If, however, it's a necessity, I shall go with that. – Jordan Niedzielski Aug 26 '22 at 10:52
  • Isn't defining additional functions in `View` layer considered an anti-pattern? – Jordan Niedzielski Aug 26 '22 at 10:59
  • yes, but I copied a my project that I needed some additional check. Check the update, you can use the same approach with a model Foo to avoid to create too many var in view. The CRUD should be only inside the model, so every View doesn't need to check again – Simone Pistecchia Aug 26 '22 at 11:17
0

I've written a couple generic optional Binding helpers to address cases like this. See this thread.

It lets you do if let unwrappedBinding = $optional.withUnwrappedValue { or TestView(optional: $optional.defaulting(to: someNonOptional).

Pavel Orel
  • 144
  • 10