How to use Combine framework NSObject.KeyValueObservingPublisher?
Asked Answered
B

2

8

I'm trying to use the Combine framework NSObject.KeyValueObservingPublisher. I can see how to produce this publisher by calling publisher(for:options:) on an NSObject. But I'm having two problems:

  • I can include .old in the options, but no .old value ever arrives. The only values that appear are the .initial value (when we subscribe) and the .new value (each time the observed property changes). I can suppress the .initial value but I can't suppress the .new value or add the .old value.

  • If the options are [.initial, .new] (the default), I see no way to distinguish whether the value I'm receiving is .initial or .new. With "real" KVO I get an NSKeyValueChangeKey or an NSKeyValueObservedChange that tells me what I'm getting. But with the Combine publisher, I don't. I just get unmarked values.

It seems to me that these limitations make this publisher all but unusable except in the very simplest cases. Are there any workarounds?

Bari answered 25/2, 2020 at 0:40 Comment(2)
The question is why do you need the old value? A point of a publisher is for the new value. If a new publisher pipeline needs an old value it could get it from a Subject like a CurrentValueSubject.Gielgud
Although a different question, this may help too: https://mcmap.net/q/1324352/-why-does-user-defaults-publisher-trigger-multiple-timesCatabasis
O
6

For getting the old value, the only workaround I was able to find was to use .prior instead of .old, which causes the publisher to emit the current value of the property before it is changed, and then combine that value with the next emission (which is the new value of the property) using collect(2).

For determining what's an initial value vs. a new value, the only workaround I found was to use first() on the publisher.

I then merged these two publishers and wrapped it all up in a nice little function that spits out a custom KeyValueObservation enum that lets you easily determine whether it's an initial value or not, and also gives you the old value if it's not an initial value.

Full example code is below. Just create a brand new single-view project in Xcode and replace the contents of ViewController.swift with everything below:

import UIKit
import Combine

/// The type of value published from a publisher created from 
/// `NSObject.keyValueObservationPublisher(for:)`. Represents either an
/// initial KVO observation or a non-initial KVO observation.
enum KeyValueObservation<T> {
    case initial(T)
    case notInitial(old: T, new: T)

    /// Sets self to `.initial` if there is exactly one element in the array.
    /// Sets self to `.notInitial` if there are two or more elements in the array.
    /// Otherwise, the initializer fails.
    ///
    /// - Parameter values: An array of values to initialize with.
    init?(_ values: [T]) {
        if values.count == 1, let value = values.first {
            self = .initial(value)
        } else if let old = values.first, let new = values.last {
            self = .notInitial(old: old, new: new)
        } else {
            return nil
        }
    }
}

extension NSObjectProtocol where Self: NSObject {

    /// Publishes `KeyValueObservation` values when the value identified 
    /// by a KVO-compliant keypath changes.
    ///
    /// - Parameter keyPath: The keypath of the property to publish.
    /// - Returns: A publisher that emits `KeyValueObservation` elements each 
    ///            time the property’s value changes.
    func keyValueObservationPublisher<Value>(for keyPath: KeyPath<Self, Value>)
        -> AnyPublisher<KeyValueObservation<Value>, Never> {

        // Gets a built-in KVO publisher for the property at `keyPath`.
        //
        // We specify all the options here so that we get the most information
        // from the observation as possible.
        //
        // We especially need `.prior`, which makes it so the publisher fires 
        // the previous value right before any new value is set to the property.
        //
        // `.old` doesn't seem to make any difference, but I'm including it
        // here anyway for no particular reason.
        let kvoPublisher = publisher(for: keyPath,
                                     options: [.initial, .new, .old, .prior])

        // Makes a publisher for just the initial value of the property.
        //
        // Since we specified `.initial` above, the first published value will
        // always be the initial value, so we use `first()`.
        //
        // We then map this value to a `KeyValueObservation`, which in this case
        // is `KeyValueObservation.initial` (see the initializer of
        // `KeyValueObservation` for why).
        let publisherOfInitialValue = kvoPublisher
            .first()
            .compactMap { KeyValueObservation([$0]) }

        // Makes a publisher for every non-initial value of the property.
        //
        // Since we specified `.initial` above, the first published value will 
        // always be the initial value, so we ignore that value using 
        // `dropFirst()`.
        //
        // Then, after the first value is ignored, we wait to collect two values
        // so that we have an "old" and a "new" value for our 
        // `KeyValueObservation`. This works because we specified `.prior` above, 
        // which causes the publisher to emit the value of the property
        // _right before_ it is set to a new value. This value becomes our "old"
        // value, and the next value emitted becomes the "new" value.
        // The `collect(2)` function puts the old and new values into an array, 
        // with the old value being the first value and the new value being the 
        // second value.
        //
        // We then map this array to a `KeyValueObservation`, which in this case 
        // is `KeyValueObservation.notInitial` (see the initializer of 
        // `KeyValueObservation` for why).
        let publisherOfTheRestOfTheValues = kvoPublisher
            .dropFirst()
            .collect(2)
            .compactMap { KeyValueObservation($0) }

        // Finally, merge the two publishers we created above
        // and erase to `AnyPublisher`.
        return publisherOfInitialValue
            .merge(with: publisherOfTheRestOfTheValues)
            .eraseToAnyPublisher()
    }
}

