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

import Combine

final class StringController: ObservableObject {
    @Published var string: String = ""
}

final class IntController: ObservableObject {
    @Published var int: Int = 0
}


final class AggregatedController: ObservableObject {
    let stringController: StringController
    let intController: IntController

    init() {
        self.stringController = .init()
        self.intController = .init()
    }
}
let aggregated = AggregatedController()

let cancellable = aggregated.objectWillChange.sink { _ in
    print("Change Received")
}

Task {
    aggregated.intController.int = 42
}

With the code above, the change of the IntController.int is not captured by the outer controller AggregatedController. Therefore the expected statement of Change Received is not printed to the console.

I am able to notify the changes from inner controllers by subscribing their objectWillChange publisher:

final class AggregatedController: ObservableObject {
    let stringController: StringController
    let intController: IntController

+    private var cancellables: Set<AnyCancellable> = []

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

+        Publishers.Merge(
+            self.stringController.objectWillChange,
+            self.intController.objectWillChange
+        )
+        .sink(receiveValue: self.objectWillChange.send)
+        .store(in: &self.cancellables)

    }
}

But it is cumbersome and requires manual collection of all ObservableObjects, is there a more concise way to achieve this or some property wrappers that implicitly does it?

Thanks in advance!

1 Like

Not when you're directly observing objectWillChange, since you've essentially decided to do everything manually.

If you have a local @Published value for the output, you can use assign(to:) to automatically manage the connection (if you're on iOS 14 or later).

@Published var state: (Int, String) = (0, "")

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

    Publishers.CombineLatest(intController.$int, stringController.$string)
        .assign(to: $state)
}

I am fine with NOT directly observing objectWillChange, the solution code I posted there is just an attempt to proxy the updated changes.

Is there a way to achieve this without having a bridged @Published property? It seems okay for the demo code here, but in production, StringController and IntController could each has multiple @Published properties on its own. The intermediate tuple in your code will soon become hard to maintain.

1 Like

I've got no idea how to do it without your general approach, but you can at least make it as reusable as

private var cancellable: AnyCancellable!

init() {
  …
  cancellable = childrenObjectWillChanges.merged.subscribe(objectWillChange)
}
/// An `ObservableObject` that has other `ObservableObject`s
/// forward their `objectWillChange`s through its own.
public protocol ObservableObjectParent: ObservableObject {
  associatedtype ObservableObjectChildren: Sequence<any ObservableObject>

  /// The children that need their `objectWillChange`s forwarded.
  var observableObjectChildren: ObservableObjectChildren { get }
}

public extension ObservableObjectParent {
  var observableObjectChildren: [any ObservableObject] {
    Mirror(reflecting: self).children.compactMap {
      $0.value as? any ObservableObject
    }
  }

  /// All child `ObservableObjects`' `objectWillChange`s.
  var childrenObjectWillChanges: [ObservableObjectPublisher] {
    observableObjectChildren.compactMap {
      $0.objectWillChange as any Publisher as? ObservableObjectPublisher
    }
  }
}
public extension Sequence where Element: Publisher {
  var merged: Publishers.MergeMany<Element> { .init(self) }
}
extension ObservableObjectPublisher: Subject {
  public func send(subscription: any Subscription) {
    subscription.request(.unlimited)
  }

  public func send(_: Void) {
    send()
  }

  public func send(completion: Subscribers.Completion<Never>) { }
}
1 Like

Interesting solution with childrenObjectWillChanges by merging children's publishers collected via Mirror.

I wonder how ObservableObject automatically send changes for its @Published properties without explicit setup? Maybe we can utilize the same for your proposal to achieve the same?

There was an earlier thread on the same topic, and the conclusion seemed to be that two underscore-prefixed features are at play;

  • a static subscript protocol member of ObservableObject with the argument labels _enclosingInstance:wrapped:storage:, and
  • _forEachField(of:options:body:), which is more flexible than Mirror and hidden from ordinary code using @_spi(Reflection); hat tip to @AlexanderM!

So yes, there is reflection behind the scenes.

3 Likes

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()
        }
    }
}
5 Likes
I like this as a type nested within Published. (Twirl detail arrow for code.)
import Combine

public extension Published
where Value: ObservableObject, Value.ObjectWillChangePublisher == ObservableObjectPublisher {
  /// An `ObservableObject` that forwards its `objectWillChange` through a parent.
  @propertyWrapper struct Object {
    // MARK: propertyWrapper

    @available(*, unavailable, message: "The enclosing type is not an 'ObservableObject'.")
    public var wrappedValue: Value { get { fatalError() } set { } }

    public static subscript<Parent: ObservableObject>(
      _enclosingInstance parent: Parent,
      wrapped _: ReferenceWritableKeyPath<Parent, Value>,
      storage keyPath: ReferenceWritableKeyPath<Parent, Self>
    ) -> Value
    where Parent.ObjectWillChangePublisher == ObservableObjectPublisher {
      get {
        @Computed(root: parent, keyPath: keyPath) var `self`

        // It's `nil` until a parent can be provided.
        if self.objectWillChangeSubscription == nil {
          self.setParent(parent)
        }

        return self._wrappedValue
      }
      set {
        @Computed(root: parent, keyPath: keyPath) var `self`
        self._wrappedValue = newValue
        self.setParent(parent)
        parent.objectWillChange.send()
      }
    }

    // MARK: private

    private var _wrappedValue: Value

    /// The subscription which forwards  `_wrappedValue`'s `objectWillChange` through a "parent".
    private var objectWillChangeSubscription: AnyCancellable!
  }
}

