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?
Overview. The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events. Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers.
Whilst Combine can be used with UIKit, it doesn't provide publishers for controls like text fields, search bars, buttons etc. So we cannot get out of the box, a stream of text entered into a search bar from which to create our input. This is something RxSwift developers are used to in the guise of RxCocoa.
Publishers and Subscribers hold SwiftUI together, they provide the synchronization between the UI and the underlying model. Besides Publishers and Subscribers, Combine also contains a third feature called Operators. Operators operate on a Publisher, perform some computation, and return another Publisher.
I don't have much to add to TylerTheCompiler's answer, but I want to note a few things:
NSObject.KeyValueObservingPublisher
doesn't use the change dictionary internally. It always uses the key path to get the value of the property.
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
.
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)
.
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)\"" }
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With