How to tell SwiftUI views to bind to nested ObservableObjects
Asked Answered
W

15

120

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.

Waddle answered 16/10, 2019 at 5:28 Comment(2)
I'm also designing my app like this. I usually have a global App object in past app development. Does anyone else think this design of a super "App" class as the environment variable will become standard practice? I was also considering using multiple EnvironmentObjects but that's been hard to maintain.Blouson
DO NOT use reference types for @Published. The property wrapper will not emit an objectWillChange if a member of its wrapped type changes.Proparoxytone
B
149

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()
}
Braswell answered 16/10, 2019 at 5:40 Comment(10)
Thanks, this is helpful! When you say "Nested models does not work yet in SwiftUI", do you know for sure that they are planned?Waddle
I'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 editBraswell
@SorinLica Should Submodel be ObservableObject type?Parathion
One thing I just notice that without weak self, the model deinit will never call anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in self?.objectWillChange.send() }Suboceanic
I 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...Clustered
if you think an observable object cannot have a related observable object then you have a problem with your View code because it works fine.Nieves
In 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 freezesDislocation
See following post: arthurhammer.de/2020/03/combine-optional-flatmap . Solving Combine-Way with the $ publisher.Prevenient
I personally prefer the second one. It is better to create nested struct instead of nested class.Ambidexterity
@Prevenient Link's broken. 🙁Diastole
B
30

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 ContentView: View {
  @EnvironmentObject var appModel: AppModel

  var body: some View {
    SubView(submodel: appModel.submodel)
  }
}

struct SubView: View {
  @ObservedObject var submodel: Submodel

  var body: some View {
      Text("Count: \(submodel.count)")
      .onTapGesture {
        self.submodel.count += 1
      }
  }
}

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/

Bibliofilm answered 15/7, 2021 at 9:1 Comment(4)
The 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-swiftuiKathrynekathy
This is probably "the SwiftUI way".Limousin
This solution is great, however it doesn't seem to work on iOS13Colorblind
Great answer. Much easier to go with the intended pattern hereIrreclaimable
M
10

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.

Mankind answered 16/3, 2021 at 22:59 Comment(1)
The 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 @Mankind :)Decoupage
Q
7

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.

Questionable answered 23/10, 2021 at 9:18 Comment(0)
J
4

I liked solution by sorin-lica. Based upon that I've decided to implement a custom Property Wrapper (following this amazing article) named NestedObservableObject to make that solution more developer friendly.

This allow to write your model in the following way

class Submodel: ObservableObject {
  @Published var count = 0
}

class AppModel: ObservableObject {
  @NestedObservableObject var submodel: Submodel = Submodel()
}

Property Wrapper implementation

@propertyWrapper
struct NestedObservableObject<Value : ObservableObject> {
    
    static subscript<T: ObservableObject>(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<T, Self>
    ) -> Value {
        
        get {
            if instance[keyPath: storageKeyPath].cancellable == nil, let publisher = instance.objectWillChange as? ObservableObjectPublisher   {
                instance[keyPath: storageKeyPath].cancellable =
                    instance[keyPath: storageKeyPath].storage.objectWillChange.sink { _ in
                            publisher.send()
                    }
            }
            
            return instance[keyPath: storageKeyPath].storage
         }
         set {
             
             if let cancellable = instance[keyPath: storageKeyPath].cancellable {
                 cancellable.cancel()
             }
             if let publisher = instance.objectWillChange as? ObservableObjectPublisher   {
                 instance[keyPath: storageKeyPath].cancellable =
                     newValue.objectWillChange.sink { _ in
                             publisher.send()
                     }
             }
             instance[keyPath: storageKeyPath].storage = newValue
         }
    }
    
    @available(*, unavailable,
        message: "This property wrapper can only be applied to classes"
    )
    var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }
    
    private var cancellable: AnyCancellable?
    private var storage: Value

    init(wrappedValue: Value) {
        storage = wrappedValue
    }
}

