Observable controls

Continuing on our previous post, let’s create some observable controls, and a view controller that observes another view controller.

Let’s say we want a button that we can observe. Rather than by setting a target and a selector and a control event, as we do traditionally, like this:

control.addTarget(bridge, action: selector, for: .touchUpInside)

…where we have to define a method whose selector we pass to the control, we want to be able to add observers like this:

button1.addObserver(for: .touchUpInside, handler: { [weak self] in
            self.button1Pressed()
        })

We pass only the control event(s) we are interested in and the corresponding handler to be called when an event occurs.

How do we go about designing such a control?

Well, there is an issue that we need to get out of the way first: Since we want to observe a UIControl, and eventually have to add a target and provide a selector, we need to have functions that can serve as selectors. Meaning, they have to be visible from Objective-C. Since objects sporting generics aren’t visible from ObjC, we need a bridge that provides the crossing from Swift to ObjC. This bridge will provide for all the selectors that are meaningful to a UIControl, and it will hold a reference to the actual control so that it can pass actions through to it.

@objc
final private class ObjCSelectorBridge: NSObject {
    private let control: UIControl

    private lazy var eventSelectors: [UIControl.Event.RawValue: Selector] = [
        UIControl.Event.touchDown.rawValue: #selector(touchDown),
        UIControl.Event.touchDownRepeat.rawValue: #selector(touchDownRepeat),
        UIControl.Event.touchDragInside.rawValue: #selector(touchDragInside),
        UIControl.Event.touchDragOutside.rawValue: #selector(touchDragOutside),
        UIControl.Event.touchDragEnter.rawValue: #selector(touchDragEnter),
        UIControl.Event.touchDragExit.rawValue: #selector(touchDragExit),
        UIControl.Event.touchUpInside.rawValue: #selector(touchUpInside),
        UIControl.Event.touchUpOutside.rawValue: #selector(touchUpOutside),
        UIControl.Event.touchCancel.rawValue: #selector(touchCancel),
        UIControl.Event.valueChanged.rawValue: #selector(valueChanged),
        UIControl.Event.primaryActionTriggered.rawValue: #selector(primaryActionTriggered),
        UIControl.Event.editingDidBegin.rawValue: #selector(editingDidBegin),
        UIControl.Event.editingChanged.rawValue: #selector(editingChanged),
        UIControl.Event.editingDidEnd.rawValue: #selector(editingDidEnd),
        UIControl.Event.editingDidEndOnExit.rawValue: #selector(editingDidEndOnExit)
    ]

    required init(with control: UIControl) {
        self.control = control
    }

    func selectors(for controlEvents: UIControl.Event) -> [(UIControl.Event, Selector)] {
        var selectors = [(UIControl.Event, Selector)]()
        for event in controlEvents {
            if let selector = eventSelectors[event.rawValue] {
                selectors.append((event, selector))
            }
        }

        return selectors
    }
}

We initialize the bridge passing in the relevant UIControl instance, and the bridge knows how to map between control events and selectors. “But, where are the selectors?” you say? Well, we put them in an extension on the bridge:

extension ObjCSelectorBridge {
    @objc fileprivate func touchDown() {
        control.sendActions(for: .touchDown)
    }

    @objc fileprivate func touchDownRepeat() {
        control.sendActions(for: .touchDownRepeat)
    }

    etc

    @objc fileprivate func editingDidEndOnExit() {
        control.sendActions(for: .editingDidEndOnExit)
    }
}

These are the reason we need the bridge. They have to be visible from Objective-C, so that the UIControl can call them.

You may have noticed that we are actually iterating over controlEvents. This is an Optionset (of type UIControl.Event). How’s that possible? Optionsets are not Collections. Well, there’s a trick for that, but its implementation is not relevant to the subject at hand. You can check it out in the full implementation, available in the repo.

Now that we have the bridge in place, we can define a class that will observe a UIControl:

final internal class ControlObserver<Observation>: NSObject {
    internal class EventObserver {
        let observer: Observer<Observation>
        var controlEvents: UIControl.Event

