about summary refs log tree commit diff
path: root/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts')
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts400
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