I've published code on gist

Josephus answered 17/1, 2022 at 12:26 Comment(0)
P
3

@Published is not designed for reference types so it's a programming error to add it on the AppModel property, even though the compiler or runtime doesn't complain. What would've been intuitive is adding @ObservedObject like below but sadly this silently does nothing:

class AppModel: ObservableObject {
    @ObservedObject var submodel: SubModel = SubModel()
}

I'm not sure if disallowing nested ObservableObjects was intentional by SwiftUI or a gap to be filled in the future. Wiring up the parent and child objects as suggested in the other answers is very messy and hard to maintain. What seems to be the idea of SwiftUI is to split up the views into smaller ones and pass the child object to the subview:

struct ContentView: View {
    @EnvironmentObject var appModel: AppModel

    var body: some View {
        SubView(model: appModel.submodel)
    }
}

struct SubView: View {
    @ObservedObject var model: SubModel

    var body: some View {
        Text("Count: \(model.count)")
            .onTapGesture {
                model.count += 1
            }
    }
}

class SubModel: ObservableObject {
    @Published var count = 0
}

class AppModel: ObservableObject {
    var submodel: SubModel = SubModel()
}

The submodel mutations actually propagate when passing into a subview!

However, there's nothing stopping another dev from calling appModel.submodel.count from the parent view which is annoying there's no compiler warning or even some Swift way to enforce not doing this.

Source: https://rhonabwy.com/2021/02/13/nested-observable-objects-in-swiftui/

Proa answered 15/5, 2021 at 11:1 Comment(2)
The 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-swiftuiKathrynekathy
For sure that's the best answer cause I think the whole concept of Propertywrappers is using them on value types, not reference types.Fib
P
1

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.

Personality answered 15/3, 2020 at 12:1 Comment(0)
R
1

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)
        }
    }
}
Rabelais answered 6/8, 2020 at 20:25 Comment(0)
B
0

I do it like this:

import Combine

extension ObservableObject {
    func propagateWeakly<InputObservableObject>(
        to inputObservableObject: InputObservableObject
    ) -> AnyCancellable where
        InputObservableObject: ObservableObject,
        InputObservableObject.ObjectWillChangePublisher == ObservableObjectPublisher
    {
        objectWillChange.propagateWeakly(to: inputObservableObject)
    }
}

extension Publisher where Failure == Never {
    public func propagateWeakly<InputObservableObject>(
        to inputObservableObject: InputObservableObject
    ) -> AnyCancellable where
        InputObservableObject: ObservableObject,
        InputObservableObject.ObjectWillChangePublisher == ObservableObjectPublisher
    {
        sink { [weak inputObservableObject] _ in
            inputObservableObject?.objectWillChange.send()
        }
    }
}

So on the call side:

class TrackViewModel {
    private let playbackViewModel: PlaybackViewModel
    
    private var propagation: Any?
    
    init(playbackViewModel: PlaybackViewModel) {
        self.playbackViewModel = playbackViewModel
        
        propagation = playbackViewModel.propagateWeakly(to: self)
    }
    
    ...
}

Here's a gist.

Beirut answered 3/5, 2021 at 9:10 Comment(0)
P
0

See following post for a solution: [arthurhammer.de/2020/03/combine-optional-flatmap][1] . This is solving the question in a Combine-Way with the $ publisher.

Assume class Foto has an annotation struct and and annotation publisher, which publish an annotation struct. Within Foto.sample(orientation: .Portrait) the annotation struct gets "loaded" through the annotation publisher asynchroniously. Plain vanilla combine.... but to get that into a View & ViewModel, use this:

class DataController: ObservableObject {
    @Published var foto: Foto
    @Published var annotation: LCPointAnnotation
    @Published var annotationFromFoto: LCPointAnnotation

