Search code examples
swiftkey-value-observingcombinereference-type

`@Published var name: ClassType` doesn't work _outside_ of SwiftUI / manual trigger?


I found a lot of SwiftUI-related topics about this which didn't help (eg Why an ObservedObject array is not updated in my SwiftUI application?)

This doesn't work with Combine in Swift (specifically not using SwiftUI):

class SomeTask {
  @Published var progress = Progress(totalUnitCount: 5) // Progress is a Class
  [...]
}
var task = SomeTask()
let cancellable = task.$progress.sink { print($0.fractionCompleted) }
task.progress.completedUnitCount = 2

This is not SwiftUI-related so no ObservableObject inheritance to get objectWillChange, but even if I try to use ObservableObject and task.objectWillChange.send() it doesn't do anything, also trying to add extension Progress: ObservableObject {} doesn't help. Since the publisher emits values through the var's willSet and since Progress is itself class-type nothing happens.

Looks like there is no real decent way to manually trigger it?

Only solution I found is to just re-assign itself which is quite awkward:

let pr = progress progress = pr

(writing progress = progress is a compile-time error).

Only other way which might be working is probably by using Key-value-observing/KVO and/or writing a new @PublishedClassType property wrapper?


Solution

  • I was able to implement this using KVO, wrapped by a @propertyWrapper, with a CurrentValueSubject as the publisher:

    @propertyWrapper
    class PublishedClass<T : NSObject> {
        private let subject: CurrentValueSubject<T, Never>
        private var observation: NSKeyValueObservation? = nil
    
        init<U>(wrappedValue: T, keyPath: ReferenceWritableKeyPath<T, U>) {
            self.wrappedValue = wrappedValue
            subject = CurrentValueSubject(wrappedValue)
            observation = wrappedValue.observe(keyPath, options: [.new]) { (wrapped, change) in
                self.subject.send(wrapped)
            }
        }
    
        var wrappedValue: T
    
        var projectedValue: CurrentValueSubject<T, Never> {
            subject
        }
    
        deinit {
            observation.invalidate()
        }
    }
    

    Usage:

    class Bar : NSObject {
        @objc dynamic var a: Int
        init(a: Int) {
            self.a = a
        }
    }
    
    class Foo {
        @PublishedClass(keyPath: \.a)
        var bar = Bar(a: 0)
    }
    
    let f = Foo()
    let c = f.$bar.sink(receiveValue: { x in print(x.a) })
    f.bar.a = 2
    f.bar.a = 3
    f.bar.a = 4
    

    Output:

    0
    2
    3
    4
    

    The disadvantage of using KVO is, of course, that the key path you pass in must be @objc dynamic and the root of the keypath must be an NSObject subclass. :(

    I haven't tried, but it should be possible to extend this to observe on multiple key paths if you want.