Key-value observing without NSObject and dynamic modifier in Swift

When writing code in Swift it is often needed to observe changes in other objects. We can use Apple’s key-value observation but it has some implications: requires to use NSObject and dynamic dispatch through Objective-C runtime. This time, let’s build a simple key-value observation in Swift what does not require to use NSObject at all. Although it is far from being as feature complete as Apple’s implementation, it delivers the basic use-case which is often all what we need.

Custom KeyValueObservable protocol

The approach we take here is defining a protocol, providing default implementations for all the functions. Then we can make any class to conform to this protocol, but as we need to store observation related information, then the class needs to define a property holding an instance of ObservationStore. Secondly, it is required to send key-value change notification manually using didChangeValue(for:).

Add observer function returns an instance of Observation what can be used for removing the added observation. If the observer does not need to be removed during the lifetime of the observer, it can be ignored. Observation is always cleaned up automatically next time any key value changes happen after observer is deallocated. This is due to the fact that observation handler captures observer weakly and during key-value changes, it is checked if the object is still alive or not.

protocol KeyValueObservable where Self: AnyObject {
/// Stores all the added observations.
var observationStore: ObservationStore<Self> { get }
/// Sends key-value change notification to all the observers for this key path.
func didChangeValue(for keyPath: PartialKeyPath<Self>)
/// Adds observer for key path and returns observation token.
/// – Note: Observation token is only useful if it is needed to remove observation before observer is deallocated. When observer is deallocated, then observation is removed when next key value change is handled.
@discardableResult func addObserver<Observer: AnyObject, Value>(_ observer: Observer,
keyPath: KeyPath<Self, Value>,
options: Observation.Options,
handler: @escaping (Observer, Value) -> Void) -> Observation
/// Removes added observation.
func removeObservation(_ observation: Observation)
}

Adding default implementations

When adding observer, we create an observation handler what captures self and observer weakly. Handler returns boolean, what tells if the handler is still valid or not. Handler is not valid when observer has been deallocated since the last change. Otherwise handler is valid and should not be removed automatically.

extension KeyValueObservable {
@discardableResult func addObserver<Observer: AnyObject, Value>(_ observer: Observer, keyPath: KeyPath<Self, Value>, options: Observation.Options, handler: @escaping (Observer, Value) -> Void) -> Observation {
let observation = Observation()
let observationHandler: (PartialKeyPath<Self>) -> Bool = { [weak self, weak observer] changedKeyPath in
guard let self = self else { return false }
guard let observer = observer else { return false }
guard changedKeyPath == keyPath else { return true }
handler(observer, self[keyPath: keyPath])
return true
}
observationStore.observationInfos[observation] = observationHandler
if options.contains(.initial) {
handler(observer, self[keyPath: keyPath])
}
return observation
}
func removeObservation(_ observation: Observation) {
observationStore.observationInfos.removeValue(forKey: observation)
}
func didChangeValue(for keyPath: PartialKeyPath<Self>) {
observationStore.observationInfos = observationStore.observationInfos.filter({ (_, handler) -> Bool in
return handler(keyPath)
})
}
}

Supporting objects to key-value observing

As mentioned before, ObservationStore is needed to added to every class conforming to KeyValueObservable protocol. It stores all the observations and restricts the access to modifying the observations directly from the observable class.

Observation is a simple struct containing an identifier and subtype defining the observation options. In this basic case, it just has initial option what assures handler is called immediately when adding an observer.

final class ObservationStore<T> {
fileprivate var observationInfos = [Observation: (PartialKeyPath<T>) -> Bool]()
var observations: [Observation] {
return observationInfos.map({ $0.key })
}
func removeAll() {
observationInfos.removeAll()
}
}
struct Observation: Hashable {
fileprivate let identifier = UUID()
struct Options: OptionSet {
let rawValue: Int
static let initial = Options(rawValue: 1 << 0)
}
}

Conforming to KeyValueObservable

In this small example a class Event conforms to KeyValueObservable and ViewController observers the title change and updates a label.

final class Event: KeyValueObservable {
let observationStore = ObservationStore<Event>()
var title: String = "Initial Title" {
didSet {
didChangeValue(for: \Event.title)
}
}
}
final class ViewController: UIViewController {
let event = Event()
@IBOutlet weak var label: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
event.addObserver(self, keyPath: \.title, options: .initial) { (observer, title) in
observer.label.text = title
}
}
@IBAction func changeTitle(_ sender: Any) {
event.title = "New Title"
}
}
view raw Observing.swift hosted with ❤ by GitHub

Summary

This time we added basic support for observing key paths without using key-value observing APIs known already from Objective-C times. The added KeyValueObservable protocol is easy to add to existing classes but requires manually calling didChangeValue(for:) for every property change.

Inspiration came from Observers in Swift part 2 (Swift by Sundell).

If this was helpful, please let me know on Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Resources

One Reply to “Key-value observing without NSObject and dynamic modifier in Swift”

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s