From 26afacc9db2f2464f3b9d72ad2139fb3530d3433 Mon Sep 17 00:00:00 2001 From: Salman <146541764+Salman-s10@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:07:04 +0530 Subject: [PATCH] improve Android pinch-zoom to maintain focus and center correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: On Android, the pinch-to-zoom functionality in the image viewer was zooming from the center instead of the focal point of the pinch. Additionally, when zooming back out, the image would shift abruptly instead of smoothly returning to the center. This caused inconsistent behavior compared to iOS and affected user experience, especially when pinch to zoom on a specific portion of the image. Solution: Updated the usePanResponder hook to calculate translation based on the pinch focal point for Android, so zooming happens relative to where the user pinches. Introduced a threshold (CENTER_THRESHOLD) so the image starts moving toward the center slightly before reaching the initial scale, creating a smooth zoom-out experience. Ensured that double-tap zoom continues to work correctly by preventing the “snap-to-center” logic from overriding double-tap translations. Minor cleanup in onRelease to correctly handle tmpScale, tmpTranslate, and currentScale updates. Impact: Pinch-to-zoom now works correctly on Android, zooming from the pinch location. Zoom-out now smoothly centers the image before reaching the initial scale. Double-tap zoom behavior remains intact and is unaffected by pinch-zoom fixes. Aligns Android behavior with iOS for a consistent cross-platform experience. Testing: Verified pinch-zooming at different focal points. Verified zoom-out centering behavior. Verified double-tap zoom on specific portions of the image. Tested across multiple Android devices to ensure smooth animations. --- src/hooks/usePanResponder.ts | 644 +++++++++++++++-------------------- 1 file changed, 268 insertions(+), 376 deletions(-) diff --git a/src/hooks/usePanResponder.ts b/src/hooks/usePanResponder.ts index 202eca8e..9dd44ec5 100644 --- a/src/hooks/usePanResponder.ts +++ b/src/hooks/usePanResponder.ts @@ -5,393 +5,285 @@ * LICENSE file in the root directory of this source tree. * */ - -import { useMemo, useEffect, useRef } from "react"; -import { - Animated, - Dimensions, - GestureResponderEvent, - GestureResponderHandlers, - NativeTouchEvent, - PanResponderGestureState, -} from "react-native"; - -import { Position } from "../@types"; -import { - createPanResponder, - getDistanceBetweenTouches, - getImageTranslate, - getImageDimensionsByTranslate, -} from "../utils"; - +import { useEffect, useMemo } from "react"; +import { Animated, Dimensions, } from "react-native"; +import { createPanResponder, getDistanceBetweenTouches, getImageDimensionsByTranslate, getImageTranslate, } from "../utils"; 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; const OUT_BOUND_MULTIPLIER = 0.75; - -type Props = { - initialScale: number; - initialTranslate: Position; - onZoom: (isZoomed: boolean) => void; - doubleTapToZoomEnabled: boolean; - onLongPress: () => void; - delayLongPress: number; -}; - -const usePanResponder = ({ - initialScale, - initialTranslate, - onZoom, - doubleTapToZoomEnabled, - onLongPress, - delayLongPress, -}: Props): Readonly< - [GestureResponderHandlers, Animated.Value, Animated.ValueXY] -> => { - let numberInitialTouches = 1; - let initialTouches: NativeTouchEvent[] = []; - let currentScale = initialScale; - let currentTranslate = initialTranslate; - let tmpScale = 0; - 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); - - const imageDimensions = getImageDimensionsByTranslate( - initialTranslate, - SCREEN - ); - - const getBounds = (scale: number) => { - const scaledImageDimensions = { - width: imageDimensions.width * scale, - height: imageDimensions.height * scale, +const CENTER_THRESHOLD = 0.3; // adjust 0.3 = 30% before initial scale +const usePanResponder = ({ initialScale, initialTranslate, onZoom, doubleTapToZoomEnabled, onLongPress, delayLongPress, }) => { + let numberInitialTouches = 1; + let initialTouches = []; + let currentScale = initialScale; + let currentTranslate = initialTranslate; + let tmpScale = 0; + let tmpTranslate = null; + let isDoubleTapPerformed = false; + let lastTapTS = null; + let longPressHandlerRef = null; + const meaningfulShift = MIN_DIMENSION * 0.01; + const scaleValue = new Animated.Value(initialScale); + const translateValue = new Animated.ValueXY(initialTranslate); + const imageDimensions = getImageDimensionsByTranslate(initialTranslate, SCREEN); + const getBounds = (scale) => { + const scaledImageDimensions = { + width: imageDimensions.width * scale, + height: imageDimensions.height * scale, + }; + const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN); + const left = initialTranslate.x - translateDelta.x; + const right = left - (scaledImageDimensions.width - SCREEN.width); + const top = initialTranslate.y - translateDelta.y; + const bottom = top - (scaledImageDimensions.height - SCREEN.height); + return [top, left, bottom, right]; }; - const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN); - - const left = initialTranslate.x - translateDelta.x; - const right = left - (scaledImageDimensions.width - SCREEN.width); - const top = initialTranslate.y - translateDelta.y; - const bottom = top - (scaledImageDimensions.height - SCREEN.height); - - return [top, left, bottom, right]; - }; - - const getTranslateInBounds = (translate: Position, scale: number) => { - const inBoundTranslate = { x: translate.x, y: translate.y }; - const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale); - - if (translate.x > leftBound) { - inBoundTranslate.x = leftBound; - } else if (translate.x < rightBound) { - inBoundTranslate.x = rightBound; - } - - if (translate.y > topBound) { - inBoundTranslate.y = topBound; - } else if (translate.y < bottomBound) { - inBoundTranslate.y = bottomBound; - } - - return inBoundTranslate; - }; - - const fitsScreenByWidth = () => - imageDimensions.width * currentScale < SCREEN_WIDTH; - const fitsScreenByHeight = () => - imageDimensions.height * currentScale < SCREEN_HEIGHT; - - useEffect(() => { - scaleValue.addListener(({ value }) => { - if (typeof onZoom === "function") { - onZoom(value !== initialScale); - } - }); - - 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 - ) => { - initialTouches = event.nativeEvent.touches; - numberInitialTouches = gestureState.numberActiveTouches; - - if (gestureState.numberActiveTouches > 1) return; - - 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 - ); - - if (doubleTapToZoomEnabled && isDoubleTapPerformed) { - const isScaled = currentTranslate.x !== initialTranslate.x; // currentScale !== initialScale; - const { pageX: touchX, pageY: touchY } = event.nativeEvent.touches[0]; - const targetScale = SCALE_MAX; - const nextScale = isScaled ? initialScale : targetScale; - const nextTranslate = isScaled - ? initialTranslate - : getTranslateInBounds( - { - x: - initialTranslate.x + - (SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale), - y: - initialTranslate.y + - (SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale), - }, - targetScale - ); - - onZoom(!isScaled); - - Animated.parallel( - [ - Animated.timing(translateValue.x, { - toValue: nextTranslate.x, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(translateValue.y, { - toValue: nextTranslate.y, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(scaleValue, { - toValue: nextScale, - duration: 300, - useNativeDriver: true, - }), - ], - { stopTogether: false } - ).start(() => { - currentScale = nextScale; - currentTranslate = nextTranslate; - }); - - lastTapTS = null; - } else { - lastTapTS = Date.now(); - } - }, - onMove: ( - 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) { - cancelLongPressHandle(); - return; - } - - if ( - numberInitialTouches === 1 && - gestureState.numberActiveTouches === 2 - ) { - numberInitialTouches = 2; - initialTouches = event.nativeEvent.touches; - } - - const isTapGesture = - numberInitialTouches == 1 && gestureState.numberActiveTouches === 1; - const isPinchGesture = - numberInitialTouches === 2 && gestureState.numberActiveTouches === 2; - - if (isPinchGesture) { - cancelLongPressHandle(); - - const initialDistance = getDistanceBetweenTouches(initialTouches); - const currentDistance = getDistanceBetweenTouches( - event.nativeEvent.touches - ); - - let nextScale = (currentDistance / initialDistance) * currentScale; - - /** - * In case image is scaling smaller than initial size -> - * slow down this transition by applying OUT_BOUND_MULTIPLIER - */ - if (nextScale < initialScale) { - nextScale = - nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER; + const getTranslateInBounds = (translate, scale) => { + const inBoundTranslate = { x: translate.x, y: translate.y }; + const [topBound, leftBound, bottomBound, rightBound] = getBounds(scale); + if (translate.x > leftBound) { + inBoundTranslate.x = leftBound; } - - /** - * In case image is scaling down -> move it in direction of initial position - */ - if (currentScale > initialScale && currentScale > nextScale) { - const k = (currentScale - initialScale) / (currentScale - nextScale); - - const nextTranslateX = - nextScale < initialScale - ? initialTranslate.x - : currentTranslate.x - - (currentTranslate.x - initialTranslate.x) / k; - - const nextTranslateY = - nextScale < initialScale - ? initialTranslate.y - : currentTranslate.y - - (currentTranslate.y - initialTranslate.y) / k; - - translateValue.x.setValue(nextTranslateX); - translateValue.y.setValue(nextTranslateY); - - tmpTranslate = { x: nextTranslateX, y: nextTranslateY }; - } - - scaleValue.setValue(nextScale); - tmpScale = nextScale; - } - - if (isTapGesture && currentScale > initialScale) { - const { x, y } = currentTranslate; - const { dx, dy } = gestureState; - const [topBound, leftBound, bottomBound, rightBound] = getBounds( - currentScale - ); - - let nextTranslateX = x + dx; - let nextTranslateY = y + dy; - - if (nextTranslateX > leftBound) { - nextTranslateX = - nextTranslateX - - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER; - } - - if (nextTranslateX < rightBound) { - nextTranslateX = - nextTranslateX - - (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER; - } - - if (nextTranslateY > topBound) { - nextTranslateY = - nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER; - } - - if (nextTranslateY < bottomBound) { - nextTranslateY = - nextTranslateY - - (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER; - } - - if (fitsScreenByWidth()) { - nextTranslateX = x; - } - - if (fitsScreenByHeight()) { - nextTranslateY = y; + else if (translate.x < rightBound) { + inBoundTranslate.x = rightBound; } - - translateValue.x.setValue(nextTranslateX); - translateValue.y.setValue(nextTranslateY); - - tmpTranslate = { x: nextTranslateX, y: nextTranslateY }; - } - }, - onRelease: () => { - cancelLongPressHandle(); - - if (isDoubleTapPerformed) { - isDoubleTapPerformed = false; - } - - if (tmpScale > 0) { - if (tmpScale < initialScale || tmpScale > SCALE_MAX) { - tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX; - Animated.timing(scaleValue, { - toValue: tmpScale, - duration: 100, - useNativeDriver: true, - }).start(); - } - - currentScale = tmpScale; - tmpScale = 0; - } - - if (tmpTranslate) { - const { x, y } = tmpTranslate; - const [topBound, leftBound, bottomBound, rightBound] = getBounds( - currentScale - ); - - let nextTranslateX = x; - let nextTranslateY = y; - - if (!fitsScreenByWidth()) { - if (nextTranslateX > leftBound) { - nextTranslateX = leftBound; - } else if (nextTranslateX < rightBound) { - nextTranslateX = rightBound; - } + if (translate.y > topBound) { + inBoundTranslate.y = topBound; } - - if (!fitsScreenByHeight()) { - if (nextTranslateY > topBound) { - nextTranslateY = topBound; - } else if (nextTranslateY < bottomBound) { - nextTranslateY = bottomBound; - } + else if (translate.y < bottomBound) { + inBoundTranslate.y = bottomBound; } - - Animated.parallel([ - Animated.timing(translateValue.x, { - toValue: nextTranslateX, - duration: 100, - useNativeDriver: true, - }), - Animated.timing(translateValue.y, { - toValue: nextTranslateY, - duration: 100, - useNativeDriver: true, - }), - ]).start(); - - currentTranslate = { x: nextTranslateX, y: nextTranslateY }; - tmpTranslate = null; - } - }, - }; - - const panResponder = useMemo(() => createPanResponder(handlers), [handlers]); - - return [panResponder.panHandlers, scaleValue, translateValue]; + return inBoundTranslate; + }; + const fitsScreenByWidth = () => imageDimensions.width * currentScale < SCREEN_WIDTH; + const fitsScreenByHeight = () => imageDimensions.height * currentScale < SCREEN_HEIGHT; + useEffect(() => { + scaleValue.addListener(({ value }) => { + if (typeof onZoom === "function") { + onZoom(value !== initialScale); + } + }); + return () => scaleValue.removeAllListeners(); + }); + const cancelLongPressHandle = () => { + longPressHandlerRef && clearTimeout(longPressHandlerRef); + }; + const handlers = { + onGrant: (_, gestureState) => { + numberInitialTouches = gestureState.numberActiveTouches; + if (gestureState.numberActiveTouches > 1) + return; + longPressHandlerRef = setTimeout(onLongPress, delayLongPress); + }, + onStart: (event, gestureState) => { + initialTouches = event.nativeEvent.touches; + numberInitialTouches = gestureState.numberActiveTouches; + if (gestureState.numberActiveTouches > 1) + return; + 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); + if (doubleTapToZoomEnabled && isDoubleTapPerformed) { + const isScaled = currentTranslate.x !== initialTranslate.x; // currentScale !== initialScale; + const { pageX: touchX, pageY: touchY } = event.nativeEvent.touches[0]; + const targetScale = SCALE_MAX; + const nextScale = isScaled ? initialScale : targetScale; + const nextTranslate = isScaled + ? initialTranslate + : getTranslateInBounds({ + x: initialTranslate.x + + (SCREEN_WIDTH / 2 - touchX) * (targetScale / currentScale), + y: initialTranslate.y + + (SCREEN_HEIGHT / 2 - touchY) * (targetScale / currentScale), + }, targetScale); + onZoom(!isScaled); + Animated.parallel([ + Animated.timing(translateValue.x, { + toValue: nextTranslate.x, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(translateValue.y, { + toValue: nextTranslate.y, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(scaleValue, { + toValue: nextScale, + duration: 300, + useNativeDriver: true, + }), + ], { stopTogether: false }).start(() => { + currentScale = nextScale; + currentTranslate = nextTranslate; + }); + lastTapTS = null; + } + else { + lastTapTS = Date.now(); + } + }, + onMove: (event, gestureState) => { + 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) { + cancelLongPressHandle(); + return; + } + if (numberInitialTouches === 1 && + gestureState.numberActiveTouches === 2) { + numberInitialTouches = 2; + initialTouches = event.nativeEvent.touches; + } + const isTapGesture = numberInitialTouches == 1 && gestureState.numberActiveTouches === 1; + const isPinchGesture = numberInitialTouches === 2 && gestureState.numberActiveTouches === 2; + if (isPinchGesture) { + cancelLongPressHandle(); + + const initialDistance = getDistanceBetweenTouches(initialTouches); + const currentDistance = getDistanceBetweenTouches(event.nativeEvent.touches); + + let nextScale = (currentDistance / initialDistance) * currentScale; + if (nextScale < initialScale) { + nextScale = nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER; + } + + // new part – use the pinch focal point instead of fixed centre + const touch1 = event.nativeEvent.touches[0]; + const touch2 = event.nativeEvent.touches[1]; + const focusX = (touch1.pageX + touch2.pageX) / 2; + const focusY = (touch1.pageY + touch2.pageY) / 2; + + const dx = (SCREEN_WIDTH / 2 - focusX) * (nextScale / currentScale - 1); + const dy = (SCREEN_HEIGHT / 2 - focusY) * (nextScale / currentScale - 1); + + const nextTranslateX = currentTranslate.x + dx; + const nextTranslateY = currentTranslate.y + dy; + + translateValue.x.setValue(nextTranslateX); + translateValue.y.setValue(nextTranslateY); + tmpTranslate = { x: nextTranslateX, y: nextTranslateY }; + + // keep the existing scale assignment + scaleValue.setValue(nextScale); + tmpScale = nextScale; + } + if (isTapGesture && currentScale > initialScale) { + const { x, y } = currentTranslate; + const { dx, dy } = gestureState; + const [topBound, leftBound, bottomBound, rightBound] = getBounds(currentScale); + let nextTranslateX = x + dx; + let nextTranslateY = y + dy; + if (nextTranslateX > leftBound) { + nextTranslateX = + nextTranslateX - + (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER; + } + if (nextTranslateX < rightBound) { + nextTranslateX = + nextTranslateX - + (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER; + } + if (nextTranslateY > topBound) { + nextTranslateY = + nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER; + } + if (nextTranslateY < bottomBound) { + nextTranslateY = + nextTranslateY - + (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER; + } + if (fitsScreenByWidth()) { + nextTranslateX = x; + } + if (fitsScreenByHeight()) { + nextTranslateY = y; + } + translateValue.x.setValue(nextTranslateX); + translateValue.y.setValue(nextTranslateY); + tmpTranslate = { x: nextTranslateX, y: nextTranslateY }; + } + }, + onRelease: () => { + cancelLongPressHandle(); + + const wasDoubleTap = isDoubleTapPerformed; + if (isDoubleTapPerformed) { + isDoubleTapPerformed = false; + } + if (tmpScale > 0) { + if (tmpScale < initialScale || tmpScale > SCALE_MAX) { + tmpScale = tmpScale < initialScale ? initialScale : SCALE_MAX; + Animated.timing(scaleValue, { + toValue: tmpScale, + duration: 100, + useNativeDriver: true, + }).start(); + } + currentScale = tmpScale; + tmpScale = 0; + } + if (tmpTranslate) { + const { x, y } = tmpTranslate; + const [topBound, leftBound, bottomBound, rightBound] = getBounds(currentScale); + let nextTranslateX = x; + let nextTranslateY = y; + if (!fitsScreenByWidth()) { + if (nextTranslateX > leftBound) { + nextTranslateX = leftBound; + } + else if (nextTranslateX < rightBound) { + nextTranslateX = rightBound; + } + } + if (!fitsScreenByHeight()) { + if (nextTranslateY > topBound) { + nextTranslateY = topBound; + } + else if (nextTranslateY < bottomBound) { + nextTranslateY = bottomBound; + } + } + Animated.parallel([ + Animated.timing(translateValue.x, { + toValue: nextTranslateX, + duration: 100, + useNativeDriver: true, + }), + Animated.timing(translateValue.y, { + toValue: nextTranslateY, + duration: 100, + useNativeDriver: true, + }), + ]).start(); + currentTranslate = { x: nextTranslateX, y: nextTranslateY }; + tmpTranslate = null; + } + if (!wasDoubleTap && currentScale <= initialScale * (1 + CENTER_THRESHOLD)) { + Animated.parallel([ + Animated.timing(translateValue.x, { + toValue: initialTranslate.x, + duration: 100, + useNativeDriver: true, + }), + Animated.timing(translateValue.y, { + toValue: initialTranslate.y, + duration: 100, + useNativeDriver: true, + }), + ]).start(); + currentTranslate = initialTranslate; + } + }, + }; + const panResponder = useMemo(() => createPanResponder(handlers), [handlers]); + return [panResponder.panHandlers, scaleValue, translateValue]; }; - export default usePanResponder;