        init(observer: Observer<Observation>, controlEvents: UIControl.Event) {
            self.observer = observer
            self.controlEvents = controlEvents
        }
    }

    private var bridge: ObjCSelectorBridge
    private weak var control: UIControl?
    internal var eventObservers = [EventObserver]()

    required init(with control: UIControl) {
        self.control = control
        self.bridge = ObjCSelectorBridge(with: control)
    }
}

ControlObservers have an associated generic type, so that we can decide what they actually observe. We use a nested helper class that holds information about the observer and the events it responds to. It sets up an empty array of EventObservers, and creates and holds on to a bridge object upon initialization, where it also stores the control it will be observing.

In an extension on ControlObserver we put the utility functions for managing observers. We’ll list the two most important ones:

When adding an observer we request the relevant selectors for the passed in control events. Then we loop through those events and for each add an action (selector) to the control. Finally we create an EventObserver that stores the handler and the applicable control events:

internal func addObserver(for controlEvents: UIControl.Event, handler: @escaping (Observation) -> Void) {
        guard let control = control else { return }
        let eventsAndSelectors = bridge.selectors(for: controlEvents)

        for (controlEvent, selector) in eventsAndSelectors {
            control.addTarget(bridge, action: selector, for: controlEvent)
        }

        let eventObserver = EventObserver(observer: Observer<Observation>(handler: handler), controlEvents: controlEvents)
        eventObservers.append(eventObserver)
    }

We allow removing all observers for a specific control event, or set of control events:

internal func removeObservers(for controlEvents: UIControl.Event) {
        if controlEvents == .allEvents {
            eventObservers.removeAll()
            return
        }

        for event in controlEvents {
            // Remove all observers whose controlEvents match exactly
            eventObservers.removeAll(where: { $0.controlEvents.rawValue == event.rawValue })
            // For those that have more events, and can't be deleted yet, we remove the specific event.
            for eventObserver in eventObservers {
                if eventObserver.controlEvents.contains(event) {
                    eventObserver.controlEvents.remove(event)
                }
            }
        }

        // Finally, we remove the action for the passed in set of control events.
        control?.removeTarget(bridge, action: nil, for: controlEvents)
    }

We also allow retrieving all observers, querying for observers for specific control events, or querying the registered actions. The implementations of these can be found in the full source code.

So, to take stock: we now have a bridge to the Objective-C world, and we have an object that can observe a control. What we need next is a control that can be observed. For that, we create a protocol called—what else?—ObservableControl:

protocol ObservableControl {
    associatedtype Observation

    var controlObserver: ControlObserver<Observation> { get }

    func addObserver(for controlEvents: UIControl.Event, handler: @escaping (Observation) -> Void)
    func removeObservers(for controlEvents: UIControl.Event)

    func sendActions(for controlEvents: UIControl.Event)

    func actions(for controlEvents: UIControl.Event) -> [String]?
    func observers(for controlEvents: UIControl.Event) -> [Observer<Observation>]
    var observers: [Observer<Observation>] { get }
}

We need a ControlObserver, of course, to do the actual observing, and we define an API for adding, removing and monitoring observers, and also for the actual interactions.

We provide default implementations for the bulk of the requirements, to avoid having to duplicate code all over the place:

extension ObservableControl {
    func addObserver(for controlEvents: UIControl.Event, handler: @escaping (Observation) -> Void) {
        controlObserver.addObserver(for: controlEvents, handler: handler)
    }

    func removeObservers(for controlEvents: UIControl.Event) {
        controlObserver.removeObservers(for: controlEvents)
    }

    func observers(for controlEvents: UIControl.Event) -> [Observer<Observation>] {
        return controlObserver.observers(for: controlEvents)
    }

    var observers: [Observer<Observation>] {
        return controlObserver.observers
    }

    func actions(for controlEvent: UIControl.Event) -> [String]? {
        return controlObserver.actions(for: controlEvent)
    }
}

These simply provide a pass-through to the equivalent interface calls of the ControlObserver.

Now we are finally ready to to implement a concrete observable control. Here’s a button:

final class ObservableButton: UIButton, ObservableControl {
    typealias Observation = Void

