Реагировать на длинное событие

Есть ли способ добавить длинное событие пресса в веб-приложение реакции?

У меня есть список адресов. При длительном нажатии на любой адрес, я хочу запустить событие для удаления этого адреса, за которым следует поле подтверждения.

16 ответов

Решение

Вы можете использовать события MouseDown, MouseUp, TouchStart, TouchEnd для управления таймерами, которые могут действовать как длительное нажатие. Проверьте код ниже

class App extends Component {
  constructor() {
    super()
    this.handleButtonPress = this.handleButtonPress.bind(this)
    this.handleButtonRelease = this.handleButtonRelease.bind(this)
  }
  handleButtonPress () {
    this.buttonPressTimer = setTimeout(() => alert('long press activated'), 1500);
  }

  handleButtonRelease () {
    clearTimeout(this.buttonPressTimer);
  }

  render() {
    return (
      <div onTouchStart={this.handleButtonPress} onTouchEnd={this.handleButtonRelease} onMouseDown={this.handleButtonPress} onMouseUp={this.handleButtonRelease}>Button</div>
    );
  }
}

С хуками в реаги 16.8 вы можете переписать класс с помощью функций и хуков.

import { useState, useEffect } from 'react';

export default function useLongPress(callback = () => {}, ms = 300) {
  const [startLogPress, setStartLongPress] = useState(false);

  useEffect(() => {
    let timerId;
    if (startLogPress) {
      timerId = setTimeout(callback, ms);
    } else {
      clearTimeout(timerId);
    }

    return () => {
      clearTimeout(timerId);
    };
  }, [startLogPress]);

  return {
    onMouseDown: () => setStartLongPress(true),
    onMouseUp: () => setStartLongPress(false),
    onMouseLeave: () => setStartLongPress(false),
    onTouchStart: () => setStartLongPress(true),
    onTouchEnd: () => setStartLongPress(false),
  };
}
import useLongPress from './useLongPress';

function MyComponent (props) {
  const backspaceLongPress = useLongPress(props.longPressBackspaceCallback, 500);

  return (
    <Page>
      <Button {...backspaceLongPress}>
        Click me
      </Button>
    </Page>
  );
};

Хороший крюк! Но я хотел бы сделать небольшое улучшение. С помощью useCallback обернуть обработчики событий. Это гарантирует, что они не будут изменены при каждом рендере.

import { useState, useEffect, useCallback } from 'react';

export default function useLongPress(callback = () => {}, ms = 300) {
  const [startLongPress, setStartLongPress] = useState(false);

  useEffect(() => {
    let timerId;
    if (startLongPress) {
      timerId = setTimeout(callback, ms);
    } else {
      clearTimeout(timerId);
    }

    return () => {
      clearTimeout(timerId);
    };
  }, [startLongPress]);

  const start = useCallback(() => {
    setStartLongPress(true);
  }, []);
  const stop = useCallback(() => {
    setStartLongPress(false);
  }, []);

  return {
    onMouseDown: start,
    onMouseUp: stop,
    onMouseLeave: stop,
    onTouchStart: start,
    onTouchEnd: stop,
  };
}

Основываясь на приведенном выше комментарии @Sublime me об избежании многократных повторных рендеров, моя версия не использует ничего, что запускает рендеринг:

export function useLongPress({
  onClick = () => {},
  onLongPress = () => {},
  ms = 300,
} = {}) {
  const timerRef = useRef(false);
  const eventRef = useRef({});

  const callback = useCallback(() => {
    onLongPress(eventRef.current);
    eventRef.current = {};
    timerRef.current = false;
  }, [onLongPress]);

  const start = useCallback(
    (ev) => {
      ev.persist();
      eventRef.current = ev;
      timerRef.current = setTimeout(callback, ms);
    },
    [callback, ms]
  );

  const stop = useCallback(
    (ev) => {
      ev.persist();
      eventRef.current = ev;
      if (timerRef.current) {
        clearTimeout(timerRef.current);
        onClick(eventRef.current);
        timerRef.current = false;
        eventRef.current = {};
      }
    },
    [onClick]
  );

  return useMemo(
    () => ({
      onMouseDown: start,
      onMouseUp: stop,
      onMouseLeave: stop,
      onTouchStart: start,
      onTouchEnd: stop,
    }),
    [start, stop]
  );
}

Он также обеспечивает как onLongPress а также onClick и передает полученный объект события.

Использование в основном такое, как описано ранее, за исключением того, что теперь в объекте передаются аргументы, все они являются необязательными:

  const longPressProps = useLongPress({
    onClick: (ev) => console.log('on click', ev.button, ev.shiftKey),
    onLongPress: (ev) => console.log('on long press', ev.button, ev.shiftKey),
  });

// and later:
  return (<button {...longPressProps}>click me</button>);

Вот версия самого популярного ответа на Typescript , если она кому-то будет полезна:

(это также устраняет проблему с доступом к event свойства в делегированном событии на timeOut используя e.persist() и клонирование события)

useLongPress.ts

      import { useCallback, useRef, useState } from "react";
  
function preventDefault(e: Event) {
  if ( !isTouchEvent(e) ) return;
  
  if (e.touches.length < 2 && e.preventDefault) {
    e.preventDefault();
  }
};

export function isTouchEvent(e: Event): e is TouchEvent {
  return e && "touches" in e;
};

interface PressHandlers<T> {
  onLongPress: (e: React.MouseEvent<T> | React.TouchEvent<T>) => void,
  onClick?: (e: React.MouseEvent<T> | React.TouchEvent<T>) => void,
}

interface Options {
  delay?: number,
  shouldPreventDefault?: boolean
}

export default function useLongPress<T>(
  { onLongPress, onClick }: PressHandlers<T>,
  { delay = 300, shouldPreventDefault = true }
  : Options
  = {}
) {
  const [longPressTriggered, setLongPressTriggered] = useState(false);
  const timeout = useRef<NodeJS.Timeout>();
  const target = useRef<EventTarget>();

  const start = useCallback(
    (e: React.MouseEvent<T> | React.TouchEvent<T>) => {
      e.persist();
      const clonedEvent = {...e};
      
      if (shouldPreventDefault && e.target) {
        e.target.addEventListener(
          "touchend",
          preventDefault,
          { passive: false }
        );
        target.current = e.target;
      }

      timeout.current = setTimeout(() => {
        onLongPress(clonedEvent);
        setLongPressTriggered(true);
      }, delay);
    },
    [onLongPress, delay, shouldPreventDefault]
  );

  const clear = useCallback((
      e: React.MouseEvent<T> | React.TouchEvent<T>,
      shouldTriggerClick = true
    ) => {
      timeout.current && clearTimeout(timeout.current);
      shouldTriggerClick && !longPressTriggered && onClick?.(e);

      setLongPressTriggered(false);

      if (shouldPreventDefault && target.current) {
        target.current.removeEventListener("touchend", preventDefault);
      }
    },
    [shouldPreventDefault, onClick, longPressTriggered]
  );

  return {
    onMouseDown: (e: React.MouseEvent<T>) => start(e),
    onTouchStart: (e: React.TouchEvent<T>) => start(e),
    onMouseUp: (e: React.MouseEvent<T>) => clear(e),
    onMouseLeave: (e: React.MouseEvent<T>) => clear(e, false),
    onTouchEnd: (e: React.TouchEvent<T>) => clear(e)
  };
};

Общий хук, который позволяет избежать повторного рендеринга

Это то, что я использую в производстве, вдохновленное оригинальными ответами. Если ниже есть ошибка, я думаю, у меня есть ошибка в производстве! ‍♂️

Применение

Я хотел сделать хук немного более кратким и обеспечить возможность компоновки, если этого требует реализация (например, добавление быстрого ввода против медленного ввода, а не одиночный обратный вызов).

      const [onStart, onEnd] = useLongPress(() => alert('Old School Alert'), 1000);

return (
  <button
    type="button"
    onTouchStart={onStart}
    onTouchEnd={onEnd}
  >
    Hold Me (Touch Only)
  </button>
)

Реализация

Это более простая реализация, чем кажется. Просто больше строк комментариев.

Я добавил кучу комментариев, так что если вы скопируете/вставите это в свою кодовую базу, ваши коллеги смогут лучше понять это во время PR.

      import {useCallback, useRef} from 'react';

export default function useLongPress(
  // callback that is invoked at the specified duration or `onEndLongPress`
  callback : () => any,
  // long press duration in milliseconds
  ms = 300
) {
  // used to persist the timer state
  // non zero values means the value has never been fired before
  const timerRef = useRef<number>(0);

  // clear timed callback
  const endTimer = () => {
    clearTimeout(timerRef.current || 0);
    timerRef.current = 0;
  };

  // init timer
  const onStartLongPress = useCallback((e) => {
    // stop any previously set timers
    endTimer();

    // set new timeout
    timerRef.current = window.setTimeout(() => {
      callback();
      endTimer();
    }, ms);
  }, [callback, ms]);

  // determine to end timer early and invoke the callback or do nothing
  const onEndLongPress = useCallback(() => {
    // run the callback fn the timer hasn't gone off yet (non zero)
    if (timerRef.current) {
      endTimer();
      callback();
    }
  }, [callback]);

  return [onStartLongPress, onEndLongPress, endTimer];
}

