swiftcombineproperty-wrapperproperty-wrapper-publishedswift-property-wrapper

Is there a way to access enclosing instance `ObservableObject` to call `objectWillChange.send()` from anywhere in a property wrapper


I'm trying to make a property wrapper similar to Combine's Published one(for my project needs), but with ability to modify the wrapped property by sending a value to a publisher, stored in projectedValue, like this:

// in class
@PublishedMutable var foo = "foo"

$foo.send("bar")
// ...

Here's the code of the property wrapper:

@propertyWrapper
struct PublishedMutable<Value> {
    static subscript<T: ObservableObject>(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<T, Self>
    ) -> Value {
        get {
            instance[keyPath: storageKeyPath].storage
        }
        set {
            let publisher = instance.objectWillChange
            // This assumption is definitely not safe to make in
            // production code, but it's fine for this demo purpose:
            (publisher as? ObservableObjectPublisher)?.send()
            
            instance[keyPath: storageKeyPath].storage = newValue
        }
    }
    
    @available(*, unavailable,
                message: "@PublishedMutable can only be applied to classes"
    )
    var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }
    
    private var storage: Value{
        get { publisher.value }
        set { publisher.send(newValue) }
    }
    typealias Publisher = CurrentValueSubject<Value, Never>

    var projectedValue: Publisher { self.publisher }

    private var publisher: Publisher
    init(initialValue: Value) {
        self.publisher = Publisher(initialValue)
    }
    
    init(wrappedValue: Value) {
        self.init(initialValue: wrappedValue)
    }
}

And here's the playground code that I used to test its features:

class AmogusComic: ObservableObject {
    @PublishedMutable var currentPageContent = "S O S"
    
    private var cancellables: Set<AnyCancellable> = []
    
    func connect() {
        $currentPageContent.sink { newPage in
            print(newPage)
        }
        .store(in: &cancellables)
        objectWillChange.sink { _ in
            print("Update")
        }
        .store(in: &cancellables)
    }
}

let amogusComic = AmogusComic()
DispatchQueue.main.async {
    // connect publishers and print initial content. prints "S O S"
    amogusComic.connect()
    
    // change the value of current page via setting a property, the amogusComic class will reactively print "S U S" after
    amogusComic.currentPageContent = "S U S"
    
    // take a property publisher and send new value to publisher, the amogusComic class will reactively print "A M O G U S" after
    amogusComic.$currentPageContent.send("A M O G U S")
}

PlaygroundPage.current.needsIndefiniteExecution = true

Just modifying the value both by modifying a property directly and sending a value to publisher work perfectly. But I also want to call objectWillChange.send() anytime the value changes The problem is that it only gets called when modifying the wrapped property directly, which is not correct behavior.

The test code outputs this

S O S
Update
S U S
A M O G U S

when I think it should output this

S O S
Update
S U S
Update
A M O G U S

As you can see in code above, I used _enclosingInstance static subscript to get the ObservableObject and call objectWillChange, but it turns out that Swift calls that subscript only when modifying a property directly (amogusComic.currentPageContent = "S U S")

Getting Mirror(reflecting: self).superclassMirror also doesn't work, superclassMirror always returns nil

Can I somehow get the ObservableObject outside of _enclosingInstance static subscript? So I could call objectWillChange also when sending a value to the publisher


Solution

  • No. We don't have access to a static subscript equivalent of projectedValue. It's Apple's secret, how a Published subscribes to its parent object's objectWillChange, without the need for its getter or setter to be called first.

    You can only recreate (and you have) Published's projectedValue if the class you use it with is not an ObservableObject.

    That said, there's no need to, if your intention is just to have a send method.

    final class Object: ObservableObject {
      @Published var published = ""
    }
    
    let object = Object()
    object.objectWillChange.sink { print("objectWillChange") }
    object.$published.send("new value")
    // "objectWillChange"
    object.published // "new value"
    
    public extension Published.Publisher {
      mutating func send(_ value: Value) {
        Just(value).assign(to: &self)
      }
    }