    lazy internal var controlObserver = ControlObserver<Observation>(with: self)

    override func sendActions(for controlEvents: UIControl.Event) {
        for event in controlEvents {
            for eventObserver in controlObserver.eventObservers {
                guard eventObserver.controlEvents.contains(event) else { continue }
                eventObserver.observer.observe(())
            }
        }
    }
}

So, it’s a proper UIButton, but one that conforms to ObservableControl. As you can see, there’s not much left to do here:

  • We define the type of action to observe: in this case Void since all we are interested in is that the observer is called (for the specific control event it was registered with). There is no value that we are interested in.
  • We create and hold on to a control observer, generic on the type we just defined.
  • We pass triggered actions through to any relevant observers that are registered with us.

How about a slider?

final class ObservableSlider: UISlider, ObservableControl {
    enum Action {
        case isTrackingTouch(_ flag: Bool)
        case valueChanged(_ value: Float)
    }

    typealias Observation = Action

    lazy var controlObserver = ControlObserver<Observation>(with: self)

    override func sendActions(for controlEvents: UIControl.Event) {
        for event in controlEvents {
            for eventObserver in controlObserver.eventObservers {
                guard eventObserver.controlEvents.contains(event) else { return }
                switch controlEvents {
                case .touchDown:
                    eventObserver.observer.observe(.isTrackingTouch(true))
                case .valueChanged:
                    eventObserver.observer.observe(.valueChanged(value))
                case .touchCancel, .touchUpInside, .touchUpOutside, .touchDragOutside, .touchDragExit:
                    eventObserver.observer.observe(.isTrackingTouch(false))
                default:
                    break
                }
            }
        }
    }
}

This one is slightly more interesting, because now we are interested in a value: we want to know when the value has changed, and, in this particular implementation, we also want to know when the slider is tracking a touch. For this we create an enum that provides the state changes we are interested in: isTrackingTouch and valueChanged. These cases each have an associated value: isTrackingTouch has a flag indicating whether tracking has started or ended, and valueChanged has the current value of the slider.

So, now that we have all the pieces in place, how does this look in actual use? Well, to observe a button:

class MyViewController {
    let button = ObservableButton(type: .custom)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupButtonObservers()
    }

    private func setupButtonObservers() {
        button.addObserver(for: .touchDown, handler: { [weak self] in
            self?.enterPanicMode()
        })

        button.addObserver(for: .touchUpInside, handler: { [weak self] in
            self?.exitPanicMode()
        })
    }
}

To observe a slider:

class MyViewController, MultiObservable {
    enum Action {
        
        case sliderValueChanged(newValue: Float)
        case sliderIsTrackingTouch(_ flag: Bool)
        
    }

    private var progressSlider = ObservableSlider()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupSliderObserver()
    }

    private func setupSliderObserver() {
        progressSlider.addObserver(for: [
            .valueChanged, .touchDown, .touchUpInside,
            .touchUpOutside, .touchCancel, .touchDragExit
        ]) { [weak self] (action) in
            guard let self = self else { return }

            switch action {
            case .valueChanged(let newValue):
                for observer in self.observers {
                    observer.observe(.sliderValueChanged(newValue: newValue))
                }
            case .isTrackingTouch(let isTracking):
                for observer in self.observers {
                    observer.observe(.interactiveStateChanged(isActive: isTracking))
                    observer.observe(.sliderIsTrackingTouch(isTracking))
                }
            }
        }
    }
}

Notice, in this last example, that the view controller itself is also observable. In fact, it conforms to MultiObservable, meaning it can have multiple observers. When a slider is interacted with, the controller informs all of its observers of the slider’s new value as its thumb is dragged, and also conveys some other state changes.

I started developing this technique some time before WWDC 2019. Meanwhile, WWDC came and passed and SwiftUI will soon be upon us, with its own variation of attaching handlers to controls. But the technique shown here is available right now, and compatible with iOS 11 and iOS 12, whereas SwiftUI is available only from iOS 13 onwards.

You can find the full source code here.

Published on 7 July, 2019