Пример

В примере используется настройка 500 мс. Самопроизвольный круг в GIF показывает, когда я нажимаю.

Это самое простое и лучшее решение, которое я смог сделать самостоятельно.

  • Таким образом, вам не нужно передавать событие щелчка
  • Событие клика все еще работает
  • Хук возвращает функцию вместо самих событий, затем вы можете использовать ее в цикле или условно и передавать разные обратные вызовы каждому элементу.

useLongPress.js

      export default function useLongPress() {
  return function (callback) {
    let timeout;
    let preventClick = false;

    function start() {
      timeout = setTimeout(() => {
        preventClick = true;
        callback();
      }, 300);
    }

    function clear() {
      timeout && clearTimeout(timeout);
      preventClick = false;
    }

    function clickCaptureHandler(e) {
      if (preventClick) {
        e.stopPropagation();
        preventClick = false;
      }
    }

    return {
      onMouseDown: start,
      onTouchStart: start,
      onMouseUp: clear,
      onMouseLeave: clear,
      onTouchMove: clear,
      onTouchEnd: clear,
      onClickCapture: clickCaptureHandler
    };
  }
}

Применение:

      import useLongPress from './useLongPress';

export default function MyComponent(){
  const onLongPress = useLongPress();
  const buttons = ['button one', 'button two', 'button three'];

  return (
    buttons.map(text => 
      <button
        onClick={() => console.log('click still working')}
        {...onLongPress(() => console.log('long press worked for ' + text))}
      >
      {text}
      </button>
    )
  )
}

Вот компонент, который предоставляет события onClick и onHold - при необходимости адаптируйте...

CodeSandbox: https://codesandbox.io/s/hold-press-event-r8q9w

Применение:

import React from 'react'
import Holdable from './holdable'

function App() {

  function onClick(evt) {
    alert('click ' + evt.currentTarget.id)
  }

  function onHold(evt) {
    alert('hold ' + evt.currentTarget.id)
  }

  const ids = 'Label1,Label2,Label3'.split(',')

  return (
    <div className="App">
      {ids.map(id => (
        <Holdable
          onClick={onClick}
          onHold={onHold}
          id={id}
          key={id}
        >
          {id}
        </Holdable>
      ))}
    </div>
  )
}

holdable.jsx:

import React from 'react'

const holdTime = 500 // ms
const holdDistance = 3**2 // pixels squared

export default function Holdable({id, onClick, onHold, children}) {

  const [timer, setTimer] = React.useState(null)
  const [pos, setPos] = React.useState([0,0])

  function onPointerDown(evt) {
    setPos([evt.clientX, evt.clientY]) // save position for later
    const event = { ...evt } // convert synthetic event to real object
    const timeoutId = window.setTimeout(timesup.bind(null, event), holdTime)
    setTimer(timeoutId)
  }

  function onPointerUp(evt) {
    if (timer) {
      window.clearTimeout(timer)
      setTimer(null)
      onClick(evt)
    }
  }

  function onPointerMove(evt) {
    // cancel hold operation if moved too much
    if (timer) {
      const d = (evt.clientX - pos[0])**2 + (evt.clientY - pos[1])**2
      if (d > holdDistance) {
        setTimer(null)  
        window.clearTimeout(timer)
      }
    }
  }

  function timesup(evt) {
    setTimer(null)
    onHold(evt)
  }

  return (
    <div
      onPointerDown={onPointerDown}
      onPointerUp={onPointerUp}
      onPointerMove={onPointerMove}
      id={id}
    >
      {children}
    </div>
  )
}

Примечание: это пока не работает с Safari - события указателя появляются в версии 13 - https://caniuse.com/

После долгих размышлений, просмотра других ответов и добавления новых функций, я думаю, что теперь у меня есть надежная, если не лучшая, реализация долговременного нажатия React. Вот основные моменты:

  • Необходимо передать только одну fn, которая будет использоваться как для onClick, так и для onLongPress, хотя их все еще можно определить по отдельности.
  • Сохраняет fn в ref, чтобы вы могли обновлять состояние, не беспокоясь о том, что fn устареет и не получит последнее состояние реакции.
  • Разрешает статическую или динамическую задержку, поэтому longPress fn может начать выполняться быстрее или медленнее в зависимости от того, как долго кнопка удерживается.
  • Написано машинописным шрифтом
      // useInterval.ts
import React from "react";

