diff options
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts')
-rw-r--r-- | src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts | 400 |
1 files changed, 400 insertions, 0 deletions
diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts new file mode 100644 index 000000000..4600cf1a8 --- /dev/null +++ b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts @@ -0,0 +1,400 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/** + * Copyright (c) JOB TODAY S.A. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {useMemo, useEffect} from 'react' +import { + Animated, + Dimensions, + GestureResponderEvent, + GestureResponderHandlers, + NativeTouchEvent, + PanResponderGestureState, +} from 'react-native' + +import {Position} from '../@types' +import { + createPanResponder, + getDistanceBetweenTouches, + getImageTranslate, + getImageDimensionsByTranslate, +} 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 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 + } + + /** + * 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 + // eslint-disable-next-line @typescript-eslint/no-shadow + 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() + + 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 + } + }, + } + + const panResponder = useMemo(() => createPanResponder(handlers), [handlers]) + + return [panResponder.panHandlers, scaleValue, translateValue] +} + +export default usePanResponder |