2

I am trying to MVVM my SwiftUI app, but am unable to find a working solution for injecting a shared Model from @EnvironmentObject into the app's various Views' ViewModels.

The simplified code below creates a Model object in the init() of an example View, but I feel like I am supposed to be creating the model at the top of the app so that it can be shared among multiple Views and will trigger redraws when Model changes.

My question is whether this is the correct strategy, if so how to do it right, and if not what do I have wrong and how do I do it instead. I haven't found any examples that demonstrate this realistically beginning to end, and I can't tell if I am just a couple of property wrappers off, or it I am approaching this completely wrong.

import SwiftUI @main struct DIApp: App { // This is where it SEEMS I should be creating and sharing Model: // @StateObject var dataModel = DataModel() var body: some Scene { WindowGroup { ListView() // .environmentObject(dataModel) } } } struct Item: Identifiable { let id: Int let title: String } class DataModel: ObservableObject { @Published var items = [Item]() init() { items.append(Item(id: 1, title: "First Item")) items.append(Item(id: 2, title: "Second Item")) items.append(Item(id: 3, title: "Third Item")) } func addItem(_ item: Item) { items.append(item) print("DM adding \(item.title)") } } struct ListView: View { // Creating the StateObject here compiles, but it will not work // in a realistic app with other views that need to share it. // It should be an app-wide ObservableObject created elsewhere // and accessible everywhere, right? @StateObject private var vm: ViewModel init() { _vm = StateObject(wrappedValue: ViewModel(dataModel: DataModel())) } var body: some View { NavigationView { List { ForEach(vm.items) { item in Text(item.title) } } .navigationTitle("List") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: Button(action: { addItem() }) { Image(systemName: "plus.circle") } ) } .navigationViewStyle(StackNavigationViewStyle()) } func addItem() { vm.addRandomItem() } } extension ListView { class ViewModel: ObservableObject { @Published var items: [Item] let dataModel: DataModel init(dataModel: DataModel) { self.dataModel = dataModel items = dataModel.items } func addRandomItem() { let newID = Int.random(in: 100..<999) let newItem = Item(id: newID, title: "New Item \(newID)") // The line below causes Model to be successfully updated -- // dataModel.addItem print statement happens -- but Model change // is not reflected in View. dataModel.addItem(newItem) // The line below causes the View to redraw and reflect additions, but the fact // that I need it means I am not doing doing this right. It seems like I should // be making changes to the Model and having them automatically update View. items.append(newItem) } } } 
2

2 Answers 2

6

There are a few different issues here and multiple strategies to handle them.

From the top, yes, you can create your data model at the App level:

@main struct DIApp: App { var dataModel = DataModel() var body: some Scene { WindowGroup { ListView(dataModel: dataModel) .environmentObject(dataModel) } } } 

Notice that I've passed dataModel explicitly to ListView and as an environmentObject. This is because if you want to use it in init, it has to be passed explicitly. But, perhaps subviews will want a reference to it as well, so environmentObject will get it sent down the hierarchy automatically.

The next issue is that your ListView won't update because you have nested ObservableObjects. If you change the child object (DataModel in this case), the parent doesn't know to update the view unless you explicitly call objectWillChange.send().

struct ListView: View { @StateObject private var vm: ViewModel init(dataModel: DataModel) { _vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel)) } var body: some View { NavigationView { List { ForEach(vm.dataModel.items) { item in Text(item.title) } } .navigationTitle("List") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: Button(action: { addItem() }) { Image(systemName: "plus.circle") } ) } .navigationViewStyle(StackNavigationViewStyle()) } func addItem() { vm.addRandomItem() } } extension ListView { class ViewModel: ObservableObject { let dataModel: DataModel init(dataModel: DataModel) { self.dataModel = dataModel } func addRandomItem() { let newID = Int.random(in: 100..<999) let newItem = Item(id: newID, title: "New Item \(newID)") dataModel.addItem(newItem) self.objectWillChange.send() } } } 

