How to tell SwiftUI views to bind to nested ObservableObjects
Solution 1
Nested models does not work yet in SwiftUI, but you could do something like this
class SubModel: ObservableObject {
@Published var count = 0
}
class AppModel: ObservableObject {
@Published var submodel: SubModel = SubModel()
var anyCancellable: AnyCancellable? = nil
init() {
anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in
self?.objectWillChange.send()
}
}
}
Basically your AppModel
catches the event from SubModel
and send it further to the View
.
Edit:
If you do not need SubModel
to be class, then you could try something like this either:
struct SubModel{
var count = 0
}
class AppModel: ObservableObject {
@Published var submodel: SubModel = SubModel()
}
Solution 2
Sorin Lica's solution can solve the problem but this will result in code smell when dealing with complicated views.
What seems to better advice is to look closely at your views, and revise them to make more, and more targeted views. Structure your views so that each view displays a single level of the object structure, matching views to the classes that conform to ObservableObject
. In the case above, you could make a view for displaying Submodel
(or even several views) that display's the property from it that you want show. Pass the property element to that view, and let it track the publisher chain for you.
struct SubView: View {
@ObservableObject var submodel: Submodel
var body: some View {
Text("Count: \(submodel.count)")
.onTapGesture {
self.submodel.count += 1
}
}
}
struct ContentView: View {
@EnvironmentObject var appModel: AppModel
var body: some View {
SubView(submodel: appModel.submodel)
}
}
This pattern implies making more, smaller, and focused views, and lets the engine inside SwiftUI do the relevant tracking. Then you don't have to deal with the book keeping, and your views potentially get quite a bit simpler as well.
You can check for more detail in this post: https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/
Solution 3
I wrote about this recently on my blog: Nested Observable Objects. The gist of the solution, if you really want a hierarchy of ObservableObjects, is to create your own top-level Combine Subject to conform to the ObservableObject protocol, and then encapsulate any logic of what you want to trigger updates into imperative code that updates that subject.
For example, if you had two "nested" classes, such as
class MainThing : ObservableObject {
@Published var element : SomeElement
init(element : SomeElement) {
self.element = element
}
}
class SomeElement : ObservableObject {
@Published var value : String
init(value : String) {
self.value = value
}
}
Then you could expand the top-level class (MainThing
in this case) to:
class MainThing : ObservableObject {
@Published var element : SomeElement
var cancellable : AnyCancellable?
init(element : SomeElement) {
self.element = element
self.cancellable = self.element.$value.sink(
receiveValue: { [weak self] _ in
self?.objectWillChange.send()
}
)
}
}
Which grabs a publisher from the embedded ObservableObject
, and sends an update into the local published when the property value
on SomeElement
class is modified. You can extend this to use CombineLatest for publishing streams from multiple properties, or any number of variations on the theme.
This isn't a "just do it" solution though, because the logical conclusion of this pattern is after you've grown that hierarchy of views, you're going to end up with potentially huge swatches of a View subscribed to that publisher that will invalidate and redraw, potentially causing excessive, sweeping redraws and relatively poor performance on updates. I would advise seeing if you can refactor your views to be specific to a class, and match it to just that class, to keep the "blast radius" of SwiftUI's view invalidation minimized.
Solution 4
I have a solution that I believe is more ellegant than subscribing to the child (view)models. It's weird and I don't have an explanation for why it works.
Solution
Define a base class that inherits from ObservableObject
, and defines a method notifyWillChange()
that simply calls objectWillChange.send()
. Any derived class then overrides notifyWillChange()
and calls the parent's notifyWillChange()
method.
Wrapping objectWillChange.send()
in a method is required, otherwise the changes to @Published
properties do not cause the any View
s to update. It may have something to do with how @Published
changes are detected. I believe SwiftUI/Combine use reflection under the hood...
I have made some slight additions to OP's code:
count
is wrapped in a method call which callsnotifyWillChange()
before the counter is incremented. This is required for the propagation of the changes.AppModel
contains one more@Published
property,title
, which is used for the navigation bar's title. This showcases that@Published
works for both the parent object and the child (in the example below, updated 2 seconds after the model is initialized).
Code
Base Model
class BaseViewModel: ObservableObject {
func notifyWillUpdate() {
objectWillChange.send()
}
}
Models
class Submodel: BaseViewModel {
@Published var count = 0
}
class AppModel: BaseViewModel {
@Published var title: String = "Hello"
@Published var submodel: Submodel = Submodel()
override init() {
super.init()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self = self else { return }
self.notifyWillChange() // XXX: objectWillChange.send() doesn't work!
self.title = "Hello, World"
}
}
func increment() {
notifyWillChange() // XXX: objectWillChange.send() doesn't work!
submodel.count += 1
}
override func notifyWillChange() {
super.notifyWillChange()
objectWillChange.send()
}
}
The View
struct ContentView: View {
@EnvironmentObject var appModel: AppModel
var body: some View {
NavigationView {
Text("Count: \(appModel.submodel.count)")
.onTapGesture {
self.appModel.increment()
}.navigationBarTitle(appModel.title)
}
}
}
Solution 5
If you need to nest observable objects here is the best way to do it that I could find.
class ChildModel: ObservableObject {
@Published
var count = 0
}
class ParentModel: ObservableObject {
@Published
private var childWillChange: Void = ()
let child = ChildModel()
init() {
child.objectWillChange.assign(to: &$childWillChange)
}
}
Instead of subscribing to child's objectWillChange publisher and firing parent's publisher, you assign values to published property and parent's objectWillChange triggers automatically.
Comments
-
rjkaplan almost 2 years
I have a SwiftUI view that takes in an EnvironmentObject called
appModel
. It then reads the valueappModel.submodel.count
in itsbody
method. I expect this to bind my view to the propertycount
onsubmodel
so that it re-renders when the property updates, but this does not seem to happen.Is this a bug? And if not, what is the idiomatic way to have views bind to nested properties of environment objects in SwiftUI?
Specifically, my model looks like this...
class Submodel: ObservableObject { @Published var count = 0 } class AppModel: ObservableObject { @Published var submodel: Submodel = Submodel() }
And my view looks like this...
struct ContentView: View { @EnvironmentObject var appModel: AppModel var body: some View { Text("Count: \(appModel.submodel.count)") .onTapGesture { self.appModel.submodel.count += 1 } } }
When I run the app and click on the label, the
count
property does increase but the label does not update.I can fix this by passing in
appModel.submodel
as a property toContentView
, but I'd like to avoid doing so if possible. -
rjkaplan over 4 yearsThanks, this is helpful! When you say "Nested models does not work yet in SwiftUI", do you know for sure that they are planned?
-
Sorin Lica over 4 yearsI'm not sure, but in my opinion it should work, I also use something similar in my proj, so if I'll find a better approach I'll come with an edit
-
Farhan Amjad over 4 years@SorinLica Should
Submodel
beObservableObject
type? -
Michael Ozeryansky over 4 yearsCan you clarify what xcode version you are currently on that works? I currently have Xcode 11.0 and experience this issue. I've had trouble getting upgrading to 11.1, it won't get past like 80% complete.
-
user12208004 almost 4 yearsOne thing I just notice that without weak self, the model deinit will never call
anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in self?.objectWillChange.send() }
-
Gin Tonyx over 3 yearsI would like to add that the AnyCancellable Type is defined in the Combine Framework. 99% of you guys knew this I guess, I had to google...
-
malhal over 3 yearsif you think an observable object cannot have a related observable object then you have a problem with your View code because it works fine.
-
Antonio Favata about 3 yearsThe advice at the end (and in the blog post) is absolutely golden. I was going down a rabbit hole of chained
objectWillChange
invocations, but instead I just had to refactor a single view to take an@ObservedObject
... thanks @heckj :) -
Андрей Первушин about 3 yearsIn my case i have a list of ObservableObject with active changes, if i would sink on changes in nested objects this would trigger reload entire list when i need to refresh only one row. So i would have freezes
-
JeanNicolas almost 3 yearsSee following post: arthurhammer.de/2020/03/combine-optional-flatmap . Solving Combine-Way with the $ publisher.
-
John Zhou over 2 yearsThe answer in this page is golden. Thank you. Not only it explains the issue, and is more elegant than the whole passing the objectWillChange upstream hell, which, like mentioned, will cause many unnecessary UI updates. rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui
-
John Zhou over 2 yearsThe answer in this page is golden. Thank you. Not only it explains the issue, and is more elegant than the whole passing the objectWillChange upstream hell, which, like mentioned, will cause many unnecessary UI updates. rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui
-
Calin over 2 yearsThis is probably "the SwiftUI way".
-
lawicko over 2 yearsThis solution is great, however it doesn't seem to work on iOS13