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.