Key-value observing is an ancient technology on Apple platforms. It allows objects to be notified of changes to properties of other objects. Working with KVO in Swift has always been cumbersome. It’s only available in
NSObject subclasses, making it impossible to use as your one-stop binding solution.
Combine, Apple’s new functional reactive framework, promises to change that. It is fully native in Swift and observing properties is one of its many use cases.
Replacing KVO with Combine, however, is not without gotchas. What KVO gets right is that it makes it easy to observe nested properties. With Combine, it’s more difficult.
In this post, I’ll explain why that is and how to bridge the gap.
What We Want to Do
Say, we have a video player app and want to display the current video title in a label. The setup looks like this:
Our goal is to keep the video title and the label in sync. In the view controller, we want to observe the nested property
playbackController?.player?.videoTitle and update
titleLabel with its value. This is called a binding.
When observing nested properties, a binding should have one important feature: It needs to be robust against changes to intermediate properties.
Let me illustrate with an example.
Assume we’ve set up the initial binding. At some point, we replace the playback controller’s player:
What do we expect to happen? We expect the video title label to display
oldPlayer.videoTitle. In other words, the binding reports the value of
playbackController?.player?.videoTitle independent of which specific intermediate instances it is attached to.
The same goes for
nil. When the player becomes
nil so should the video title.
This feature has a big advantage. It makes our code simpler and more maintainable. We can define the binding only once when we create the view controller and let any changes propagate automatically.
This is easy to do with key-value observing. In fact, it’s its prime use case. KVO allows us to observe key paths or chains of nested properties as follows:
KVO has a big drawback: It’s only available in
NSObject subclasses and properties marked with
@objc dynamic. We can’t use KVO when working with plain Swift types. It’s inherently tied to Objective-C.
As a result, developers had to roll their own Swift binding solution while waiting for a native one. With Combine, it finally arrived.
Let’s explore how we can create our binding in Combine.
The basic building blocks in Combine are publishers. A publisher emits values over time, in our case when a property changes.
To make a property observable in Combine, we create a publisher for it. And to make an entire chain of nested properties observable, we need to create a publisher for each one.
We can do this conveniently by marking all properties with
First, let’s simplify our assumptions. Consider the case where all properties along the chain are non-optionals:
We then create our binding as follows:
Note the use of
$. It refers to the property’s publisher as opposed to its value.
flatMap takes the value from the previous publisher and returns a new one. The playback controller publisher is successively mapped into the video title publisher.
Compare this with the following:
This only binds the current playback controller’s current player’s video title. When either changes, the binding becomes outdated.
This is also incorrect:
This sink is only triggered when the playback controller instance changes. Changes to the player or video title are not recorded.
What the latter two get wrong is that they operate on the level of values. The correct solution above operates on the level of publishers. To observe a chain of properties in Combine, we need an unbroken chain of publishers handing down their values.
With optionals, it’s trickier. Let’s add them back in:
The binding from above now looks like this:
This doesn’t compile. Since
playbackController is an optional, the input to
flatMap is too and so is its output.
flatMap, however, only accepts concrete return values.
What we need to do is provide an alternative publisher should
nil. The solution is to replace a
nil publisher with a publisher that emits
eraseToAnyPublisher is required to reduce both publishers to the same type.
We’ve pushed the optional one level deeper. The difference is important: The chain of publishers remains unbroken. Should any value along the chain be
nil, we propagate it downstream.
For longer chains, this gets unruly very quickly. It is better extracted into an extension. I chose to overload the
flatMap operator. Another solution could be to write a custom proxy publisher to wrap optional publishers.
The signature looks more complex than it is. The important thing is that the output
Output of the current publisher is the input of the
transform closure which converts it into a new optional publisher
In the case above, the output of that new publisher is an optional itself (
P.Output == T?). The case where it is not must be handled in a separate overload (
P.Output == T):
This is the same except for
map. Its job is to raise the new publisher’s output type
T to the required
With both extensions in place, we can finally create our binding as follows:
So what have we achieved here?
We have shown how to observe nested properties with Combine. We can set up the binding once and handle changes to intermediate properties automatically. This makes code simple and maintainable.
The method behaves similar to key-value observing. But unlike key-value observing, it is available in plain Swift types. Setting up the solution proved to be a bit of work, though. It required us to patch Combine’s gaps when it comes to optional publishers.