From 80eb318c2ce42a98e267f40c29ab36050c3b23ce Mon Sep 17 00:00:00 2001 From: Maxim Bolshakov Date: Sun, 17 May 2020 13:55:11 +0200 Subject: [PATCH] Android: Fix "long press handler broke gesture recognition" (#44) * Long press for Android * Clean up --- example/App.tsx | 4 +- src/ImageViewing.tsx | 5 +-- .../ImageItem/ImageItem.android.tsx | 38 +++++++--------- src/components/ImageItem/ImageItem.d.ts | 4 +- src/components/ImageItem/ImageItem.ios.tsx | 6 +-- src/hooks/useDoubleTapToZoom.ts | 2 +- src/hooks/useImageDimensions.ts | 1 - ...ZoomPanResponder.ts => usePanResponder.ts} | 43 +++++++++++++++++-- src/utils.ts | 3 ++ 9 files changed, 67 insertions(+), 39 deletions(-) rename src/hooks/{useZoomPanResponder.ts => usePanResponder.ts} (90%) diff --git a/example/App.tsx b/example/App.tsx index df02036..d08dd40 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -44,8 +44,8 @@ export default function App() { const getImageUrls = memoize((images) => images.map((image) => ({ uri: image.original as string })) ); - const onLongPress = (event, image) => { - Alert.alert('Long Pressed', image.uri); + const onLongPress = (image) => { + Alert.alert("Long Pressed", image.uri); }; return ( diff --git a/src/ImageViewing.tsx b/src/ImageViewing.tsx index 6d65632..93e097a 100644 --- a/src/ImageViewing.tsx +++ b/src/ImageViewing.tsx @@ -14,7 +14,6 @@ import { View, VirtualizedList, ModalProps, - GestureResponderEvent, } from "react-native"; import Modal from "./components/Modal/Modal"; @@ -31,7 +30,7 @@ type Props = { imageIndex: number; visible: boolean; onRequestClose: () => void; - onLongPress?: (event: GestureResponderEvent, image: ImageSource) => void; + onLongPress?: (image: ImageSource) => void; onImageIndexChange?: (imageIndex: number) => void; presentationStyle?: ModalProps["presentationStyle"]; animationType?: ModalProps["animationType"]; @@ -61,7 +60,7 @@ function ImageViewing({ presentationStyle, swipeToCloseEnabled, doubleTapToZoomEnabled, - delayLongPress= DEFAULT_DELAY_LONG_PRESS, + delayLongPress = DEFAULT_DELAY_LONG_PRESS, HeaderComponent, FooterComponent, }: Props) { diff --git a/src/components/ImageItem/ImageItem.android.tsx b/src/components/ImageItem/ImageItem.android.tsx index b255e4d..4601b78 100644 --- a/src/components/ImageItem/ImageItem.android.tsx +++ b/src/components/ImageItem/ImageItem.android.tsx @@ -14,12 +14,10 @@ import { StyleSheet, NativeScrollEvent, NativeSyntheticEvent, - TouchableWithoutFeedback, - GestureResponderEvent, } from "react-native"; import useImageDimensions from "../../hooks/useImageDimensions"; -import useZoomPanResponder from "../../hooks/useZoomPanResponder"; +import usePanResponder from "../../hooks/usePanResponder"; import { getImageStyles, getImageTransform } from "../../utils"; import { ImageSource } from "../../@types"; @@ -35,7 +33,7 @@ type Props = { imageSrc: ImageSource; onRequestClose: () => void; onZoom: (isZoomed: boolean) => void; - onLongPress: (event: GestureResponderEvent, image: ImageSource) => void; + onLongPress: (image: ImageSource) => void; delayLongPress: number; swipeToCloseEnabled?: boolean; doubleTapToZoomEnabled?: boolean; @@ -67,11 +65,17 @@ const ImageItem = ({ } }; - const [panHandlers, scaleValue, translateValue] = useZoomPanResponder({ + const onLongPressHandler = useCallback(() => { + onLongPress(imageSrc); + }, [imageSrc, onLongPress]); + + const [panHandlers, scaleValue, translateValue] = usePanResponder({ initialScale: scale || 1, initialTranslate: translate || { x: 0, y: 0 }, onZoom: onZoomPerformed, doubleTapToZoomEnabled, + onLongPress: onLongPressHandler, + delayLongPress, }); const imagesStyles = getImageStyles( @@ -107,13 +111,6 @@ const ImageItem = ({ scrollValueY.setValue(offsetY); }; - const onLongPressHandler = useCallback( - (event: GestureResponderEvent) => { - onLongPress(event, imageSrc); - }, - [] - ); - return ( - - - + {(!isLoaded || !imageDimensions) && } ); diff --git a/src/components/ImageItem/ImageItem.d.ts b/src/components/ImageItem/ImageItem.d.ts index ed36c72..57a902e 100644 --- a/src/components/ImageItem/ImageItem.d.ts +++ b/src/components/ImageItem/ImageItem.d.ts @@ -14,7 +14,7 @@ declare type Props = { imageSrc: ImageSource; onRequestClose: () => void; onZoom: (isZoomed: boolean) => void; - onLongPress: (event: GestureResponderEvent, image: ImageSource) => void; + onLongPress: (image: ImageSource) => void; delayLongPress: number; swipeToCloseEnabled?: boolean; doubleTapToZoomEnabled?: boolean; @@ -26,7 +26,7 @@ declare const _default: React.MemoExoticComponent<({ onRequestClose, onLongPress, delayLongPress, - swipeToCloseEnabled + swipeToCloseEnabled, }: Props) => JSX.Element>; export default _default; diff --git a/src/components/ImageItem/ImageItem.ios.tsx b/src/components/ImageItem/ImageItem.ios.tsx index 1b38c2f..4a96d91 100644 --- a/src/components/ImageItem/ImageItem.ios.tsx +++ b/src/components/ImageItem/ImageItem.ios.tsx @@ -37,7 +37,7 @@ type Props = { imageSrc: ImageSource; onRequestClose: () => void; onZoom: (scaled: boolean) => void; - onLongPress: (event: GestureResponderEvent, image: ImageSource) => void; + onLongPress: (image: ImageSource) => void; delayLongPress: number; swipeToCloseEnabled?: boolean; doubleTapToZoomEnabled?: boolean; @@ -108,9 +108,9 @@ const ImageItem = ({ const onLongPressHandler = useCallback( (event: GestureResponderEvent) => { - onLongPress(event, imageSrc); + onLongPress(imageSrc); }, - [] + [imageSrc, onLongPress] ); return ( diff --git a/src/hooks/useDoubleTapToZoom.ts b/src/hooks/useDoubleTapToZoom.ts index 694ff86..8fcb4a3 100644 --- a/src/hooks/useDoubleTapToZoom.ts +++ b/src/hooks/useDoubleTapToZoom.ts @@ -20,7 +20,7 @@ let lastTapTS: number | null = null; /** * This is iOS only. - * Same functionality for Android implemented inside useZoomPanResponder hook. + * Same functionality for Android implemented inside usePanResponder hook. */ function useDoubleTapToZoom( scrollViewRef: React.RefObject, diff --git a/src/hooks/useImageDimensions.ts b/src/hooks/useImageDimensions.ts index c838a01..f84c7f4 100644 --- a/src/hooks/useImageDimensions.ts +++ b/src/hooks/useImageDimensions.ts @@ -36,7 +36,6 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => { }, // @ts-ignore (error) => { - console.warn(error); resolve({ width: 0, height: 0 }); } ); diff --git a/src/hooks/useZoomPanResponder.ts b/src/hooks/usePanResponder.ts similarity index 90% rename from src/hooks/useZoomPanResponder.ts rename to src/hooks/usePanResponder.ts index 25caed2..202eca8 100644 --- a/src/hooks/useZoomPanResponder.ts +++ b/src/hooks/usePanResponder.ts @@ -6,7 +6,7 @@ * */ -import { useMemo, useEffect } from "react"; +import { useMemo, useEffect, useRef } from "react"; import { Animated, Dimensions, @@ -27,6 +27,7 @@ import { const SCREEN = Dimensions.get("window"); const SCREEN_WIDTH = SCREEN.width; const SCREEN_HEIGHT = SCREEN.height; +const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT); const SCALE_MAX = 2; const DOUBLE_TAP_DELAY = 300; @@ -37,13 +38,17 @@ type Props = { initialTranslate: Position; onZoom: (isZoomed: boolean) => void; doubleTapToZoomEnabled: boolean; + onLongPress: () => void; + delayLongPress: number; }; -const useZoomPanResponder = ({ +const usePanResponder = ({ initialScale, initialTranslate, onZoom, doubleTapToZoomEnabled, + onLongPress, + delayLongPress, }: Props): Readonly< [GestureResponderHandlers, Animated.Value, Animated.ValueXY] > => { @@ -55,7 +60,9 @@ const useZoomPanResponder = ({ let tmpTranslate: Position | null = null; let isDoubleTapPerformed = false; let lastTapTS: number | null = null; + let longPressHandlerRef: number | null = null; + const meaningfulShift = MIN_DIMENSION * 0.01; const scaleValue = new Animated.Value(initialScale); const translateValue = new Animated.ValueXY(initialTranslate); @@ -113,7 +120,21 @@ const useZoomPanResponder = ({ return () => scaleValue.removeAllListeners(); }); + const cancelLongPressHandle = () => { + longPressHandlerRef && clearTimeout(longPressHandlerRef); + }; + const handlers = { + onGrant: ( + _: GestureResponderEvent, + gestureState: PanResponderGestureState + ) => { + numberInitialTouches = gestureState.numberActiveTouches; + + if (gestureState.numberActiveTouches > 1) return; + + longPressHandlerRef = setTimeout(onLongPress, delayLongPress); + }, onStart: ( event: GestureResponderEvent, gestureState: PanResponderGestureState @@ -125,6 +146,7 @@ const useZoomPanResponder = ({ const tapTS = Date.now(); // Handle double tap event by calculating diff between first and second taps timestamps + isDoubleTapPerformed = Boolean( lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY ); @@ -183,8 +205,17 @@ const useZoomPanResponder = ({ event: GestureResponderEvent, gestureState: PanResponderGestureState ) => { + const { dx, dy } = gestureState; + + if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) { + cancelLongPressHandle(); + } + // Don't need to handle move because double tap in progress (was handled in onStart) - if (doubleTapToZoomEnabled && isDoubleTapPerformed) return; + if (doubleTapToZoomEnabled && isDoubleTapPerformed) { + cancelLongPressHandle(); + return; + } if ( numberInitialTouches === 1 && @@ -200,6 +231,8 @@ const useZoomPanResponder = ({ numberInitialTouches === 2 && gestureState.numberActiveTouches === 2; if (isPinchGesture) { + cancelLongPressHandle(); + const initialDistance = getDistanceBetweenTouches(initialTouches); const currentDistance = getDistanceBetweenTouches( event.nativeEvent.touches @@ -292,6 +325,8 @@ const useZoomPanResponder = ({ } }, onRelease: () => { + cancelLongPressHandle(); + if (isDoubleTapPerformed) { isDoubleTapPerformed = false; } @@ -359,4 +394,4 @@ const useZoomPanResponder = ({ return [panResponder.panHandlers, scaleValue, translateValue]; }; -export default useZoomPanResponder; +export default usePanResponder; diff --git a/src/utils.ts b/src/utils.ts index 0ef4ff0..6ab0f08 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -136,6 +136,7 @@ type HandlerType = ( ) => void; type PanResponderProps = { + onGrant: HandlerType; onStart?: HandlerType; onMove: HandlerType; onRelease?: HandlerType; @@ -143,6 +144,7 @@ type PanResponderProps = { }; export const createPanResponder = ({ + onGrant, onStart, onMove, onRelease, @@ -153,6 +155,7 @@ export const createPanResponder = ({ onStartShouldSetPanResponderCapture: () => true, onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponderCapture: () => true, + onPanResponderGrant: onGrant, onPanResponderStart: onStart, onPanResponderMove: onMove, onPanResponderRelease: onRelease,