    private var cancellables: Set<AnyCancellable> = []

        
    init() {
      self.foto = Foto.sample(orientation: .Portrait)
      self.annotation = LCPointAnnotation()
      self.annotationFromFoto = LCPointAnnotation()
    
      self.foto.annotationPublisher
        .replaceError(with: LCPointAnnotation.emptyAnnotation)
        .assign(to: \.annotation, on: self)
        .store(in: &cancellables)
    
      $foto
        .flatMap { $0.$annotation }
        .replaceError(with: LCPointAnnotation.emptyAnnotation)
        .assign(to: \.annotationFromFoto, on: self)
        .store(in: &cancellables)
    
    }
 }

Note: [1]: https://arthurhammer.de/2020/03/combine-optional-flatmap/

Pay attention the $annotation above within the flatMap, it's a publisher!

 public class Foto: ObservableObject, FotoProperties, FotoPublishers {
   /// use class not struct to update asnyc properties!
   /// Source image data
   @Published public var data: Data
   @Published public var annotation = LCPointAnnotation.defaultAnnotation
   ......
   public init(data: Data)  {
      guard let _ = UIImage(data: data),
            let _ = CIImage(data: data) else {
           fatalError("Foto - init(data) - invalid Data to generate          CIImage or UIImage")
       }
      self.data = data
      self.annotationPublisher
        .replaceError(with: LCPointAnnotation.emptyAnnotation)
        .sink {resultAnnotation in
            self.annotation = resultAnnotation
            print("Foto - init annotation = \(self.annotation)")
        }
        .store(in: &cancellables)
    }
Prevenient answered 4/8, 2021 at 14:22 Comment(0)
A
0

You can create a var in your top view that is equal to a function or published var in your top class. Then pass it and bind it to every sub view. If it changes in any sub view then the top view will be updated.

Code Structure:

struct Expense : Identifiable {
    var id = UUID()
    var name: String
    var type: String
    var cost: Double
    var isDeletable: Bool
}

class Expenses: ObservableObject{ 
    @Published var name: String
    @Published var items: [Expense] 

    init() {
        name = "John Smith"
        items = [
            Expense(name: "Lunch", type: "Business", cost: 25.47, isDeletable: true),
            Expense(name: "Taxi", type: "Business", cost: 17.0, isDeletable: true),
            Expense(name: "Sports Tickets", type: "Personal", cost: 75.0, isDeletable: false)
        ]
    }
    
    func totalExpenses() -> Double { }      
}

class ExpenseTracker: ObservableObject {
    @Published var name: String
    @Published var expenses: Expenses
    
    init() {
        name = "My name"
        expenses = Expenses()
    }    

    func getTotalExpenses() -> Double { }
}

Views:

struct MainView: View {
    @ObservedObject var myTracker: ExpenseTracker
    @State var totalExpenses: Double = 0.0
    
    var body: some View {
        NavigationView {
            Form {
                Section (header: Text("Main")) {
                    HStack {
                        Text("name:")
                        Spacer()
                        TextField("", text: $myTracker.name)
                            .multilineTextAlignment(.trailing)
                            .keyboardType(.default)
                    }                         
                    NavigationLink(destination: ContentView(myExpenses: myTracker.expenses, totalExpenses: $totalExpenses),
                                   label: {
                                       Text("View Expenses")
                                   })
                }                
                Section (header: Text("Results")) {
                    }
                    HStack {
                        Text("Total Expenses")
                        Spacer()
                        Text("\(totalExpenses, specifier: "%.2f")")
                    }
                }
            }
            .navigationTitle("My Expense Tracker")
            .font(.subheadline)
        }      
        .onAppear{
            totalExpenses = myTracker.getTotalExpenses()
        }
    }
}

struct ContentView: View {
    @ObservedObject var myExpenses:Expenses
    @Binding var totalExpenses: Double
    @State var selectedExpenseItem:Expense? = nil
    
