1

I've been trying to get an @ObservedObject-like property wrapper (let's call it @Observed) working that would listen to Combine events published by @Published properties of a class adhering to the ObservableObject protocol. I want to do it this way because I want to hide all the sink handling behind a Swift package instead of managing it in the main code base. So I need a way to inspect from the new @Observed property wrapper into its held wrappedValue to see if any of its properties are @Published and hold sink references to them

Here's a bit of code to show you what I mean:

@propertyWrapper
struct Observed<T> where T : ObservableObject {
    var sinks = [AnyCancellable]()
    var wrappedValue: T
    
    init(wrappedValue: T) {
        self.wrappedValue = wrappedValue
        let mirror = Mirror(reflecting: wrappedValue)
        for case let (label?, value) in mirror.children {
            //if `value` is wrapped as an `@Published` property {
            //   grab sink to the property
            //}
        }
    }
}

EDIT: the ultimate purpose of this is to build SwiftUI-like Combine-reactive architecture that triggers off changes of an @Published property of a wrapped ObservableObject. Only instead of drawing to a window we do something like serialize the model instead. The thing that is hanging me up is how SwiftUI knows an update happened from a model of a child of a view in order for it to trigger a re-render. I figured if, at the root level, I could grab all the properties of children via reflection, I could sink them directly and trigger that kind of reactive architecture

mjKosmic
  • 63
  • 5
  • This post of mine on the Swift forums covers exactly this topic. I think you'll find it interesting. https://forums.swift.org/t/how-is-the-published-property-wrapper-implemented/58223 – Alexander Feb 28 '23 at 21:37
  • 1
    What are you going to do with the "`sinks`"? I bet I've solved your problem but I can't tell what you're trying to do. –  Feb 28 '23 at 22:07
  • I'd like to use sinks to trigger something like serializing an updated model for sending over XPC to be consumed by a different process – mjKosmic Feb 28 '23 at 22:51
  • Having multiple still doesn't make sense to me. Unless you can show how you'd use the wrapper, externally, I'll believe that you only want one `objectWillChange`. –  Feb 28 '23 at 23:27
  • one `objectWillChange` is preferred but I found that if I grabbed the sink from the `ObservableObject` directly, instead of it's `@Published` properties, the `newValue` coming in with the `objectWillChange` notification was not as expected. – mjKosmic Mar 01 '23 at 13:00
  • @propertyWrapper struct Observed where T : ObservableObject { var wrappedValue: T var sink: AnyCancellable init(wrappedValue: T) { self.wrappedValue = wrappedValue sink = wrappedValue.objectWillChange.sink { newValue in print("sink: \(newValue)") } } } – mjKosmic Mar 01 '23 at 13:01
  • bad formatting, but an example like the above prints out `sink: ()` when the `sink` is triggered – mjKosmic Mar 01 '23 at 13:02
  • Is it a problem that `ObservableObjectPublisher`'s `Ouput` is `Void`? What else could you want to be doing? (Please post in the question.) –  Mar 01 '23 at 19:30
  • No, that doesn’t explain why what we just went over here won’t work. You’re going to need to post what the resulting code should look like, and why what you showed, and my answer, don’t support it. I can show you how to do exactly what you’re asking, but I won’t waste our time if it’s pointless. –  Mar 01 '23 at 22:02

1 Answers1

0

You can't actually be caring about each property individually, if they're all getting erased to AnyCancellables. Instead, forward them through another object.

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 { Self[_enclosingInstance: parent, storage: keyPath] }
      set { Self[_enclosingInstance: parent, storage: keyPath] = newValue }
    }

    // MARK: PublishedObject
    public var value: Value

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

// MARK: - PublishedObject
extension Published.Object: PublishedObject {
  public var object: Value { value }
}

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

public protocol PublishedObject {
  associatedtype Object: ObservableObject
  where Object.ObjectWillChangePublisher == ObservableObjectPublisher

  associatedtype WrappedValue

  init(wrappedValue: WrappedValue)
  var value: WrappedValue { get set }
  var object: Object { get }
  var objectWillChangeSubscription: AnyCancellable! { get set }
}

extension PublishedObject {
  static subscript<Parent: ObservableObject>(
    _enclosingInstance parent: Parent,
    storage keyPath: ReferenceWritableKeyPath<Parent, Self>
  ) -> WrappedValue
  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.value
    }
    set {
      @Computed(root: parent, keyPath: keyPath) var `self`
      self.value = newValue
      self.setParent(parent)
      parent.objectWillChange.send()
    }
  }

  private mutating func setParent<Parent: ObservableObject>(_ parent: Parent)
  where Parent.ObjectWillChangePublisher == ObservableObjectPublisher {
    objectWillChangeSubscription = object.objectWillChange.subscribe(parent.objectWillChange)
  }
}
/// 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 {
  /// 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 }
    )
  }
}
import Combine

/// The simplest way to forward one `objectWillChange` through another
/// is to make `ObservableObjectPublisher` be a `Subject`,
/// so that `Publisher.subscribe` can be used with it.
extension ObservableObjectPublisher: Subject {
  public func send(subscription: any Subscription) {
    subscription.request(.unlimited)
  }

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

  public func send(completion _: Subscribers.Completion<Never>) { }
}
  • I've been seeing the static subscript syntax on other forums about property wrappers and they've been confusing me a bit. Is that the specific declaration it has to have? Having those 3 parameters? No one really explains why that subscript declaration is the way it is – mjKosmic Mar 01 '23 at 13:37
  • It's not documented. Yes, you need that exact spelling, even though the first one is always useless. Hence my removal of it in the protocol that I use for other this and other types. –  Mar 01 '23 at 19:34