// MARK: - public
public extension Published.Object {
  init(wrappedValue: Value) {
    _wrappedValue = wrappedValue
  }
}

// MARK: - private
private extension Published.Object {
  private mutating func setParent<Parent: ObservableObject>(_ parent: Parent)
  where Parent.ObjectWillChangePublisher == ObservableObjectPublisher {
    objectWillChangeSubscription = _wrappedValue.objectWillChange.subscribe(parent.objectWillChange)
  }
}

// MARK: - Codable
extension Published.Object: Encodable where Value: Encodable {
  public func encode(to encoder: Encoder) throws {
    try _wrappedValue.encode(to: encoder)
  }
}
//
extension Published.Object: Decodable where Value: Decodable {
  public init(from decoder: Decoder) throws {
    self.init(wrappedValue: try .init(from: decoder))
  }
}
/// A workaround for limitations of Swift's computed properties.
///
/// Limitations of Swift's computed property accessors:
/// 1. They are not mutable.
/// 2. They cannot be referenced as closures.
@propertyWrapper public struct Computed<Value> {
  public typealias Get = () -> Value
  public typealias Set = (Value) -> Void

  public init(
    get: @escaping Get,
    set: @escaping Set
  ) {
    self.get = get
    self.set = set
  }

  public var get: Get
  public var set: Set

  public var wrappedValue: Value {
    get { get() }
    nonmutating set { set(newValue) }
  }

  public var projectedValue: Self {
    get { self }
    set { self = newValue }
  }
}

// MARK: - public
public extension Computed {
  init(
    wrappedValue: Value,
    get: @escaping Get = {
      fatalError("`get` must be assigned before accessing `wrappedValue`.")
    },
    set: @escaping Set
  ) {
    self.init(get: get, set: set)
    self.wrappedValue = wrappedValue
  }

  /// Convert a `KeyPath` to a get/set accessor pair.
  init<Root>(
    root: Root,
    keyPath: ReferenceWritableKeyPath<Root, Value>
  ) {
    self.init(
      get: { root[keyPath: keyPath] },
      set: { root[keyPath: keyPath] = $0 }
    )
  }
}
extension ObservableObjectPublisher: Subject {
  public func send(subscription: any Subscription) {
    subscription.request(.unlimited)
  }

  public func send(_: Void) {
    send()
  }

  public func send(completion: Subscribers.Completion<Never>) { }
}

We can make this Codable more easily than with Published, because we control the source, but I'm wondering what should be done. I don't recall the last time I used a Published.Publisher; I think it might be better to just use self for projectedValue, and expose _wrappedValue publicly, but what should it be called? wrappedValue is the right name, but if you use that, you can't mark it unavailable, and you have to make the setter public. The latter is worse to me than the former. I've just used value for Published :person_shrugging::

2 Likes

Thank you for sharing this! This is most interesting to read!

Thanks for sharing this piece of implementation!

Quick question, why is the wrappedValue not accessible? This means the @Republished property can only be used as $intController?

That’s what I was trying to get an opinion on. It’s fun from a technical standpoint to understand how Published works, but it’s no fun to deal with its obfuscation outside of the most common use case. I’d really love to know what it was like at Apple, selling the design of that one.

1 Like

It's common to make wrappedValue unavailable when using the enclosing self subscript because the latter covers the functionality of the former. As I understand it, Swift won't call wrappedValue anyway if the property wrapper has an enclosing self subscript. In this case, it has the added benefit of providing a nice compiler diagnostic if you use @Republished in an unsupported place (not inside an ObservableObject).

No, you can still totally do this. Did you try the code?

It seems like good design at first, but no, it breaks down (and it doesn't have the ability to enforce ObservableObject—the AnyObject requirement is a hack). The conflation of what wrappedValue is for is incredibly confusing, and, if you have the capacity to be irritated, it will irritate you. And that's a really mean thing to do to most everybody!

Even though the availability attribute prevents the use wrappedValue for the typical purpose, it also, as I show above, precludes it for the second most common usage: $property.wrappedValue. These are not the same thing, but whoever designed _enclosingSelf didn't have to worry about it because we're technically not given the go ahead to use it—even though we don't have a replacement, nearly 4 years later.