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)

        }
    }
}
Rick Free
  • 143
  • 1
  • 10
  • Does this answer your question https://stackoverflow.com/a/60605529/12299030? Or this one https://stackoverflow.com/a/60019566/12299030? – Asperi Feb 02 '22 at 19:21
  • @Asperi Perhaps, and I did spend a lot of time with both of those, but had trouble using either in full solution. Thx – Rick Free Feb 02 '22 at 19:33

2 Answers2

4

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.

jnpdx
  • 45,847
  • 6
  • 64
  • 94
  • 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! – Rick Free Feb 02 '22 at 19:47
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)
        
    }
}
malhal
  • 26,330
  • 7
  • 115
  • 133
  • 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. – Rick Free Feb 04 '22 at 19:16