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