Long Press detection with SwitchMap, Race and Timer

464 views Asked by At

I'm trying to get a single Observable that can distinguish between a regular click (0-100ms) and a long press (exactly at 1000ms).

pseudocode

  1. user clicks and holds
  2. mouseup between 0 - 100ms -> emit click
  3. no mouseup until 1000ms -> emit long press
    1. (BONUS): emit separate event called longPressFinished (click or longPress need to be emitted in any case) after the user eventuelly performs a mouseup sometime after the long press event

Visual representation
time diagram

reproduction
https://codesandbox.io/s/long-press-p4el0?file=/src/index.ts

So far I was able to get close using:

interface UIEventResponse {
  type: UIEventType,
  event: UIEvent
}

type UIEventType = 'click' | 'longPress'

import { fromEvent, merge, Observable, race, timer } from "rxjs";
import { map, mergeMap, switchMap, take, takeUntil } from "rxjs/operators";

const clickTimeout = 100
const longPressTimeout = 1000

const mouseDown$ = fromEvent<MouseEvent>(window, "mousedown");
const mouseUp$ = fromEvent<MouseEvent>(window, "mouseup");
const click1$ = merge(mouseDown$).pipe(
  switchMap((event) => {
    return race(
      timer(longPressTimeout).pipe(mapTo(true)),
      mouseUp$.pipe(mapTo(false))
    );
  })
);

However, if the user keeps the button pressed until before the longPress event can be emitted, it is still emitting a click event.

So I want to restrict the click event to 0-100ms after the mousedown. If the user holds for 1 second it should immediately emit a long press. My current code only works for the regular click but the long press afterwards is ignored:

const click2$: Observable<UIEventResponse> = mouseDown$.pipe(
  switchMap((event) => {
    return race<UIEventResponse>(
      timer(longPressTimeout).pipe(
        mapTo({
          type: "longPress",
          event
        })
      ),
      mouseUp$.pipe(
        takeUntil(timer(clickTimeout)),
        mapTo({
          type: "click",
          event
        })
      )
    );
  })
);

I figure this is because the takeUntil in the second stream of the race unsubscribes the race. How can I prevent the mouseup event from ignoring the first stream in the race and thus still have the long press event emitted?

Any help is greatly appreciated.

3

There are 3 answers

0
Sander Schnydrig On BEST ANSWER

Thanks to @Giovanni Londero for pointing me in the right direction and helping me find a solution that works for me!

const click$: Observable<UIEventResponse> = mouseDown$.pipe(
  switchMap((event) => {
    return race<UIEventResponse>(
      timer(longPressTimeout).pipe(
        mapTo({
          type: "longPress",
          event
        })
      ),
      mouseUp$.pipe(
        mapTo({
          type: "click",
          event
        }),
        timeoutWith(clickTimeout, mouseUp$.pipe(mapTo(undefined)))
      )
    );
  }),
  filter((val) => !!val)
);

I'm happy to get some recommendations on how to improve this code.

3
Giovanni Londero On

Not the cleanest solution, but should help you with your problem; you're free to improve it to avoid repetition.

const click2$: Observable<UIEventResponse> = mouseDown$.pipe(
  switchMap((event) => {
    return race<UIEventResponse>(
      timer(longPressTimeout).pipe(
        mapTo({
          type: "longPress",
          event
        })
      ),
      mouseUp$.pipe(
        mapTo({
          type: "click",
          event
        }),
        timeoutWith(
          clickTimeout,
          mouseUp$.pipe(
            mapTo({
              type: "longPress",
              event
            })
          )
        )
      )
    );
  })
);

Result

If you click and release in under 100ms, it's a click.

If you click and then release after 100ms, it's a longPress.

If you click and don't release, after 2000ms it's a longPress.

Explanation

The race is still used, but instead of takeUntil(timer(...)) I used timeoutWith; this allows to set a timeout, and if the due is passed it uses another observable to treat a mouseUp as a long press.

mapTo used instead of map to clean things up, but it's not necessary.

NOTE: the first mapTo in the mouseUp$.pipe must come before the timeoutWith, as per my example, otherwise the observable returned would always map to "click".

1
Picci On

I am not sure I got the problem right, but maybe the zip function could be your friend in this case.

Here the code

// first create 2 Observables which emit the mousedown and mouseup respectively
// together with a timestamp representing when the event occured
const mouseDown_1$ = fromEvent<MouseEvent>(window, "mousedown").pipe(
  map((event) => {
    const ts = Date.now();
    return { event, ts };
  })
);
const mouseUp_1$ = fromEvent<MouseEvent>(window, "mouseup").pipe(
  map((event) => {
    const ts = Date.now();
    return { event, ts };
  })
);

// then use the zip function to build an Observable which emits a tuple when
// both mouseDown_1$ and mouseUp_1$ notify
const click3$ = zip(mouseDown_1$, mouseUp_1$).pipe(
  // then calculate the time difference between the timestamps and decide
  // whether it was a click or a longPress
  map(([down, up]) => {
    return up.ts - down.ts < clickTimeout
      ? { event: down.event, type: "click" }
      : { event: down.event, type: "longPress" };
  })
);