export default function useInterval(callback: any, delay: number | null) {
  const savedCallback = React.useRef<any>();

  React.useEffect(() => {
    savedCallback.current = callback;
  });

  React.useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    if (delay !== null) {
      const id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}


// useLongPress.ts
import React from "react";
import useInterval from "./use-interval";

type Fn<T> = (
  e: React.MouseEvent<T, MouseEvent>,
  pressedTimeElapsedInMs: number
) => void;

type Opts<T extends HTMLElement> = {
  shouldPreventDefault?: boolean;
  delay?: number | ((pressedTimeElapsedInMs: number) => number);
  onClick?: boolean | Fn<T>;
};

/**
 * useLongPress hook that handles onClick and longPress events.
 * if you dont pass an onClick fn, the longPress fn will be for onClick.
 * the delay can be a number or a function that recieves how long the button has been pressed.
 * This value can be used to calculate a dynamic value.
 * The onClick and longPress fns will receive the click or touch event as the first parameter,
 * and how long the button has been pressed as the second parameter.
 * @param onLongPress
 * @param opts
 * @returns
 */
export default function useLongPress<T extends HTMLElement>(
  onLongPress: Fn<T>,
  opts: Opts<T> = {}
) {
  const {
    // default onClick to onLongPress if no onClick fn is provided
    onClick = onLongPress,
    shouldPreventDefault = true,
    delay: initialDelay = 300,
  } = opts;

  // hold duration in ms
  const [holdDuration, setHoldDuration] = React.useState(0);
  const [longPressTriggered, setLongPressTriggered] = React.useState(false);
  const [delay, setDelay] = React.useState(0);

  const target = React.useRef<EventTarget | null>(null);
  // store the click or touch event globally so the fn function can pass it to longPress
  const evt = React.useRef<any | null>(null);

  // store the latest onLongPress and onClick fns here to prevent them being stale when used
  const longPressRef = React.useRef<Fn<T>>();
  const clickRef = React.useRef<Fn<T>>();

  // update the onClick and onLongPress fns everytime they change
  React.useEffect(() => {
    longPressRef.current = onLongPress;
    // if false is passed as onClick option, use onLongPress fn in its place
    clickRef.current = typeof onClick === "boolean" ? onLongPress : onClick;
  }, [onClick, onLongPress]);

  // this fn will be called onClick and in on interval when the btn is being held down
  const fn = React.useCallback(() => {
    // call the passed in onLongPress fn, giving it the click
    //  event and the length of time the btn is being held
    longPressRef.current?.(evt.current, holdDuration);
    // get the latest delay duration by passing the current
    // hold duration if it was a fn, or just use the number
    const updatedDelay =
      typeof initialDelay === "function"
        ? initialDelay(holdDuration)
        : initialDelay;

    // update the delay if its dynamic
    setDelay(updatedDelay);
    // update how long the btn has been pressed
    setHoldDuration(holdDuration + updatedDelay);
    setLongPressTriggered(true);
  }, [initialDelay, holdDuration]);

  // start calling the fn function on an interval as the button is being held
  useInterval(fn, longPressTriggered ? delay : null);

  // this fn is called onMouseDown and onTouchStart
  const start = React.useCallback(
    (event: React.MouseEvent<T, MouseEvent> | React.TouchEvent<T>) => {
      if (shouldPreventDefault && event.target) {
        event.target.addEventListener("touchend", preventDefault, {
          passive: false,
        });
        target.current = event.target;
      }

      // globally store the click event
      evt.current = event;
      // call the fn function once, which handles the onClick
      fn();
    },
    [shouldPreventDefault, fn]
  );

  // this fn is called onMouseUp and onTouchEnd
  const clear = React.useCallback(
    (
      event: React.MouseEvent<T, MouseEvent> | React.TouchEvent<T>,
      shouldTriggerClick = true
    ) => {
      // reset how long the btn has been held down
      setHoldDuration(0);

      if (shouldTriggerClick && !longPressTriggered) {
        clickRef.current?.(
          event as React.MouseEvent<T, MouseEvent>,
          holdDuration
        );
      }

      // stop the interval
      setLongPressTriggered(false);
      // clear the globally stored click event
      evt.current = null;
      if (shouldPreventDefault && target.current) {
        target.current.removeEventListener("touchend", preventDefault);
      }
    },
    [clickRef, longPressTriggered, shouldPreventDefault, holdDuration]
  );

  return {
    onMouseDown: (e: React.MouseEvent<T, MouseEvent>) => start(e),
    onMouseUp: (e: React.MouseEvent<T, MouseEvent>) => clear(e),
    onMouseLeave: (e: React.MouseEvent<T, MouseEvent>) => clear(e, false),
    onTouchStart: (e: React.TouchEvent<T>) => start(e),
    onTouchEnd: (e: React.TouchEvent<T>) => clear(e),
  };
}

const assertTouchEvt = (event: Event | TouchEvent): event is TouchEvent => {
  return "touches" in event;
};

const preventDefault = (event: Event | TouchEvent) => {
  if (!assertTouchEvt(event)) return;

  if (event.touches.length < 2 && event.preventDefault) {
    event.preventDefault();
  }
};

Тогда крючок можно использовать следующими способами:

обновление состояния с параметрами по умолчанию

      export default App() {
  const [count, setCount] = React.useState(0)

  const useIncrement = useLongPress((e, holdDurationInMs) => {
    setCount(count + 1)
  })
}

обновление состояния со статической задержкой, где количество увеличивается в зависимости от того, сколько миллисекунд удерживается кнопка

      export default App() {
  const [count, setCount] = React.useState(0)

  const useIncrement = useLongPress((e, holdDurationInMs) => {
    if (holdDurationInMs < 1000) setCount(count + (e.metaKey || e.shiftKey ? 5 : 1))
    else if (holdDurationInMs < 3000) setCount(count + 5)
    else setCount(count + 100)
  }, {
    delay: 300
  })
}

обновление состояния с динамической задержкой, при которой функция выполняется быстрее, чем дольше удерживается кнопка

      export default App() {
  const [count, setCount] = React.useState(0)

  const useIncrement = useLongPress((e, holdDurationInMs) => {
    setCount(count + 1)
  }, {
       delay: (holdDurationInMs) => {
          if (holdDurationInMs < 1000) return 550;
          else if (holdDurationInMs < 3000) return 450;
          else if (holdDurationInMs < 8000) return 250;
          else return 110;
       },
    })
  }

Решение Брайана позволяет передавать параметры детям, что, на мой взгляд, невозможно с помощью крючка. Тем не менее, если я могу предложить немного более чистое решение для наиболее распространенного случая, когда вы хотите добавить поведение onHold к одному компоненту, и вы также хотите иметь возможность изменить время ожидания onHold.

Пример Material-UI с компонентом Chip:

'use strict';

const {
  Chip
} = MaterialUI

function ChipHoldable({
  onClick = () => {},
  onHold = () => {},
  hold = 500,
  ...props
}) {
  const [timer, setTimer] = React.useState(null);

  function onPointerDown(evt) {
    const event = { ...evt
    }; // convert synthetic event to real object
    const timeoutId = window.setTimeout(timesup.bind(null, event), hold);
    setTimer(timeoutId);
  }

  function onPointerUp(evt) {
    if (timer) {
      window.clearTimeout(timer);
      setTimer(null);
      onClick(evt);
    }
  }

  const onContextMenu = e => e.preventDefault();

  const preventDefault = e => e.preventDefault(); // so that ripple effect would be triggered

  function timesup(evt) {
    setTimer(null);
    onHold(evt);
  }

  return React.createElement(Chip, {
    onPointerUp,
    onPointerDown,
    onContextMenu,
    onClick: preventDefault,
    ...props
  });
}

const App = () =>  <div> {[1,2,3,4].map(i => < ChipHoldable style={{margin:"10px"}}label = {`chip${i}`}
    onClick = {
      () => console.log(`chip ${i} clicked`)
    }
    onHold = {
      () => console.log(`chip ${i} long pressed`)
    }
    />)}
    </div>


ReactDOM.render( <App/>, document.querySelector('#root'));
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8" />
</head>

<body>
  <div id="root"></div>
  <script src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
  <script src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
  <script src="https://unpkg.com/@material-ui/core@latest/umd/material-ui.development.js"></script>
</body>

</html>

Просто хотел указать, что хуки здесь не лучшее решение, так как вы не можете использовать их для обратного вызова.

например, если вы хотите добавить длительное нажатие к ряду элементов:

items.map(item => <button {...useLongPress(() => handle(item))}>{item}</button>)

получает вас:

... React Hooks должны вызываться в компоненте функции React или в пользовательской функции React Hook

однако вы можете использовать vanilla JS:

export default function longPressEvents(callback, ms = 500) {
  let timeout = null

  const start = () => timeout = setTimeout(callback, ms)
  const stop = () => timeout && window.clearTimeout(timeout)

  return callback ? {
    onTouchStart: start,
    onTouchMove: stop,
    onTouchEnd: stop,
  } : {}
}

тогда:

items.map(item => <button { ...longPressEvents(() => handle(item)) }>{item}</button>)

демонстрация: https://codesandbox.io/s/long-press-hook-like-oru24?file=/src/App.js

просто имейте в виду, что longPressEventsбудет запускать каждый рендер. Наверное, не имеет большого значения, но о чем следует помнить.

Пример Ionic React LongPress Я использую его с Ionic React, он работает хорошо.

import React, {useState}  from 'react';
import { Route, Redirect } from 'react-router';

interface MainTabsProps { }
const MainTabs: React.FC<MainTabsProps> = () => {

// timeout id  
var initial: any;

// setstate
const [start, setStart] = useState(false);

const handleButtonPress = () => {
  initial = setTimeout(() => {
    setStart(true); // start long button          
    console.log('long press button');
    }, 1500);
}

const handleButtonRelease = () => {
  setStart(false); // stop long press   
  clearTimeout(initial); // clear timeout  
  if(start===false) { // is click
    console.log('click button');
  }  
}

  return (
    <IonPage>
      <IonHeader>
        <IonTitle>Ionic React LongPress</IonTitle>
      </IonHeader>    
      <IonContent className="ion-padding">
        <IonButton expand="block"  
          onMouseDown={handleButtonPress} 
          onMouseUp={handleButtonRelease} >LongPress</IonButton>    
      </IonContent>
    </IonPage>
  );
};

export default MainTabs;

Адаптация решения Дэвида: ловушка React для случаев, когда вы хотите многократно запускать событие. Оно используетsetInterval вместо.

export function useHoldPress(callback = () => {}, ms = 300) {
  const [startHoldPress, setStartHoldPress] = useState(false);

  useEffect(() => {
    let timerId;
    if (startHoldPress) {
      timerId = setInterval(callback, ms);
    } else {
      clearTimeout(timerId);
    }

    return () => {
      clearTimeout(timerId);
    };
  }, [startHoldPress]);

  return {
    onMouseDown: () => setStartHoldPress(true),
    onMouseUp: () => setStartHoldPress(false),
    onMouseLeave: () => setStartHoldPress(false),
    onTouchStart: () => setStartHoldPress(true),
    onTouchEnd: () => setStartHoldPress(false)
  };
}

Спасибо, @sudo bangbang, за этот отличный кастомный хук.

Однако у меня были некоторые проблемы:

Когда я прокручивал таблицу с помощью мобильного устройства (сенсорный ввод), этот хук случайно вызвал щелчок во время прокрутки. Конечно, это не то, чего мы хотим.

Еще одна проблема заключалась в том, что если я прокручивал очень медленно, хук случайно срабатывал при длительном нажатии.

Мне удалось обойти это поведение с небольшими изменениями:

      // Set 'shouldPreventDefault' to false to listen also to 'onMouseUp', 
// would be canceled otherwise if 'shouldPreventDefault' would have been 'true'
const defaultOptions = { shouldPreventDefault: false, delay: 500 };
          return {
        onMouseDown: (e) => start(e),
        onTouchStart: (e) => start(e),
        onMouseUp: (e) => clear(e),
        onMouseLeave: (e) => clear(e, false),
        onTouchEnd: (e) => clear(e, false), // Do not trigger click here
        onTouchMove: (e) => clear(e, false), // Do not trigger click here
    };

Вот моя реализация с изменениями

      import { useCallback, useRef, useState } from "react";

// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// Custom hook to handle a long press event (e.g. on mobile for secondary action)
// https://stackoverflow.com/a/48057286/7220665
// Usage:
//      const onLongPress = () => {console.info('long press is triggered')};
//      const onClick = () => {console.info('click  is triggered')};
//      const defaultOptions = { shouldPreventDefault: false, delay: 500 };
//      const longPressEvent = useLongPress(onLongPress, onClick, defaultOptions);
//      return <button {...longPressEvent}>do long Press</button>
//
// If we are scrolling with the finger 'onTouchStart' and 'onTouchEnd' is triggered
// if we are clicking with the finger additionally to 'onTouchStart' and 'onTouchEnd' ->
// 'onMouseDown' 'onMouseUp' is triggered as well
// We do not want a click event if the user is just scrolling (e.g. in a list or table)
// That means 'onTouchEnd' should not trigger a click
// ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

// Hook
const useLongPress = (onLongPress, onClick, { shouldPreventDefault = true, delay = 300 } = {}) => {
    // console.info("useLongPress");

    const [longPressTriggered, setLongPressTriggered] = useState(false);
    const timeout = useRef();
    const target = useRef();

    //
    // Start the long press if 'onMouseDown' or 'onTouchStart'
    const start = useCallback(
        (event) => {
            console.info("useLongPress start");

            // Create listener
            if (shouldPreventDefault && event.target) {
                event.target.addEventListener("touchend", preventDefault, { passive: false });
                target.current = event.target;
            }

            // A long press event has been triggered
            timeout.current = setTimeout(() => {
                onLongPress(event);
                setLongPressTriggered(true);
            }, delay);
        },
        [onLongPress, delay, shouldPreventDefault]
    );

    //
    // Clear the long press if 'onMouseUp', 'onMouseLeave' or 'onTouchEnd'
    const clear = useCallback(
        (event, shouldTriggerClick = true) => {
            console.info("useLongPress clear event:", event);

            timeout.current && clearTimeout(timeout.current);
            shouldTriggerClick && !longPressTriggered && onClick(event);
            setLongPressTriggered(false);

            // Create listener
            if (shouldPreventDefault && target.current) {
                target.current.removeEventListener("touchend", preventDefault);
            }
        },
        [shouldPreventDefault, onClick, longPressTriggered]
    );

    //
    //
    return {
        onMouseDown: (e) => start(e),
        onTouchStart: (e) => start(e),
        onMouseUp: (e) => clear(e),
        onMouseLeave: (e) => clear(e, false),
        onTouchEnd: (e) => clear(e, false), // Do not trigger click here
        onTouchMove: (e) => clear(e, false), // Do not trigger click here
    };
};

//
// Check if it is a touch event - called by 'preventDefault'
const isTouchEvent = (event) => {
    console.info("useLongPress isTouchEvent");
    return "touches" in event;
};

//
//
const preventDefault = (event) => {
    console.info("useLongPress preventDefault");

    if (!isTouchEvent(event)) return;

    if (event.touches.length < 2 && event.preventDefault) {
        if (event.cancelable) event.preventDefault();
    }
};

export default useLongPress;

Теперь не срабатывает щелчок (который будет вызываться, если мы прокручиваем список или таблицу), аonMouseUp, который будет срабатывать дополнительно кonTouchUpесли мы прокручиваем (хотя на самом деле мы не используем мышь)

Пример скрипта типа сделать обычное событие длительного нажатия

Обзор

Следующее устраняет необходимость в хуке и вместо этого добавляет к кнопке обработчик onLongPress, а также переопределяетevent.detailдля точного отслеживания двойных кликов, тройных кликов и т. д. как на настольных компьютерах, так и на мобильных устройствах.

Его можно использовать следующим образом:

      <Button
  onClick={e => {
     // accurate on both desktop and mobile
     if (e.detail === 2) {
        console.log("double click")
     } else if (e.detail === 3) {
        console.log("triple click")
     }
  })
  onLongPress={(e, pressDuration) => {
     console.log("executes once or every 10ms depending on additional prop")
  })
>
  Click Me, Touch Me, Hold Me Baby
</Button>

The onClickбудет увеличивать внутренний счетчик каждый раз при нажатии кнопки в течение периода устранения дребезга в 400 мс, а затем сбрасывать счетчик обратно в 0 после того, как пройдет более 400 мс без щелчка.

The onLongPressбудет выполняться каждые 10 мс после того, как кнопка удерживается нажатой не менее5000ms. Он возвращает событие, которое его вызвало (событие щелчка или касания), а также продолжительность времени в мс, в течение которого кнопка удерживалась нажатой. Для длительного нажатия доступны две дополнительные опоры:

  1. longPressThreshold: number - количество мс до срабатывания лонгпресса.
  2. longPressOnce: boolean — определяет, выполняется ли longPress fn только один раз или повторно после достижения порогового значения.

Таким образом, чтобы вызвать LongPress только один раз после удержания в течение 3 секунд (вместо 5 секунд по умолчанию):

      <Button
  onClick={e => {
     if (e.detail === 2) {
        console.log("double click")
     } else if (e.detail === 3) {
        console.log("triple click")
     }
  })
  longPressOnce
  longPressThreshold={3000}
  onLongPress={(e, pressDuration) => {
     console.log("executes once")
  })
>
  Longpress
</Button>

Выполнение

Реализация предполагает создание компонента кнопки, который накладывается на обычную кнопку HTML. Для определения продолжительности нажатия кнопки необходим крючок секундомера, а крючок устранения дребезга необходим для сброса счетчика кликов через 400 мс.

использовать секундомер

      import React from "react";

export default function useStopwatch() {
  const [time, setTime] = React.useState(0);
  const [active, setActive] = React.useState(false);

  React.useEffect(() => {
    let interval: NodeJS.Timer | null = null;

    if (active) {
      interval = setInterval(() => {
        setTime((prevTime) => prevTime + 10);
      }, 10);
    } else {
      clearInterval(interval!);
    }

    return () => clearInterval(interval!);
  }, [active]);

  const start = () => setActive(true);
  const reset = () => {
    setActive(false);
    setTime(0);
  };

  return { time, start, reset };
}

использоватьDebounce

      import React from "react";

/**
 * Debounce a function
 * @param pulse
 * @param fn
 * @param delay
 */
export default function useDebounceFn<T = unknown>(
  pulse: T,
  fn: () => void,
  delay: number = 500
) {
  const callbackRef = React.useRef(fn);
  React.useLayoutEffect(() => {
    callbackRef.current = fn;
  });

  // reset the timer to call the fn everytime the pulse value changes
  React.useEffect(() => {
    const timerId = setTimeout(fn, delay);
    return () => clearTimeout(timerId);
  }, [pulse, delay]);
}

С помощью этих двух хуков мы теперь можем создать окончательную реализацию:

Компонент кнопки

      import React from "react";

type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  /** only call longpress fn once */
  longPressOnce?: boolean;
  /** the number of ms needed to trigger a longpress */
  longPressThreshold?: number;
  onLongPress?: (
    e:
      | React.MouseEvent<HTMLButtonElement>
      | React.TouchEvent<HTMLButtonElement>,
    pressDuration: number
  ) => void;
}
export default function Button(props: Props) {
  // do not attach onClick or onLongPress to button directly,
  // instead we will decide when either is called
  const { children, onClick, onLongPress, ...rest } = props;

  // track click count on both browser and mobile using e.detail
  const [clickCount, setClickCount] = React.useState(0);

  // reset click counter to 0 after going 400ms without a click
  useDebounceFn(clickCount, () => setClickCount(0), 400);

  // long press stuff starts here

  // store the event that triggered the long press (click or touch event)
  const evt = React.useRef<any | null>(null);

  // store functions in a ref so they can update state without going stale
  const longPressRef = React.useRef<any>();
  const clickRef = React.useRef<any>();

  const stopwatch = useStopwatch();
  const [touched, setTouched] = React.useState(false);
  const [longPressedOnce, setLongPressedOnce] = React.useState(false);
  const pressDurationRef = React.useRef(0);

  pressDurationRef.current = stopwatch.time;
  const longPressThreshold = props.longPressThreshold ?? 500;

  // keep click and long press fns updated in refs
  React.useEffect(() => {
    longPressRef.current = onLongPress;
    clickRef.current = onClick;
  }, [onLongPress, onClick]);

  // onClick handling
  React.useEffect(() => {
    const pressDuration = pressDurationRef.current;
    // when the user starts holding down the button,
    // immediately begin the stopwatch
    if (touched) {
      stopwatch.start();
    } else {
      // otherwise if the user has just released the button and
      // it is under 500ms, then trigger the onClick and
      // increment click counter
      if (pressDuration && pressDuration < 500) {
        const updatedClickCount = clickCount + 1;
        setClickCount(updatedClickCount);
        evt.current.detail = updatedClickCount;
        clickRef.current?.(evt.current);
      }
      // finally reset the stopwatch since button is no longer held down
      stopwatch.reset();
    }
  }, [touched]);

  // long press handling
  React.useEffect(() => {
    if (!longPressRef.current) return;
    const pressDuration = pressDurationRef.current;

    // if the button has been held down longer than longPress threshold,
    // either execute once, or repeatedly everytime the pressDuration
    // changes, depending on the props provided by the user
    if (pressDuration > longPressThreshold) {
      if (props.longPressOnce) {
        // skip if long press has already been
        // executed once since being touched
        if (longPressedOnce || !touched) return;
        longPressRef.current(evt, pressDuration);
        setLongPressedOnce(true);
      } else {
        // otherwise keep calling long press every 10ms, passing the
        // event and how long the button has been held to the caller
        longPressRef.current(evt, pressDuration);
      }
    }
  }, [pressDurationRef.current, longPressThreshold, longPressedOnce, touched]);

  const isMobile = window.matchMedia("(max-width: 767px)").matches;
 
  const pressProps = isMobile
        ? {
            onTouchStart: (e) => {
              evt.current = e;
              setTouched(true);
              props.onTouchStart?.(e);
            },
            onTouchEnd: (e) => {
              setLongPressedOnce(false);
              setTouched(false);
              props.onTouchEnd?.(e);
            },
          }
        : {
            onMouseDown: (e) => {
              // globally store the click event
              evt.current = e;
              setTouched(true);
              props.onMouseDown?.(e);
            },
            onMouseUp: (e) => {
              setLongPressedOnce(false);
              setTouched(false);
              props.onMouseUp?.(e);
            },
            onMouseLeave: (e) => {
              setLongPressedOnce(false);
              setTouched(false);
              props.onMouseLeave?.(e);
            },
          }

  return (
    <button {...args} {...pressProps}>
       {children}
    </button>
  )
}

Другие вопросы по тегам