1

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?

smat88dd
  • 2,258
  • 2
  • 25
  • 38
  • If `Progress` is a class then `@Published var progress` is a reference to instance of `Progress`, and when you change properties of that instance, the reference itself is not changed, so `var progress` is not changed, so nothing to publish (even if you would set up everything else correctly). – Asperi Jun 05 '20 at 10:00
  • Yes, thats what I was saying in the initial question. Please read carefully and sorry if its difficult to understand. So do you know an answer of how to make this work? Doesnt look like there is a direct way to trigger it, analogue to the `objectWillChange()`? – smat88dd Jun 05 '20 at 11:14
  • Simplest is to make Progress as struct. – Asperi Jun 05 '20 at 11:16
  • Thanks, its actually a Foundation type, so ... Yeah could create my own `struct Progress` which is the easiest way. What about maybe writing a new `@propertyWrapper PublishedClassTypes` and find a way? – smat88dd Jun 05 '20 at 11:18
  • If you limited to standard Progress (eg. when integrating with other system API used it) then I would use KVO inside SomeTask on say `progress.completedUnitCount` and report `self.objectWillChange.send()` in KVO callback. – Asperi Jun 05 '20 at 11:29

3 Answers3

2

You can try using CurrentValueSubject<Progress, Never>:

class SomeTask: ObservableObject {
    var progress = CurrentValueSubject<Progress, Never>(Progress(totalUnitCount: 5))

    func setProgress(_ value: Int) {
        progress.value.completedUnitCount = value
        progress.send(progress.value)
    }
}
var task = SomeTask()
let cancellable = task.progress.sink { print($0.fractionCompleted) }
task.setProgress(3)
task.setProgress(1)

This way your Progress can still be a class.

smat88dd
  • 2,258
  • 2
  • 25
  • 38
pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • Thanks you, a small but manual approach! I was thinking about a more generic approach and am currently tinkering around with KVO and a custom @propertyWrapper with KeyPath. Also, I think there is a minor typo: `setProgress` should probably change `completedUnitCount` and not `totalUnitCount` – smat88dd Jun 06 '20 at 07:08
  • 1
    Thanks. And if you find a solution please post it as an answer. – pawello2222 Jun 06 '20 at 07:43
  • 1
    @pawello2222 I _think_ I found a solution. – Sweeper Jun 06 '20 at 08:13
  • 1
    Yeah looks I'm late to my own party, but I also implemented the ideas into a `@PublishedKVO` property wrapper. I made a small swift package out of it. – smat88dd Jun 06 '20 at 09:00
  • Your solutions look interesting, I'll look into them, thanks. – pawello2222 Jun 06 '20 at 09:18
2

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.

Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • When I was preparing my own `@PublishedKVO` you beat me to it by a few minutes ;) I didn't run your code but its the sample principle. Just wondering why you chose `ReferenceWritableKeyPath` since we dont write to it, right? This can be just the usual `KeyPath`. – smat88dd Jun 06 '20 at 09:02
  • @smat88dd Technically, yes we can use `KeyPath`, but would it make sense to pass a writable-with-value-semantics key path into it? Isn't this property wrapper made for classes? And if the key path is read-only, will it still be useful to subscribe it (it will only publish 1 value)? I chose `ReferenceWritableKeyPath` because accepting anything else here is likely a mistake. Maybe I'm missing the usefulness of passing some other kind of key path? – Sweeper Jun 06 '20 at 09:10
  • Yes, the property wrapper is made for classes, but that fact is completely unrelated to the type of KeyPath. We use the keypath only for the KVO, to tell what we are interested in (reading). We never us the keypath to write to the underlying property. Also, I dont see why the keypath has anything to do with how many values we publish, this is also completely unrelated. **I am using KeyPath, works perfectly, btw ;)** – smat88dd Jun 06 '20 at 09:16
  • The observing takes place as long as we dont invalidate the `NSKeyValueObservation` (which you have forgotten btw). And as long as we observe, the closure is being called and the publisher emits the values. **But thanks for your effort!** – smat88dd Jun 06 '20 at 09:18
  • 1
    @smat88dd See the [discussion in the comments under this answer](https://stackoverflow.com/a/45098104/5133585). The cleanup will happen automatically. I used `ReferenceWritableKeyPath` because subscribing to e.g. a readonly property doesn't really make much sense, and is likely a typo. It's in the same spirit as Swift disallowing `progress = progress`, because assigning a variable to itself is likely a typo. – Sweeper Jun 06 '20 at 09:26
  • Oh okay, thanks. So it'll be cleaned automatically when it goes out of scope. And - the KeyPath can be read-only or writable, but that is unrelated to the property. You can have a read-only keypath for a writable property, of course. So we still agree to disagree on the keypath. – smat88dd Jun 06 '20 at 09:34
  • But got curious now, why would that be a typo? And why wouldnt that make sense? I dont follow what you are trying to say ... :/ **I can just repeat, I am using `KeyPath` and it works like a charm.** Hoping that the read-only keypath uses less memory. Please, try it out yourself. – smat88dd Jun 06 '20 at 09:38
  • 1
    @smat88dd I'm not saying it won't work with `KeyPath`. Never did I say that. What I'm trying to do is to _disallow_ things like `@PublishedClass(keyPath: \.a)` where `a` is a `let` constant, the same way you can't do `@Published let a = 1` with the `Published` from Combine. Why do I disallow this? Because a `let` constant can't publish anything useful. If `@PublishedClass(keyPath: \.aLetConstant)` ever appeared in your code, you likely have a typo. This is the same reason why Swift disallows `progress = progress` - it's likely a typo. But oh well, it's a matter of opinion, I guess. – Sweeper Jun 06 '20 at 09:46
  • Oh, now I understand what you are trying to achieve! But it is impossible to add property wrappers to a let constant anyway, regardless of implementation. Compile time error. So - **just using a `KeyPath` is still enough**. – smat88dd Jun 06 '20 at 09:53
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/215412/discussion-between-sweeper-and-smat88dd). – Sweeper Jun 06 '20 at 09:54
  • So thanks for your time and clarifying, using a writable key path to ensure that the property wrapper is only ever used on writable variables, and never on let constants. That makes sense of course. I didnt initially understand – smat88dd Jun 06 '20 at 10:59
  • About the cleanup of the observers; that is actually still a necessary step on iOS since I just encountered reproducible crashes. fixing them with calling `invalidate` in `deinit`. See here: https://stackoverflow.com/a/46933326/3078330 – smat88dd Jun 08 '20 at 12:04
2

Based on the ideas I did implement a @PublishedKVO property wrapper and put it up on github as a small swift package, supporting multiple key paths.

https://github.com/matis-schotte/PublishedKVO

Usable as:

class Example {
    @PublishedKVO(\.completedUnitCount)
    var progress = Progress(totalUnitCount: 2)

    @Published
    var textualRepresentation = "text"
}

let ex = Example()

// Set up the publishers
let c1 = ex.$progress.sink { print("\($0.fractionCompleted) completed") }
let c1 = ex.$textualRepresentation.sink { print("\($0)") }

// Interact with the class as usual
ex.progress.completedUnitCount += 1
// outputs "0.5 completed"

// And compare with Combines @Published (almost°) same behaviour
ex.textualRepresentation = "string"
// outputs "string"

ex.$progress.emit() // Re-emits the current value
ex.$progress.send(ex.progress) // Emits given value
smat88dd
  • 2,258
  • 2
  • 25
  • 38