class ViewController: UIViewController {

    /// The property we want to observe using our KVO publisher.
    ///
    /// Note that we need to make this visible to Objective-C with `@objc` and 
    /// to make it work with KVO using `dynamic`, which means the type of this 
    /// property must be representable in Objective-C. This one works because it's 
    /// a `String`, which has an Objective-C counterpart, `NSString *`.
    @objc dynamic private var myProperty: String?

    /// The thing we have to hold on to to cancel any further publications of any
    /// changes to the above property when using something like `sink`, as shown
    /// below in `viewDidLoad`.
    private var cancelToken: AnyCancellable?

    override func viewDidLoad() {
        super.viewDidLoad()

        // Before this call to `sink` even finishes, the closure is executed with
        // a value of `KeyValueObservation.initial`.
        // This prints: `Initial value of myProperty: nil` to the console.
        cancelToken = keyValueObservationPublisher(for: \.myProperty).sink { 
            switch $0 {
            case .initial(let value):
                print("Initial value of myProperty: \(value?.quoted ?? "nil")")

            case .notInitial(let oldValue, let newValue):
                let oldString = oldValue?.quoted ?? "nil"
                let newString = newValue?.quoted ?? "nil"
                print("myProperty did change from \(oldString) to \(newString)")
            }
        }

        // This prints:
        // `myProperty did change from nil to "First value"`
        myProperty = "First value"

        // This prints:
        // `myProperty did change from "First value" to "Second value"`
        myProperty = "Second value"

        // This prints:
        // `myProperty did change from "Second value" to "Third value"`
        myProperty = "Third value"

        // This prints:
        // `myProperty did change from "Third value" to nil`
        myProperty = nil
    }
}

extension String {

    /// Ignore this. This is just used to make the example output above prettier.
    var quoted: String { "\"\(self)\"" }
}
Ostraw answered 25/2, 2020 at 8:8 Comment(2)
Basically you’re building what I was expecting the Combine publisher should have been doing in the first place! Nice, thanks. — This is like the weird lack of a UIControl publisher. Yes, you can build one, and yes, I’ve done that, but what a strange omission; it makes the whole framework feel half-baked.Bari
No prob. Yep, agreed – this is what the Combine publisher should be publishing, in my opinion.Ostraw
C
10

I don't have much to add to TylerTheCompiler's answer, but I want to note a few things:

  1. NSObject.KeyValueObservingPublisher doesn't use the change dictionary internally. It always uses the key path to get the value of the property.

  2. If you pass .prior, the publisher will publish both the before and the after values, separately, each time the property changes. This is due to how KVO is implemented by Objective-C. It's not specific to KeyValueObservingPublisher.

  3. A shorter way to get the before and after values of the property is by using the scan operator:

     extension Publisher {
         func withPriorValue() -> AnyPublisher<(prior: Output?, new: Output), Failure> {
             return self
                 .scan((prior: Output?.none, new: Output?.none)) { (prior: $0.new, new: $1) }
                 .map { (prior: $0.0, new: $0.1!) }
                 .eraseToAnyPublisher()
         }
     }
    

    If you also use .initial, then the first output of withPriorValue will be be (prior: nil, new: currentValue).

Clemenciaclemency answered 25/2, 2020 at 21:53 Comment(1)
Ahhhh, scan. I saw that but I wasn't sure how to use it, or if it even was what I was looking for. Nice! This should probably be the accepted answer, honestly, since it's much more succinct than mine.Ostraw
O
6

For getting the old value, the only workaround I was able to find was to use .prior instead of .old, which causes the publisher to emit the current value of the property before it is changed, and then combine that value with the next emission (which is the new value of the property) using collect(2).

For determining what's an initial value vs. a new value, the only workaround I found was to use first() on the publisher.

I then merged these two publishers and wrapped it all up in a nice little function that spits out a custom KeyValueObservation enum that lets you easily determine whether it's an initial value or not, and also gives you the old value if it's not an initial value.

Full example code is below. Just create a brand new single-view project in Xcode and replace the contents of ViewController.swift with everything below:

import UIKit
import Combine

/// The type of value published from a publisher created from 
/// `NSObject.keyValueObservationPublisher(for:)`. Represents either an
/// initial KVO observation or a non-initial KVO observation.
enum KeyValueObservation<T> {
    case initial(T)
    case notInitial(old: T, new: T)

    /// Sets self to `.initial` if there is exactly one element in the array.
    /// Sets self to `.notInitial` if there are two or more elements in the array.
    /// Otherwise, the initializer fails.
    ///
    /// - Parameter values: An array of values to initialize with.
    init?(_ values: [T]) {
        if values.count == 1, let value = values.first {
            self = .initial(value)
        } else if let old = values.first, let new = values.last {
            self = .notInitial(old: old, new: new)
        } else {
            return nil
        }
    }
}

