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.