What is the correct way to observe the changes from within a nested `ObservableObject`?

I wrote a property wrapper named @Republished to encapsulate this pattern. To use it, you'd write the outer class like so:

final class AggregatedController: ObservableObject {
    @Republished var stringController: StringController
    @Republished var intController: IntController

    init() {
        self.stringController = .init()
        self.intController = .init()
    }
}

Notes on my code:

  • It's largely untested. I wrote this for fun and never used it in production.

  • It uses the unofficial "enclosing self" feature of property wrappers

  • Keep in mind this note from Philippe Hausler that performance may degrade if your root class has no @Published property at all (and no custom objectWillChange publisher). I think is because in this case Combine stores the object's publisher in some sort of global lookup table.

    else wise you go down a pretty non-performant path to accomoxdate ObservbleObject types that don't have any @Published properties

import Combine

@propertyWrapper
struct Republished<Obj: ObservableObject> {
    private var storage: Obj
    private var subscription: AnyCancellable? = nil

    init(wrappedValue: Obj) {
        self.storage = wrappedValue
    }

    @available(*, unavailable, message: "Republished can only be used inside reference types that conform to ObservableObject")
    var wrappedValue: Obj {
        get { fatalError() }
        set { fatalError() }
    }

    static subscript<EnclosingSelf: ObservableObject>(
        _enclosingInstance enclosing: EnclosingSelf,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Obj>,
        storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Republished<Obj>>
    ) -> Obj where EnclosingSelf.ObjectWillChangePublisher == ObservableObjectPublisher {
        get {
            // Connect child's objectWillChange to parent's objectWillChange.
            if enclosing[keyPath: storageKeyPath].subscription == nil {
                let parentPublisher = enclosing.objectWillChange
                let childPublisher = enclosing[keyPath: storageKeyPath].storage.objectWillChange
                enclosing[keyPath: storageKeyPath].subscription = childPublisher.sink { _ in
                    parentPublisher.send()
                }
            }

            return enclosing[keyPath: storageKeyPath].storage
        }
        set {
            // Cancel old child's connection to parent.
            if enclosing[keyPath: storageKeyPath].subscription != nil {
                enclosing[keyPath: storageKeyPath].subscription = nil
            }

            enclosing[keyPath: storageKeyPath].storage = newValue

            // Connect new child's objectWillChange to parent's objectWillChange.
            let parentPublisher = enclosing.objectWillChange
            let childPublisher = newValue.objectWillChange
            enclosing[keyPath: storageKeyPath].subscription = childPublisher.sink { _ in
                parentPublisher.send()
            }

            // Must tell parent explicitly that it has changed.
            parentPublisher.send()
        }
    }
}
6 Likes