skip to Main Content

I have a FormView with a StateObject whose properties I’m binding to textfields (hopefully two-way binding)

Form View

struct FormView: View {
    
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        VStack {
            HStack {
                TextField("name", text: $viewModel.user.name)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .font(.system(size: 16))
    ...

User

struct User: Identifiable, Codable {
    var id: Int
    var name: String?

    init(id: Int) {
        self.id = id
    }
}

ViewModel

class ViewModel: ObservableObject {
    // I had it as `User?` but `View` doesn't like it, so had to hardcode a default value
    @Published var user: User = User(id: 0) 
    ...
}

Error

Cannot convert value of type ‘Binding<String?>’ to expected argument type ‘Binding’

Attempt

 // tried to assign default values

TextField("Name", text: $viewModel.user.name ?? "") 

Question,

How to resolve the optional problem: I can’t have all the properties non-optional and even the User object can be optional.

How to bind optional properties to textfields

Attempt 2

This worked for me.

TextField("Name", text: Binding(
    get: { observable.user.name ?? "" },
    set: { observable.user.name = $0 }))
    .textFieldStyle(RoundedBorderTextFieldStyle())
    .font(.system(size: 16))

2

Answers


  1. The problem is because the name is not a binding value, the User struct is a binding value so it can’t resolve that. To fix it you need to make the text Bindable.

    class TestUser: ObservableObject {       // Change struct to class
        @Published var name = "John, Doe"
    }
     
    class TestVM: ObservableObject {
        @Published var testUser = TestUser()
    }
     
    struct PlaygroundView: View {
        @StateObject var testVM = TestVM()
        
        var body: some View {
            TextField("Hello, World", text: $testVM.testUser.name)
        }
    }
    

    Notice that I’m still using @StateObject here, so it is two-way binding.
    A few other things to note.

    Structs vs Classes

    Structs are non-mutable, meaning you can’t directly change a property that exists on a struct unless it is marked @State or @Binding. The wrapper @Published cannot be added to a struct because it cannot be an ObservableObject. Classes, conversely, are "Reference Types", which means when you pass them around through args/params, you’re getting a reference to the real object in memory. They are mutable, and their properties can be changed. They can also contain @Published properties, if it’s marked ObservableObject.

    @Published vs @State vs @Binding

    @Published – exists only on class objects, which are observable.
    @State – Exists on a struct, and updates the view based on the value being updated by the view or model. This will trigger a view redraw.
    @Binding – Same functionality as @State but you would pass an @State in args/params, so that the consuming view/object can update a parent, yet maintain it’s own state.

    @StateObject vs @ObservedObject

    @StateObject is intended to indicate ownership of the observable object. When you mark a property with @StateObject, SwiftUI will create and exclusively manage the lifecycle of the object, much in the way it does for @State.

    Remember, @StateObject should be used only for objects that are owned by this view.

    @ObservedObject, on the other hand, merely establishes a connection to an observable object without implying ownership. The object in question is expected to be created and owned elsewhere in your program, not within the view that observes it. Because if you use this, it lacks ownership, when you update from your view it will not explicitly update the same way that @State would. Only when the owning object updates it would it then update any views that use is. Ergo why it gives the appearance of one-way.

    Final Note:

    • The property wrappers can be passed by using Binding<Type> such as Binding<Int>. You can use that anywhere you’d normally use it such as an initializer. Sometimes you might run into an issue where you pass an Binding<Any> as a parameter and you need to set it to a bound value, or initial value for State. You can use Binding(initialValue: _) or State(initialValue: _) or Published(InitialValue: _) to accommodate initialization w/ those wrapped properties.
    Login or Signup to reply.
  2. The problem you are seeing has to do with the Optional String Binding you are trying to pass into the TextField. TextField is expecting a String Binding (even if its empty), so to get that to be valid, you need the name of the User to be non-optional OR you need to create a Binding from an optional String.

    You can do this in a few ways, but one quick way would be to create an extension that does it for us:

    extension Binding where Value == String? {
        func orEmpty() -> Binding<String> {
            return .init(
                get: { self.wrappedValue ?? "" },
                set: { self.wrappedValue = $0 }
            )
        }
    }
    

    That should be enough to let you use it in your TextField like so:

    TextField("Placeholder", text: $viewModel.user.name.orEmpty())
    

    Also, as a side note I would mention that your ViewModel holds a User, so you should be injecting it. I imagine at some point, you will be able to either select different users, or maybe you are going into a specific View that adjust the User information. If you update your ViewModel, you can accept a user in like so:

    class ViewModel: ObservableObject {
        @Published var user: User
        
        init(user: User = User(id: 123)) {
            self.user = user
        }
    }
    

    And finally, your View can then be initialized with a ViewModel. This means that you would create the ViewModel before you are shown the FormView so that the data you are manipulating is coming from the Model (or the source of truth):

    struct FormView: View {
    
        @StateObject var viewModel: ViewModel
    
        ...
    }
    

    All of this should get you to the point where whenever you want to see the FormView, all you need to do is get the User and pass it in – or if you are creating a user, you can have it have default values:

    New User:

    FormView(viewModel: ViewModel())
    
    

    Existing User:

    FormView(viewModel: ViewModel(user: existingUser)
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search