Ad

How To Inject A Model From The Environment Into A ViewModel In SwiftUI

- 1 answer

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)

        }
    }
}
Ad

Answer

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 ListViewand 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 nestedObservableObjects. 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.

Ad
source: stackoverflow.com
Ad