The 3 RxSwift Building Blocks of UI Components
So here’s what I learned so far about the building blocks of reactive UI components from peeking at the RxCocoa source.
The 3 Building Blocks
UI components in general can have properties (read/write), input ports (read), and output ports (write). Classic UIKit/AppKit output ports would be delegate calls; classic input ports would be commands like display(banana:)
that you probably write every day or so.
Translated to the world of RxSwift:
Observable
is the basic output sequenceObserver
is the basic consumer of input event sequences- a combination of both fits mutable properties, the like
Variable
type, for example
Then there are special “traits” for UI bindings which guarantee to work on the main queue.
ControlEvent
is anObservable
traitUIBindingObserver
is anObserver
traitControlProperty
has both traits’s attributes and is anObservable
sequence as well as anObserver
Translating Known UIControl/NSControl Properties to RxSwift
For example, NSTextField
and UITextField
expose .rx.text
which is a ControlProperty<String>
. That means it’s read-write. You can bind other sequences to this property and have the text field update its content; and you can observe user-generated changes as well.
The alpha value or translucency of a view component is a UIBindingObserver
; similarly, isEnabled
is a UIBindingObserver
. Look at UIControl+Rx
of RxCocoa for details.
The reasoning I came up with: you usually want to change the alpha value or enabled state in reaction to some event, so it needs to be an Observer
(or “sink”) of sorts. You don’t expect any view component to generate changes to these on its own terms. These things are toggles that a controller usually manipulates.
So even though the underlying property of the UIKit/AppKit component is a readwrite
(or var
) property, it does not make too much sense to expose a ControlProperty
in these cases.
That doesn’t mean you couldn’t, thanks to KVO. Here’s a working but conceptually bad example of how to expose a view property in a reactive way that I wrote:
public extension Reactive where Base: NSScrollView {
public var backgroundColor: ControlProperty<NSColor> {
let source = self.observeWeakly(NSColor.self, "backgroundColor", options: [.initial, .new])
// Skip nil values (which in practice does not happen, but KVO observe returns Optional)
.filter { $0 != nil }.map { $0! }
.takeUntil(deallocated)
// `base` is a property of `Reactive` and, here, of type `NSScrollView`
let observer = UIBindingObserver(UIElement: base) { (scrollView, newColor: NSColor) in
scrollView.backgroundColor = newColor
}
return ControlProperty(values: source, valueSink: observer)
}
}
As you see, the ControlProperty
is composed of a UIBindingObserver
and a regular Observable
sequence. But there’s a catch: ControlProperty
requires your source sequence to adhere to a few conventional criteria. These criteria are not enforced through the types in this case. ControlProperty
takes responsibility of adhering to certain characteristics but it’s your job to make them happen. Let’s talk about these rules in detail.
The Rules of Using These 3 RxCocoa Types Correctly
ControlEvent
The implementer (you!) has to provide an Observable
that’s safe and auto-completes on deallocation of the object. ControlEvent
itself takes care of main queue scheduling if needed.
Quoting the Swift source of RxCocoa v3.5, ControlEvent
…
- it never fails
- it won’t send any initial value on subscription
- it will
Complete
sequence on control being deallocated- it never errors out
- it delivers events on
MainScheduler.instance
Most Observable
sequences you build don’t send initial values, but there are some that have replay behavior you should be aware of.
For example, a Variable("initial")
sends .next("initial")
when subscription starts. A BehaviorSubject<String>("inital")
does, too. But a PublishSubject<String>()
doesn’t send anything upon subscription. You can see that it doesn’t require an initial value. Observable.from(["initial"])
will start right away, too.
ControlProperty
The requirements of ControlProperty
are similar to these of ControlEvent
, but an initial value is expected, so the source has to shareReplay(1)
.
Again, ControlProperty
itself takes care of main queue scheduling.
- it never fails
shareReplay(1)
behavior
- it’s stateful, upon subscription (calling subscribe) last element is immediately replayed if it was produced
- it will
Complete
sequence on control being deallocated- it never errors out
- it delivers events on
MainScheduler.instance
UIBindingObserver
The “soft” attributes (that is, not enforced through the typing system) of UIBindingObserver
are simpler: it does not bind errors (errors will merely be logged). And if the incoming sequence is not on the main queue already, UIBindingObserver
dispatches to the main queue asynchronously.
From the call site, you don’t have to do anything.
It’s pretty easy to use:
public extension Reactive where Base: BananaView {
public var size: UIBindingObserver<BananaView, Size> {
// `base` is a property of `Reactive` and of type `BananaView`
return UIBindingObserver(UIElement: base) { (bananaView, newSize) in
bananaView.growVisibleBanana(to: newSize)
}
}
}
You pass in an object to UIBindingObserver.init
that you want to change for incoming events. UIBindingObserver
does the weak–strong-dance for you. It keeps a weak reference to the element only, so you don’t end up with a retain cycle. When a .next
event reaches the observer, and if the weakly referenced element still exists, a reference to the element is passed back into the closure where you can mutate the underlying property.
If you create your objects using Variable
type properties, you don’t need much of this stuff, of course. But if you want to provide a reactive extension to a regular view component, this is how you can do.