Search code examples
iosswiftswiftuioptional-binding

Using optional binding when SwiftUI says no


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.


Solution

  • 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)
        }
    }