React to Programmatic Changes to NSControl.state in RxCocoa

null

Say you have a collection of radio buttons. They’re NSButton instances, and NSButton inherits from NSControl. Radio buttons’s mutual exclusivity is implemented by …

  1. Grouping radio buttons from their target and action property, even if the action doesn’t do anything;
  2. Allowing only one control’s state property to be NSControl.StateValue.on, thus switching all others in the group to .off for you.

With the correct setup, you can set 1 out of 100 radio buttons to .on and have the previous selection turned off for you automatically. That’s neat.

You cannot rely on programmatic changes to the state property, though, when you work with RxSwift’s RxCocoa wrapper for NSControl. Because programmatic changes do not trigger AppKit’s target/action mechanism, the callbacks are not invoked. Just as anywhere else in RxCocoa land, when you perform programmatic changes, your Observable will not receive an event. Depending on the mechanism that’s used to provide the reactive extension, you can trigger state updates using Key–Value-Coding or notifications instead. That won’t work for the target/action mechanism used for NSControl.rx.state, though, unless you invoke the selector:

_ = theRadioButton.target?.perform(theRadioButton.action)

That’s pretty ugly and force-unwraps the action for you, potentially causing runtime exceptions, unless you unwrap things safely yourself:

if let action = theRadioButton.action, let target = theRadioButton.target {
    _ = target.perform(action)
}

Meh. Just to trigger side effects that depend on the internal implementation of RxCocoa’s NSControl reactive extension, which is prone to change over time without you noticing. Not good.

If you’re curious how the .rx.state property is implemented, have a look at the current NSButton+Rx.swift code exposing state; you’ll notice it uses the controlProperty factory method you can find in NSControl+Rx.swift. There, upon close inspection, you’ll see a ControlTarget being responsible for generating the events. Then having a look at the implementation of ControlTarget, you will finally see that it changes the target/action of the observed control to itself and its eventHandler method (by the way, I’ll have to test if this will break multiple radio button groups because they all have the same action afterwards), where the callback is invoked, which was set by NSControl+Rx to be the event forwarder.

I don’t expect this to stay the same forever. RxSwift and RxCocoa has a history of huge leaps forward with major version changes, introducing new wrapper mechanisms for the delegate pattern, for example. That’s why I won’t bet on this staying the same forever.

So what I do instead: provide a wrapper observable stream!

class ViewController: NSViewController {
    @IBOutlet var radioButtonA: NSButton!
    let radioButtonAStateChange = PublishRelay<NSControl.StateValue>()
    @IBOutlet var radioButtonB: NSButton!
    let radioButtonBStateChange = PublishRelay<NSControl.StateValue>()
    @IBOutlet var radioButtonC: NSButton!
    let radioButtonCStateChange = PublishRelay<NSControl.StateValue>()
    
    private let disposeBag = DisposeBag()
    
    override func awakeFromNib() {
        super.awakeFromNib()
        wireRadioStates()
    }
    
    private func wireRadio() {
        radioButtonA.rx.state.bind(to: radioButtonAStateChange).disposed(by: disposeBag)
        radioButtonB.rx.state.bind(to: radioButtonBStateChange).disposed(by: disposeBag)
        radioButtonC.rx.state.bind(to: radioButtonCStateChange).disposed(by: disposeBag)
    }
    
    // MARK: - Incoming events

    func updateControlsProgrammatically(whichRadioButton: Int) {
        // ...
        elseif whichRadioButton = 2 {
            radioButtonB.state = .on
            radioButtonAStateChange.accept(.off)
            radioButtonBStateChange.accept(.on)
            radioButtonCStateChange.accept(.off)
        }
        // ...
    }
}

Then you bind your event handlers not to radioButtonB.rx.state directly, but to the relay that can also be triggered programmatically.

“But doesn’t this imply I have to keep a ton of relays around when I have a lot of radio buttons?” – Yes, sure it does!

Depending on your use of radio buttons, you may be lucky: maybe you can put knowledge about which radio button is active in a single observable stream. Set each radio buttons’s tag property to a number and lump state changes together from individual button states to a single PublishRelay<Int> that tells you which button is active based on its tag value.

    let activeRadioTag = PublishRelay<Int>()
    
    func updateControlsProgrammatically(whichRadioButton: Int) {
        // ...
        elseif whichRadioButton = 2 {
            radioButtonB.state = .on
            activeRadioTag.accept(radioButtonB.tag)
        }
        // ...
    }

If you then want to react to “button C is selected” events, you’ll end up with something like activeRadioTag.filter { $0 == radioButtonC.tag }, which isn’t too bad. At least you don’t have to copy and paste during programmatic setting of the state.

So this was another day of writing “"”interesting””” RxSwift/RxCocoa event handlers that also work when you set up your views programmatically with initial display values.