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)\"" }
}