简体   繁体   中英

Long Press detection with SwitchMap, Race and Timer

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.

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".

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" };
  })
);

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.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM