API Design — Expanding on Signals
When working with UI frameworks, it is very common with APIs that want to notify the user of changes of different kinds. In Apple frameworks, these are typically expressed through the use of notifications, target/action, delegates or registration of callback closures.
In the article “Deriving Signals” those different APIs were unified by the introduction of the Signal
type. This article will further explore the utility of signals and how they can become even more convenient to work with.
We start off by extending Signal
with more useful transforms. Then UIControl
is extended to signal UI events such as text field changes. We conclude by exploring how to combine signals and handle the notion of a current state.
Signal
In the previous article Signal
was introduced as:
final class Signal<Value> {
let onValue: (_ callback: @escaping (Value) -> Void) -> Disposable
}
Signal
abstracts the API for observing changes over time. To listen to these changes, a callback is passed to onValue
. This callback will then be called for each change. When you are no longer interested in observing those changes, dispose()
is called on the returned Disposable
:
// Start observe
let disposable = signal.onValue { value in ... }
...
// Stop observe
disposable.dispose()
Perhaps, more importantly, is that a Signal
hides the details on how and from where events are generated. Signal
s can also be passed around and be shared among several users, and by applying transforms, they can be modified into new signals. The article "Deriving Signals" concluded by introducing the map()
transform:
let email: Signal<String>
let shouldEnableButton = email.map { $0.isValidEmail }
More transforms
Similar to map
we can add more transforms such as filter
:
extension Signal {
func filter(_ predicate: @escaping (Value) -> Bool) -> Signal<Value> {
return Signal { callback in
self.onValue { value in
guard predicate(value) else { return }
callback(value)
}
}
}
}
Another useful transformation distinct()
will filter out repeated values. If a new value is equal to the preceding value it will not be forwarded:
extension Signal where Value: Equatable {
func distinct() -> Signal<Value> {
return Signal { callback in
var prev: Value? = nil
return self.onValue { value in
if prev.map { $0 != value } ?? true {
callback(value)
}
prev = val
}
}
}
}
There are many more useful transformations such as debounce()
, throttle()
and latestTwo()
. See Flow for more examples.
UI events
When working with UI, probably the most common usage of event handling is when working with UI controls such as UIKit’s UIButton
and UITextField
. The API for working with UIControl
events is by setting up targets and actions. A problem with using target/action1 is that the declaration of the action is separated from the code where it is set up. This becomes even more problematic if the action needs access to some additional context data. This data needs to be stored in the target for the action to be able to access it:
class LoginController: UIViewController {
let button: UIButton // Need access to the button in `emailChanged`
init() {
button = UIButton(...)
let emailField = UITextField(...)
// Add to view hierarchy
button.addTarget(self, action: #selector(login), for: .touchUpInside)
emailField.addTarget(self, action: #selector(emailChanged), for: .editingChanged)
}
func emailChanged() {
button.isEnabled = emailField.text?.isValidEmail ?? false
}
}
When using signals we can instead capture this context data in the callback closure itself. This brings related code together and avoids potentially stale or out of sync context data.
Let us extend UIControl
to provide a signal for control events:
extension UIControl {
func signal(for controlEvents: UIControlEvents) -> Signal<()> { ... }
}
Our view controller can now instead be written as:
class LoginController: UIViewController {
init() {
let button = UIButton(...)
let emailField = UITextField(...)
// Add to view hierarchy
bag += emailField.signal(for: .editingChanged).onValue {
button.isEnabled = emailField.text?.isValidEmail ?? false
}
bag += button.signal(for: .touchUpInside).onValue(login)
}
}
SignalProvider
For most UIControl
s there is a signal that makes more sense than others. For UIButton
it is signaling button presses, and for UITextField it is signaling changes to its text. It would be useful to express that certain types provide a default signal. Let us introduce a protocol for that:
protocol SignalProvider {
associatedtype Value
var providedSignal: Signal<Value> { get }
}
We can now retroactively extend UIButton
:
extension UIButton: SignalProvider {
var providedSignal: Signal<()> {
return signal(for: .touchUpInside)
}
}
And for UITextField we will provide the current value of its text:
extension UITextField: SignalProvider {
var providedSignal: Signal<String> {
return signal(for: .editingChanged).map { self.text ?? "" }
}
}
It would be convenient if we did not have to explicitly call providedSignal
on our conforming types. However, we do not want to implement all our transformations twice, once for Signal
and once for SignalProvider
. Fortunately, it is easy to conform Signal
to SignalProvider
by just returning itself:
extension Signal: SignalProvider {
var providedSignal: Signal<Value> {
return self
}
}
We can now move onValue()
and our other transformations to work on SignalProvider
instead. Our example will now read more succinctly:
bag += emailField.onValue { value in
button.isEnabled = value.isValidEmail
}
bag += button.onValue(login)
Working with multiple signals
Once you start using signals in many places you soon see a need to combine several signals into one. For example, if we have both an email and a password field and want to enable the button if both of them are valid we currently have to write:
bag += emailField.onValue { value in
button.isEnabled = value.isValidEmail && passwordField.text?.isValidPassword
}
bag += passwordField.onValue { value in
button.isEnabled = value.isValidPassword && emailField.text?.isValidEmail
}
This will be hard to maintain and will not scale if we get even more signals participating in the validation. What we would like is to combine these two signals into one signal that updates if any of the two signals are updated:
bag += combineLatest(emailField, passwordField).onValue { email, password in
button.isEnabled = email.isValidEmail && password.isValidPassword
}
An implementation of combineLatest
might look like:
func combineLatest<A, B>(_ signalA: A, _ signalB: B) -> Signal<(A.Value, B.Value)>
where A: SignalProvider, B: SignalProvider {
return Signal { callback in
var a: A.Value?
var b: B.Value?
let bag = DisposeBag()
bag += signalA.onValue { value in
a = value
if let b = b {
callback((value, b))
}
}
bag += signalB.onEventType { value in
b = value
if let a = a {
callback((a, value))
}
}
return bag
}
}
Some other useful signal combiners are merge()
and flatMapLatest()
. See Flow for more information.
Accessing initial state
By adding combineLatest
we got rid of the duplicated enable button logic. However, we also need to set up the initial state of isEnabled
. In the current example, we could of course just set the button to disabled at creation. But what if the text fields would sometimes be pre-filled. Then disabled is not necessarily the preferred initial state. Yet again, this leads to code repetition:
button.isEnabled = emailField.text?.isValidEmail && passwordField.text?.isValidPassword
bag += combineLatest(emailField, passwordField).onValue { email, password in
button.isEnabled = email.isValidEmail && password.isValidPassword
}
It would be convenient if a text field would signal its current value at once when setting up a callback. Then the initial state could be set inside onValue()
instead. But not all signals have a notion of a current value such as UIButton or notifications. How would we know which signals will signal an initial value and which will not? We could, of course, document this at the declaration site. But it would be better if the type itself could express this.
Let us introduce a new type called ReadSignal
that extends Signal
with a read-only value
property returning its current value. We also add the transform atOnce()
that will signal this value
as soon as someone starts listening:
extension ReadSignal {
func atOnce() -> Signal<Value> {
return Signal { callback in
callback(self.value)
return self.onValue(callback)
}
}
}
We update UITextField
to instead return a ReadSignal<String>
and add atOnce()
to our example:
bag += combineLatest(emailField, passwordField).atOnce().onValue {
button.isEnabled = $0.isValidEmail && $1.isValidPassword
}
Now the logic for enabling the button is in only one place inside onValue()
.
After adding ReadSignal
, it also makes sense to add ReadWriteSignal
where value
is mutable and where updating the value will be signaled to listeners.
Moving between signal types
The introduction of different kinds of signals asks for ways to convert between them. For example, we might use a ReadWriteSignal
internally to be able to work with its mutable value
. But externally we only want to expose a non-mutable signal:
let internalState = ReadWriteSignal(false)
var state: ReadSignal<Bool> {
return internalState.readOnly()
}
Similarly, we would like to be able to upgrade a plain Signal
to a ReadSignal
or ReadWriteSignal
. UITextField
's signal could then be implemented as:
extension UITextField: SignalProvider {
var providedSignal: ReadSignal<String> {
return signal(for: .editingChanged)
.map { self.text ?? "" }
.readable(capturing: self.text ?? "")
}
}
Summary
In this article, we explored how the basic Signal
type can be extended in different dimensions to make it even more convenient to work with. Firstly, we added more transforms such as filter()
and distinct()
. After that, we built a simple login view and investigated how to work with UIControl
events. Lastly, we saw that, by combining signals and asking for initial values, we could more easily express our intention and avoid code repetition.
In an upcoming article, we will explore how we could implement parts of what was being discussed in this article, such as ReadSignal
.
To learn more about what was covered in this article, you can download our open sourced framework Flow.
- This is true with many of Apple’s other observation APIs as well ↩︎