RxJS multiple events within a timeframe
What do double-clicks and enabling Android Developer settings have in common? Let's find out!
Here’s a weird question: what do double-clicks and enabling Android Developer settings have in common? Well, in order to trigger either of them, you need to perform some actions within some timeframe. The only difference is the number of events required to trigger the desired action, and the amount of time that is allowed to pass between the events in order for the sequence to be considered valid. So while they may seem like completely different things, in essence they are very similar, and in practice they could both use the exact same detection code, just with different settings.
Definition
As a general rule of thumb, a double-click requires the 2 click events to happen within 500ms of each other in order for them to be considered one double-click event instead of 2 separate click events. On an Android phone, in order to get access to the Developer settings area, you need to tap the Build number 7 times in quick succession. After the first few taps you see a countdown message that disappears if you wait longer than (roughly) one second until the next tap.
Let’s see how we can do the same in a browser using RxJS.
Research
A few searches couldn’t find any existing solutions for the Android process. This is unsurprising, as not many people need to implement such functionality in their projects.
But what I found on double-clicks was rather shocking: a lot of blatantly wrong examples out there. The common problem seems to be the use of debounceTime to ensure that only clicks within some time period were counted. While it may sound intuitive to “take all events in 500ms intervals and emit if there’s more than one per interval”, this logic suffers from a fatal flaw: if the first click happens close to the end of one interval and the second click at the start of the next one, then a double-click will be missed and interpreted as 2 single clicks. So while it may appear to be working at first, it will inevitably miss random valid events, creating an uncanny and unstable user experience that doesn’t have an easy or obvious fix.
Emmanuel Higgins did come up with a perfectly working solution, which I find quite elegant: it takes the most recent pair of events using the pairwise operator, and it subtracts their timestamps to reliably detect double-click events. Very clever, but it’s limited to 2 events at a time by the pairwise
operator.
In order to make this solution generic we could scan the events stream and extract the desired amount of latest events, as seen here. But then comparing their timestamps produces a growing number of repeated calculations (same timestamps get subtracted over and over again), and I don’t like it when my code does that without it being absolutely necessary. Moreover, things like detecting when there are 3 events left to trigger the action imply yet another set of repeating calculations, per each event.
Solution design
So let’s try another approach. Instead of counting up to a desired amount of events, how about we count down to zero?
Counting down implies a starting point: that would be the number of events needed to trigger the action. We can initialize a counter
with this value, and instead of monitoring the entire event chain, we can simply reset our counter
in a timeout that we set up after each event. This gives us intermediate detection (3 events left) out of the box, and a simple triggering condition: when the counter
reaches zero.
Implementation
Let’s have a counter$
BehaviorSubject, initialized with the number of events we want to track (e.g. the 7 taps). On every event (which we can obtain via fromEvent) we subtract 1 from our counter
if it’s positive and emit that. If it’s greater than 1 we also set up a timer to reset the counter
, if there’s no follow-up event for a given time period.
Then, a simple subscriber to our counter$
observable can detect either intermediate or complete compound events using the cheapest comparisons possible, on the counter
value. Try it out in the Demo
section below!
A caveat is that if the timeout between events is small enough, setTimeout’s inherent lack of precision might get noticed. No problem though, as there are at least a couple of ways to improve it, so for simplicity in our example we will use setTimeout
.
Demo
See the Pen RxJS event chain detection by Teodor Sandu (@teosan) on CodePen.