How to tell SwiftUI views to bind to nested ObservableObjects

I have a SwiftUI view that takes in an EnvironmentObject called appModel. It then reads the value appModel.submodel.count in its body method. I expect this to bind my view to the property count on submodel 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 to ContentView, but I'd like to avoid doing so if possible.


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()
}

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/


All three ViewModels can communicate and update

// First ViewModel
class FirstViewModel: ObservableObject {
var facadeViewModel: FacadeViewModels

facadeViewModel.firstViewModelUpdateSecondViewModel()
}

// Second ViewModel
class SecondViewModel: ObservableObject {

}

// FacadeViewModels Combine Both 

import Combine // so you can update thru nested Observable Objects

class FacadeViewModels: ObservableObject { 
lazy var firstViewModel: FirstViewModel = FirstViewModel(facadeViewModel: self)
  @Published var secondViewModel = secondViewModel()
}

var anyCancellable = Set<AnyCancellable>()

init() {
firstViewModel.objectWillChange.sink {
            self.objectWillChange.send()
        }.store(in: &anyCancellable)

secondViewModel.objectWillChange.sink {
            self.objectWillChange.send()
        }.store(in: &anyCancellable)
}

func firstViewModelUpdateSecondViewModel() {
     //Change something on secondViewModel
secondViewModel
}

Thank you Sorin for Combine solution.


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 Views 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 calls notifyWillChange() 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)
        }
    }
}

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.