    var body: some View {
        NavigationView{
            Form {
                List {
                    ForEach(myExpenses.items) { item in
                        HStack {
                            Text("\(item.name)")
                            Spacer()
                            Button(action: {
                                self.selectedExpenseItem = item
                            } ) {
                                Text("View")
                            }
                        }
                        .deleteDisabled(item.isDeletable)
                    }
                    .onDelete(perform: removeItem)
                }
                HStack {
                    Text("Total Expenses:")
                    Spacer()
                    Text("\(myExpenses.totalExpenses(), specifier: "%.2f")")
                }
            }
            .navigationTitle("Expenses")
            .toolbar {
                Button {
                    let newExpense = Expense(name: "Enter name", type: "Expense item", cost: 10.00, isDeletable: false)
                    self.myExpenses.items.append(newExpense)
                    self.totalExpenses = myExpenses.totalExpenses()
                } label: {
                    Image(systemName: "plus")
                }
            }
            }
        .fullScreenCover(item: $selectedExpenseItem) { myItem in
            ItemDetailView(item: myItem, myExpenses: myExpenses, totalExpenses: $totalExpenses)
        }
    }
    func removeItem(at offsets: IndexSet){
        self.myExpenses.items.remove(atOffsets: offsets)
        self.totalExpenses = myExpenses.totalExpenses()
    }
}
Afterpiece answered 9/11, 2021 at 15:0 Comment(0)
M
0

Just noting that I'm using the NestedObservableObject approach from @bsorrentino in my latest app.

Normally I'd avoid this but the nested object in question is actually a CoreData model so breaking things out into smaller views doesn't really work in this regard.

This solution seemed best since the world treats NSManagedObjects as (mostly) ObservableObjects and I really, really need to trigger an update if the CodeData object model is changed down the line.

Microlith answered 12/10, 2022 at 16:38 Comment(0)
C
-1

The var submodel in AppModel doesn't need the property wrapper @Published. The purpose of @Published is to emit new values and objectWillChange. But the variable is never changed but only initiated once.

Changes in submodel are propagated to the view by the subscriber anyCancellable and ObservableObject-protocol via the sink-objectWillChange construction and causes a View to redraw.

class SubModel: ObservableObject {
    @Published var count = 0
}

class AppModel: ObservableObject {
    let submodel = SubModel()
    
    var anyCancellable: AnyCancellable? = nil
    
    init() {
        anyCancellable = submodel.objectWillChange.sink { [weak self] (_) in
            self?.objectWillChange.send()
        }
    } 
}
Carranza answered 22/10, 2020 at 19:33 Comment(0)
C
-1

Nested ObservableObject models do not work yet.

However, you can make it work by manually subscribing each model. The answer gave a simple example of this.

I wanted to add that you can make this manual process a bit more streamlined & readable via extensions:

class Submodel: ObservableObject {
  @Published var count = 0
}

class AppModel: ObservableObject {
  @Published var submodel = Submodel()
  @Published var submodel2 = Submodel2() // the code for this is not defined and is for example only
  private var cancellables: Set<AnyCancellable> = []

  init() {
    // subscribe to changes in `Submodel`
    submodel
      .subscribe(self)
      .store(in: &cancellables)

    // you can also subscribe to other models easily (this solution scales well):
    submodel2
      .subscribe(self)
      .store(in: &cancellables)
  }
}

Here is the extension:

extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher  {

  func subscribe<T: ObservableObject>(
    _ observableObject: T
  ) -> AnyCancellable where T.ObjectWillChangePublisher == ObservableObjectPublisher {
    return objectWillChange
      // Publishing changes from background threads is not allowed.
      .receive(on: DispatchQueue.main)
      .sink { [weak observableObject] (_) in
        observableObject?.objectWillChange.send()
      }
  }
}
Classical answered 18/4, 2021 at 15:37 Comment(0)
K
-4

It looks like bug. When I update the xcode to the latest version, it work correctly when binding to nested ObservableObjects

Knotting answered 22/10, 2019 at 3:42 Comment(1)
Can 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.Blouson

© 2022 - 2024 — McMap. All rights reserved.