An alternate approach would be including DataModel on your ListView as an @ObservedObject. That way, when it changes, the view will update, even if ViewModel doesn't have any @Published properties:

 struct ListView: View { @StateObject private var vm: ViewModel @ObservedObject private var dataModel: DataModel init(dataModel: DataModel) { _dataModel = ObservedObject(wrappedValue: dataModel) _vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel)) } var body: some View { NavigationView { List { ForEach(vm.dataModel.items) { item in Text(item.title) } } .navigationTitle("List") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: Button(action: { addItem() }) { Image(systemName: "plus.circle") } ) } .navigationViewStyle(StackNavigationViewStyle()) } func addItem() { vm.addRandomItem() } } extension ListView { class ViewModel: ObservableObject { let dataModel: DataModel init(dataModel: DataModel) { self.dataModel = dataModel } func addRandomItem() { let newID = Int.random(in: 100..<999) let newItem = Item(id: newID, title: "New Item \(newID)") dataModel.addItem(newItem) } } } 

Yet another object would be using Combine to automatically send objectWilLChange updates when items is updated:

struct ListView: View { @StateObject private var vm: ViewModel init(dataModel: DataModel) { _vm = StateObject(wrappedValue: ViewModel(dataModel: dataModel)) } var body: some View { NavigationView { List { ForEach(vm.dataModel.items) { item in Text(item.title) } } .navigationTitle("List") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: Button(action: { addItem() }) { Image(systemName: "plus.circle") } ) } .navigationViewStyle(StackNavigationViewStyle()) } func addItem() { vm.addRandomItem() } } import Combine extension ListView { class ViewModel: ObservableObject { let dataModel: DataModel private var cancellable : AnyCancellable? init(dataModel: DataModel) { self.dataModel = dataModel cancellable = dataModel.$items.sink { [weak self] _ in self?.objectWillChange.send() } } func addRandomItem() { let newID = Int.random(in: 100..<999) let newItem = Item(id: newID, title: "New Item \(newID)") dataModel.addItem(newItem) } } } 

As you can see, there are a few options (these, and others). You can pick the design pattern that works best for you.

Sign up to request clarification or add additional context in comments.

1 Comment

This gives me some great direction, thanks. I have actually used the Combine pattern successfully and found it simpler and very functional, but wanted to understand the EnvironmentObject way, too. I will see what I can do with your help, but your answer is awesome!
2

You are probably unable to find a working solution because it is not a valid approach. In SwiftUI we do not use MVVM pattern of view model objects. The View data structs are already the view model that SwiftUI uses to create and update actual views like UILabels, etc. on the screen. You should also be aware that when you use property wrappers like @State it makes our super efficient View data struct behave like an object, but without the memory hog of an actual heap object. If you create extra objects then you are slowing SwiftUI down and will lose the magic like dependency tracking etc.

Here is your fixed code:

import SwiftUI @main struct DIApp: App { @StateObject var dataModel = DataModel() var body: some Scene { WindowGroup { ListView() .environmentObject(dataModel) } } } struct Item: Identifiable { let id: Int let title: String } class DataModel: ObservableObject { @Published var items = [Item]() init() { items.append(Item(id: 1, title: "First Item")) items.append(Item(id: 2, title: "Second Item")) items.append(Item(id: 3, title: "Third Item")) } func addItem(_ item: Item) { items.append(item) print("DM adding \(item.title)") } } struct ListView: View { @EnvironmentObject private var dataModel: DataModel var body: some View { NavigationView { List { // ForEach($dataModel.items) { $item in // if you want write access ForEach(dataModel.items) { item in Text(item.title) } } .navigationTitle("List") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: Button(action: { addItem() }) { Image(systemName: "plus.circle") } ) } .navigationViewStyle(StackNavigationViewStyle()) } func addItem() { let newID = Int.random(in: 100..<999) let newItem = Item(id: newID, title: "New Item \(newID)") dataModel.addItem(newItem) } } 

1 Comment

Thanks, that is a very helpful reminder and direction to work in. This is just as helpful, and in a complimentary way, to the accepted answer.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.