Home > Back-end >  In SwiftUI, should bindings go in the ViewModel?
In SwiftUI, should bindings go in the ViewModel?

Time:12-16

I might be misunderstanding a couple of key concepts, but not seeing how to properly handle view bindings and retain proper MVVM structure with SwiftUI.

Let's take this example of two fields that affect the text above them:

struct ContentView: View {
    @State var firstName =  "John"
    @State var lastName = "Smith"

    var body: some View {
        VStack {
            Text("first name: \(firstName)")
            Text("last name: \(lastName)")

            ChangeMeView(firstName: $firstName, lastName: $lastName)
        }

    }

}

struct ChangeMeView: View {
    @Binding var firstName: String
    @Binding var lastName: String

    var body: some View {
        VStack {
            TextField("first name", text: $firstName)
            TextField("last name", text: $lastName)
        }
    }

}

Works as expected. However, if I wanted to follow MVVM, wouldn't I need to move (firstName, lastName) to a ViewModel object within that view? enter image description here

That means that the view starts looking like this:

struct ContentView: View {
    @State var firstName =  "John"
    @State var lastName = "Smith"

    var body: some View {
        VStack {
            Text("first name: \(firstName)")
            Text("last name: \(lastName)")

            ChangeMeView(firstName: $firstName, lastName: $lastName)
        }

    }

}

struct ChangeMeView: View {
//    @Binding var firstName: String
//    @Binding var lastName: String

    @StateObject var viewModel: ViewModel

    init(firstName: Binding<String>, lastName: Binding<String>) {
        //from https://stackoverflow.com/questions/62635914/initialize-stateobject-with-a-parameter-in-swiftui#62636048
        _viewModel = StateObject(wrappedValue: ViewModel(firstName: firstName, lastName: lastName))
    }


    var body: some View {
        VStack {
            TextField("first name", text: viewModel.firstName)
            TextField("last name", text: viewModel.lastName)
        }
    }

}


class ViewModel: ObservableObject {
    var firstName: Binding<String>
    var lastName: Binding<String>

    init(firstName: Binding<String>, lastName: Binding<String>) {
        self.firstName = firstName
        self.lastName = lastName
    }
}

This works but feels to me like it might be hacky. Is there another smarter way to pass data (like bindings) to a view while retaining MVVM?

Here's an example where I try using @Published. While it runs, the changes don't update the text:

struct ContentView: View {
     var firstName =  "John"
     var lastName = "Smith"

    var body: some View {
        VStack {
            Text("first name: \(firstName)")
            Text("last name: \(lastName)")

            ChangeMeView(viewModel: ViewModel(firstName: firstName, lastName: lastName))
        }

    }

}

struct ChangeMeView: View {
    @ObservedObject var viewModel: ViewModel

    init(viewModel: ViewModel) {
        self.viewModel = viewModel
        //apple approved strategy from https://stackoverflow.com/questions/62635914/initialize-stateobject-with-a-parameter-in-swiftui#62636048
//        _viewModel = StateObject(wrappedValue: ViewModel(firstName: firstName, lastName: lastName))
    }


    var body: some View {
        VStack {
            TextField("first name", text: $viewModel.firstName)
            TextField("last name", text: $viewModel.lastName)
        }
    }

}


class ViewModel: ObservableObject {
    @Published var firstName: String
    @Published var lastName: String

    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}

CodePudding user response:

You are missing the model part of MVVM. In a simple example, as you have here, you probably don't need full MVVM - You can just share a view model between your two views.

However, here is how you can use a model and a view model. The model and view model classes first:

The model just declares two @Published properties and is an @ObservableObject

Model

class Model: ObservableObject {
    
    @Published var firstName: String = ""
    @Published var lastName: String = ""
    
}

The ContentViewModel is initialised with the Model instance and simply exposes the two properties of the model via computed properties.

ContentViewModel

class ContentViewModel {
    
    let model: Model
    
    var firstName:String {
        return model.firstName
    }
    var lastName:String {
        return model.lastName
    }
    
    init(model: Model) {
        self.model = model
    }
    
}

ChangeMeViewModel is a little more complex - It needs to both expose the current values from the Model but also update the values in the Model when the values are set in the ChangeMeViewModel. To make this happen we use a custom Binding. The get methods are much the same as the ContentViewModel - They just accesses the properties from the Model instance. The set method takes the new value that has been assigned to the Binding and updates the properties in the Model

ChangeMeViewModel

class ChangeMeViewModel {
    
    let model: Model
    
    var firstName: Binding<String>
    var lastName: Binding<String>
    
    init(model: Model) {
        self.model = model
        self.firstName = Binding(
            get: {
                return model.firstName
            },
            set: { newValue in
                model.firstName = newValue
            }
        )
        
        self.lastName = Binding(
            get: {
                return model.lastName
            },
            set: { newValue in
                model.lastName = newValue
            }
        )
    }
    
}

Finally we need to create the Model in the App file and use it with the view models in the view hierarchy:

App

@main
struct MVVMApp: App {
    @StateObject var model = Model()
    var body: some Scene {
        WindowGroup {
            ContentView(viewModel: ContentViewModel(model: model))
        }
    }
}

ContentView

struct ContentView: View {
    
    let viewModel: ContentViewModel
    
    var body: some View {
        VStack {
            Text("first name: \(self.viewModel.firstName)")
            Text("last name: \(self.viewModel.lastName)")
            
            ChangeMeView(viewModel:ChangeMeViewModel(model:self.viewModel.model))
        }
    }
}

ChangeMeView

struct ChangeMeView: View {
    
    let viewModel: ChangeMeViewModel
    
    var body: some View {
        VStack {
            TextField("first name", text: self.viewModel.firstName)
            TextField("last name", text: self.viewModel.lastName)
        }
    }
}
  • Related