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.
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. Why, when a vanilla JS solution is simple enough? Well, for one I wanted to challenge my brain out of the imperative programming it’s gotten used to. And then, RxJS is all about event streams, so it seems like something right up its alley.
First things first: let’s research existing solutions. I couldn’t find any for the Android thing (unsurprisingly, not a lot of 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.
Someone did come up with a perfectly working solution, which I personally find quite elegant: using the pairwise operator it takes each event along with the previous one, and by subtracting their timestamps it reliably detects 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 a well-defined purpose. Moreover, things like detecting when there are 3 taps left to trigger the action imply yet another set of repeating calculations, per each tap.
So let’s try another approach. Instead of counting up to a desired amount of events, how about we count down to zero? And instead of monitoring entire event chains, we simply reset our counter in a timeout that we set up after each event? This would give us intermediate events detection (3 taps left) out of the box, and a dead-simple triggering condition: when the counter reaches zero.
This means we will have a
counter$ BehaviorSubject, which initially holds a value equal to the maximum 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’s value. The only caveat is that if the timeout between events is small enough (such as for double-clicks), 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. So without any further ado, here it is:
Any feedback is good feedback, and is much appreciated. What would you do differently?