EnvironmentObject - Update from ViewModel

In one of my SwiftUI project, I have an EnvironmentObject instantiated at the level of my RootView and used across all my application.

At some point, I have a ViewModel that make some business logic and need to access this environmentObject (to increment it).

You can find the whole example in my Github repo and all the details inside the README.

Do you have any advise how to manage it?

I tried several things without success like, for example:

  • Having a @Published property in my ViewModel
  • Using Combine sink method, be notified in the View when the property changed
  • At this point access the @EnvironmentObject

Could you update Counter after calling process on the ViewModel in the button action?
Or supply the counter to the ViewModel so that it can do the increment itself?

Don't instantiate your observable object in your view (every time SwiftUI calls your view body, you are creating a new object). Use @StateObject in your App struct if you are using the SwiftUI app lifecyle or instantiate one in your app delegate if you are not using the SwiftUI app lifecyle. Then pass it down to your root view.

2 Likes

FirstView.swift:15

@ObservedObject var viewModel = FirstViewModel()

That's a bad practise. Use @ObservedObject for references that you receive from the parent view (props). If your view owns this object, it should be @StateObject to make sure that FirstViewModel is created only once when view is inflated. Otherwise instance of FirstViewModel will be re-created when parent view re-renders for whatever unrelated reason.

RootView.swift:30

RootView()
      .environmentObject(Counter())

Similar concerns. You don't have control over when body is called, and Counter() might be re-created by accident. Better to save it into @StateObject and read it from @StateObject.

And now back to the actual question.

I think if you could pass a reference to Counter when initialising FirstViewModel, that would solve your problem, right? Conceptually, it is valid to initialise state based on props and/or environment. In SwiftUI you can use initializer arguments to provide custom initialisers for @State and @StateObject. But unfortunately you cannot access environment at this stage.

Workaround is to break down your view into two. First one will be responsible solely for reading required environment data and passing them as props to the second one. The second one will be an actual implementation.

class FirstViewModel : ObservableObject {
    @Published var result = 0
    let counter: Counter

    init(counter: Counter) {
        self.counter = counter
    }

    func process() {
        result = 100
        counter.increment(nb: result)
  }
}

struct FirstView: View {
    @EnvironmentObject var counter: Counter

    var body: some View {
        FirstViewImpl(counter: counter)
    }
}

struct FirstViewImpl: View {
    @ObservedObject var counter: Counter
    @StateObject var viewModel: FirstViewModel

    init(counter: Counter) {
        self.counter = counter
        _viewModel = StateObject<FirstViewModel>(wrappedValue: FirstViewModel(counter: counter))
    }

    var body: some View {
        VStack {
            Text("Counter: \(counter.value)").padding()
            Button("Do stuff and Increment") {
                viewModel.process()
            }
        }
    }
}

Also, to have a completely robust implementation, you may want to handle the case when identity of the counter changes:

    var body: some View {
        VStack {
            Text("Counter: \(counter.value)").padding()
            Button("Do stuff and Increment") {
                viewModel.process()
            }
        }
        .onChange(of: ObjectIdentifier(counter)) { _ in
            viewModel.counter = counter
        }
    }
1 Like

Thank you for all your comments.
I think I have something working, you can check the full solution on the GitHub repo.

ContentView : Create the Counter object as a StateObject and inject it as an environmentObject

RootView :

  • Receive the Counter as an EnvironmentObject.
  • Create the FirstViewModel as a StateObject and inject it into the FirstView constructor

FirstView :

  • Receive the Counter as an EnvironmentObject and give it into the constructor of the CounterView
  • Receive the FirstViewModel in the constructor and save it as an ObservedObject

CounterView :

  • Receive the Counter in the constructor and save it as an ObservedObject
  • Create the CounterViewModel as a StateObject (It could be also injected in the constructor and saved as an ObservedObject, like I did for the FirstViewModel. This is for the example)

Does it look good for you ?

Thanks for your feedback.