Search code examples
react-nativegesture

Cancel button click when finger is dragged off


Goal

Cancel custom button's onResponderRelease() when the user's finger moves off of it. Is there a standard way of doing this that I'm not finding online?

What I've tried

Using onLayout to grab the component's position and the onResponderMove's GestureResponderEvent's pageX/pageY to calculate the click area. However, onLayout only gets the position relative to the parent, so this doesn't work well with modals.

  const handleMove = (e: GestureResponderEvent) => {
    const { pageX, pageY } = e.nativeEvent
    if (pageX < minX || pageX > maxX || pageY < minY || pageY > maxY) {
      setCancelClick(true)
      setLongPressed(false)
      setPressed(false)
    }
  }

  const setComponentDimensions = ({ nativeEvent }: LayoutChangeEvent) => {
    const { x, y, width, height } = nativeEvent.layout
    setMinX(x - BUFFER)
    setMaxX(x + width + BUFFER)
    setMinY(y - BUFFER)
    setMaxY(y + height + BUFFER)
  }

  return (
    <View
      style={[style, pressed && pressedStyle]}
      onStartShouldSetResponder={() => true}
      onResponderGrant={() => setPressed(true)}
      onResponderRelease={handleRelease}
      onResponderMove={handleMove}
      onLayout={setComponentDimensions}
      onResponderTerminationRequest={() => false}
      {...ViewProps}>
      {children}
    </View>
  )

Other thoughts

I have also considered using a useRef and the ref's measure. This might work. A component from another library that I'm using doesn't seem to have have measure on its ref, so my concern is that this may not be the most flexible solution.

  const viewRef = useRef(null as View | null)

  useEffect(() => {
    if (viewRef.current) {
      // TODO: base calculations off of measure
      console.log(viewRef.current.measure)
    }
  }, [viewRef.current])

  ...

  return (
    <View
      ref={(view) => (viewRef.current = view)}
      ...
      {children}
    </View>
  )

Solution

  • Using measure() in the following way seems to work for me.

      const viewRef = useRef(null as View | null)
      const [dimensions, setDimensions] = useState({
        minX: 0,
        maxX: 0,
        minY: 0,
        maxY: 0,
      })
    
      const handleMove = (e: GestureResponderEvent) => {
        const { pageX, pageY } = e.nativeEvent
        const { minX, maxX, minY, maxY } = dimensions
    
        if (pageX < minX || pageX > maxX || pageY < minY || pageY > maxY) {
          setLongPressed(false)
          setPressed(false)
        }
      }
    
      const setComponentDimensions = ({ nativeEvent }: LayoutChangeEvent) => {
        const { width, height } = nativeEvent.layout
        if (viewRef.current) {
          viewRef.current.measure((_a, _b, _width, _height, px, py) => {
            setDimensions({
              minX: px - BUFFER,
              maxX: px + width + BUFFER,
              minY: py - BUFFER,
              maxY: py + height + BUFFER,
            })
          })
        }
      }
    
      return (
        <View
          ref={(view) => (viewRef.current = view)}
          onStartShouldSetResponder={() => true}
          onResponderMove={handleMove}
          onLayout={setComponentDimensions}
          onResponderTerminationRequest={() => false}
          {children}
        </View>
      )
    
    

    Update

    After refactoring my components a bit, and having never tried it before, I decided to publish a package. Hopefully, it helps someone else: react-native-better-buttons.

    I ended up with a simpler hook that just needs to be put on the component's ref

    const useMeasurement = (buffer = 0) => {
      const ref = useRef<View | null>(null)
      const [dimensions, setDimensions] = useState({
        minX: 0,
        maxX: 0,
        minY: 0,
        maxY: 0,
      })
    
      /* This goes on the view's ref property */
      const setRef = (view: View) => (ref.current = view)
    
      /* 
         Updating ref.current does not cause rerenders, which is fine for us.
         We just want to check for ref.current updates when component that uses
         us rerenders.
      */
      useEffect(() => {
        if (ref.current && ref.current.measure) {
          ref.current.measure((_a, _b, width, height, px, py) => {
            setDimensions({
              minX: px - buffer,
              maxX: px + width + buffer,
              minY: py - buffer,
              maxY: py + height + buffer,
            })
          })
        }
      }, [ref.current])
    
      return { dimensions, setRef }
    }