Handling multiple click events in React Native

joelraju

Joel Raju

Posted on February 13, 2021

Handling multiple click events in React Native

Originally published on my blog.

Introduction to click events in React Native

React Native provides 3 main primitives to handle click events.

  • TouchableHighlight
  • TouchableOpacity
  • TouchableWithoutFeedback

Using any of these is a pretty standard affair. Just wrap our component that needs to respond to click events.

// imports...

<TouchableHighlight onPress={() => console.log('Clicked')}>
  <Text>Click me</Text>
</TouchableHighlight>
Enter fullscreen mode Exit fullscreen mode

Problem with Touchables

Though they work well for most of the use cases, there are some tricky situations which they cannot handle. Consider the case of handling a single click, double click and a long press event, all on the same element.

PanResponder to the rescue

PanResponder provides a predicatable wrapper to the lower level Gesture Responder System API. It provides much granular control over the touch events and also gives access to useful meta info like touch start position, touch end position, velocity of the gesture etc.

Let's learn how to make a View component respond to touch events using PanResponder.

import { View, PanResponder, Text } from 'react-native';

const MyComponent = () => {
  const responder = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onMoveShouldSetPanResponder: () => true,

    onPanResponderStart: (event) => {
      console.log('Touch has started !');
    },

    onPanResponderRelease: (event, gestureState) => {
      console.log('Touch has ended !');
    },

    onPanResponderTerminate: () => {},
  });

  return (
    <View {...responder.panHandlers}>
      <Text>Click Me </Text>
    </View>
  );
};
Enter fullscreen mode Exit fullscreen mode

onStartShouldSetPanResponder should return true to allow the view to become the responder at the start of a touch event.

onMoveShouldSetPanResponder should return true to allow the view to become the responder at the start of a drag event.

onPanResponderStart callback is fired when the PanResponder registers touch events.

onPanResponderRelease callback is fired when the touch has been released.

onPanResponderTerminate callback is fired when the responder has been taken from the View. This can happen when other views makes a call to onPanResponderTerminationRequest or it can be taken by
the OS without asking (happens with control center/ notification center on iOS).

To make the double click work, we need to use a counter and set a maximum time duration between the click so as to treat it as a double click. 400ms of delay between the clicks is a good place to start. We'll use the handleTap to determine the type of click event based on the timer.

const MyComponent = () => {
  const [isTerminated, setTerminated] = useState(false);
  const [touchStartTime, setTouchStartTime] = useState(0);
  const [lastTap, setLastTap] = useState(0);

  const DOUBLE_PRESS_DELAY = 400;

  const handleTap = (event, gestureState) => {
    const timeNow = Date.now();
    if (lastTap && timeNow - lastTap < DOUBLE_PRESS_DELAY) {
      console.log('Handle double press');
    } else {
      setLastTap(timeNow);

      const timeout = setTimeout(() => {
        setLastTap(0);
        console.log('Handle single press');
      }, DOUBLE_PRESS_DELAY);
    }
  };

  const responder = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onMoveShouldSetPanResponder: () => true,

    onPanResponderStart: () => {
      const timeout = setTimeout(() => {
        if (!isTerminated) {
          setTouchStartTime(Date.now());
        }
      });
    },

    onPanResponderRelease: (event, gestureState) => {
      handleTap(event, gestureState);
    },

    onPanResponderTerminate: () => {
      setTerminated(true);
    },
  });

  return (
    <View {...responder.panHandlers}>
      <Text>Click Me </Text>
    </View>
  );
};
Enter fullscreen mode Exit fullscreen mode

And now to make the long press work we need another counter with a delay of 700ms. We'll first check if it is a long press before checking it was a single press or a double press. We'll use handlePressOut to determine the type of click and deligate the action for it.

const MyComponent = () => {
  const [isTerminated, setTerminated] = useState(false);
  const [touchStartTime, setTouchStartTime] = useState(0);
  const [lastTap, setLastTap] = useState(0);

  const [longPressTimer, setLongPressTimer] = useState(0);
  const [singlePressTimer, setSinglePressTimer] = useState(0);

  const DOUBLE_PRESS_DELAY = 400;
  const LONG_PRESS_DELAY = 700;

  const cancelLongPressTimer = () => {
    if (longPressTimer) {
      clearTimeout(longPressTimer);
      setLongPressTimer(0);
    }
  };

  const cancelSinglePressTimer = () => {
    if (singlePressTimer) {
      clearTimeout(singlePressTimer);
      setSinglePressTimer(0);
    }
  };

  const handleTap = (event, gestureState) => {
    cancelSinglePressTimer();

    const timeNow = Date.now();
    if (lastTap && timeNow - lastTap < DOUBLE_PRESS_DELAY) {
      console.log('Handle double press');
    } else {
      setLastTap(timeNow);

      const timeout = setTimeout(() => {
        setLastTap(0);
        console.log('Handle single press');
      }, DOUBLE_PRESS_DELAY);

      setSinglePressTimer(timeout);
    }
  };

  const handlePressOut = (event, gestureState) => {
    const elapsedTime = Date.now() - touchStartTime;
    if (elapsedTime > LONG_PRESS_DELAY) {
      console.log('Handle long press');
    } else {
      handleTap(event, gestureState); // handles the single or double click
    }
    setTouchStartTime(0);
  };

  const responder = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onMoveShouldSetPanResponder: () => true,

    onPanResponderStart: () => {
      cancelLongPressTimer();

      const timeout = setTimeout(() => {
        if (!isTerminated) {
          setTouchStartTime(Date.now());
        }
      });

      setLongPressTimer(timeout);
    },

    onPanResponderRelease: (event, gestureState) => {
      handlePressOut(event, gestureState);
    },

    onPanResponderTerminate: () => {
      setTerminated(true);
    },
  });

  return (
    <View {...responder.panHandlers}>
      <Text>Click Me </Text>
    </View>
  );
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

I've made react-native-gifted-touch which exactly does this so you can handle multiple clicks on the same element effortlessly. The default time delays in the library can be configured using props to better suit your requirements. Feel free to check it out.

💖 💪 🙅 🚩
joelraju
Joel Raju

Posted on February 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related