RxSwift: Typing/Idle Event Stream for NSTextView

To improve my note editing app The Archive's responsiveness, I want to find out when the user is actively editing a note. Here's how to create the idle switch in RxSwift/RxCocoa. A simple enum of .typing and .idle will do for a start.

enum State {
    case typing, idle
}

Of course, NSTextViewDelegate provides a textDidChange(_:) callback to hook into; it's based on a notification, so you can also subscribe to the NSText.didChangeNotification directly if you don't want to use a delegate. That's the input signal I'm going to use.

let textView: NSTextView = ... // e.g. use an @IBOutlet
let textDidChange = NotificationCenter.default.rx
    .notification(NSText.didChangeNotification, object: textView)
    .map { _ in () }

I map the notification to produce an Observable<Void> because I don't need any actual information, just the signal itself. To find out if the user stops typing, use the debounce operator of Rx to wait a second. (Got the idea from StackOverflow)

This is typical of Rx, in my opinion: it's all backwards but makes sense in the end. You don't implement a timer that waits 1 second after the last event (although you could, but the overhead is larger). Instead you ignore events emitted within a time window, then produce an event if that time has elapsed:

let state = Observable
    .merge(textDidChange.map { _ in State.typing },
           textDidChange.debounce(1, scheduler: MainScheduler.instance).map { _ in State.idle })
    .distinctUntilChanged()
    .startWith(.idle)
    // Add these if you reuse the indicator:
    .replay(1)
    .refCount()

You can print this to the console or hook it up to a UI component as an idle indicator. In my test project, I was using a label to show "Idle" and "Typing":

let idleLabel: NSTextFiel = ... // again, an @IBOutlet
state.map { $0.string }
    .bind(to: idleLabel.rx.text)
    .disposed(by: disposeBag)

Like any good OO citizen, I put the string conversion into the State type, of course:

extension State {
    var string: String {
        switch self {
        case .typing: return "Typing ..."
        case .idle: return "Idle"
        }
    }
}

I'd never have figured to use debounce on my own, I guess. In the end, this turned out to be a super simple approach, though.

If you want to write an idle indicator for another UI component, maybe you don't even have to create the initial notification-based connection: NSTextView on Mac does not expose a ControlEvent or ControlProperty for content changes, but NSTextField has .rx.text, and UIKit components have, too. The remainder of this approach will stay the same.

Browse the blog archive