extension NSObjectProtocol where Self: NSObject {

    /// Publishes `KeyValueObservation` values when the value identified 
    /// by a KVO-compliant keypath changes.
    ///
    /// - Parameter keyPath: The keypath of the property to publish.
    /// - Returns: A publisher that emits `KeyValueObservation` elements each 
    ///            time the property’s value changes.
    func keyValueObservationPublisher<Value>(for keyPath: KeyPath<Self, Value>)
        -> AnyPublisher<KeyValueObservation<Value>, Never> {

        // Gets a built-in KVO publisher for the property at `keyPath`.
        //
        // We specify all the options here so that we get the most information
        // from the observation as possible.
        //
        // We especially need `.prior`, which makes it so the publisher fires 
        // the previous value right before any new value is set to the property.
        //
        // `.old` doesn't seem to make any difference, but I'm including it
        // here anyway for no particular reason.
        let kvoPublisher = publisher(for: keyPath,
                                     options: [.initial, .new, .old, .prior])

        // Makes a publisher for just the initial value of the property.
        //
        // Since we specified `.initial` above, the first published value will
        // always be the initial value, so we use `first()`.
        //
        // We then map this value to a `KeyValueObservation`, which in this case
        // is `KeyValueObservation.initial` (see the initializer of
        // `KeyValueObservation` for why).
        let publisherOfInitialValue = kvoPublisher
            .first()
            .compactMap { KeyValueObservation([$0]) }

        // Makes a publisher for every non-initial value of the property.
        //
        // Since we specified `.initial` above, the first published value will 
        // always be the initial value, so we ignore that value using 
        // `dropFirst()`.
        //
        // Then, after the first value is ignored, we wait to collect two values
        // so that we have an "old" and a "new" value for our 
        // `KeyValueObservation`. This works because we specified `.prior` above, 
        // which causes the publisher to emit the value of the property
        // _right before_ it is set to a new value. This value becomes our "old"
        // value, and the next value emitted becomes the "new" value.
        // The `collect(2)` function puts the old and new values into an array, 
        // with the old value being the first value and the new value being the 
        // second value.
        //
        // We then map this array to a `KeyValueObservation`, which in this case 
        // is `KeyValueObservation.notInitial` (see the initializer of 
        // `KeyValueObservation` for why).
        let publisherOfTheRestOfTheValues = kvoPublisher
            .dropFirst()
            .collect(2)
            .compactMap { KeyValueObservation($0) }

        // Finally, merge the two publishers we created above
        // and erase to `AnyPublisher`.
        return publisherOfInitialValue
            .merge(with: publisherOfTheRestOfTheValues)
            .eraseToAnyPublisher()
    }
}

class ViewController: UIViewController {

    /// The property we want to observe using our KVO publisher.
    ///
    /// Note that we need to make this visible to Objective-C with `@objc` and 
    /// to make it work with KVO using `dynamic`, which means the type of this 
    /// property must be representable in Objective-C. This one works because it's 
    /// a `String`, which has an Objective-C counterpart, `NSString *`.
    @objc dynamic private var myProperty: String?

    /// The thing we have to hold on to to cancel any further publications of any
    /// changes to the above property when using something like `sink`, as shown
    /// below in `viewDidLoad`.
    private var cancelToken: AnyCancellable?

    override func viewDidLoad() {
        super.viewDidLoad()

        // Before this call to `sink` even finishes, the closure is executed with
        // a value of `KeyValueObservation.initial`.
        // This prints: `Initial value of myProperty: nil` to the console.
        cancelToken = keyValueObservationPublisher(for: \.myProperty).sink { 
            switch $0 {
            case .initial(let value):
                print("Initial value of myProperty: \(value?.quoted ?? "nil")")

            case .notInitial(let oldValue, let newValue):
                let oldString = oldValue?.quoted ?? "nil"
                let newString = newValue?.quoted ?? "nil"
                print("myProperty did change from \(oldString) to \(newString)")
            }
        }

        // This prints:
        // `myProperty did change from nil to "First value"`
        myProperty = "First value"

        // This prints:
        // `myProperty did change from "First value" to "Second value"`
        myProperty = "Second value"

        // This prints:
        // `myProperty did change from "Second value" to "Third value"`
        myProperty = "Third value"

        // This prints:
        // `myProperty did change from "Third value" to nil`
        myProperty = nil
    }
}

extension String {

    /// Ignore this. This is just used to make the example output above prettier.
    var quoted: String { "\"\(self)\"" }
}
Ostraw answered 25/2, 2020 at 8:8 Comment(2)
Basically you’re building what I was expecting the Combine publisher should have been doing in the first place! Nice, thanks. — This is like the weird lack of a UIControl publisher. Yes, you can build one, and yes, I’ve done that, but what a strange omission; it makes the whole framework feel half-baked.Bari
No prob. Yep, agreed – this is what the Combine publisher should be publishing, in my opinion.Ostraw

© 2022 - 2024 — McMap. All rights reserved.