Search code examples
iosswiftswiftui

StateObject's optional properties with TextFields


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

Solution

  • 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 {
    
        @ObservedObject 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)