about summary refs log tree commit diff
path: root/src/view/com/lightbox/ImageViewing/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/hooks')
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts47
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts65
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts88
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts32
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts25
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts400
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts24
7 files changed, 681 insertions, 0 deletions
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
new file mode 100644
index 000000000..c21cd7f2c
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
@@ -0,0 +1,47 @@
+/**
+ * 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 {Animated} from 'react-native'
+
+const INITIAL_POSITION = {x: 0, y: 0}
+const ANIMATION_CONFIG = {
+  duration: 200,
+  useNativeDriver: true,
+}
+
+const useAnimatedComponents = () => {
+  const headerTranslate = new Animated.ValueXY(INITIAL_POSITION)
+  const footerTranslate = new Animated.ValueXY(INITIAL_POSITION)
+
+  const toggleVisible = (isVisible: boolean) => {
+    if (isVisible) {
+      Animated.parallel([
+        Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
+        Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
+      ]).start()
+    } else {
+      Animated.parallel([
+        Animated.timing(headerTranslate.y, {
+          ...ANIMATION_CONFIG,
+          toValue: -300,
+        }),
+        Animated.timing(footerTranslate.y, {
+          ...ANIMATION_CONFIG,
+          toValue: 300,
+        }),
+      ]).start()
+    }
+  }
+
+  const headerTransform = headerTranslate.getTranslateTransform()
+  const footerTransform = footerTranslate.getTranslateTransform()
+
+  return [headerTransform, footerTransform, toggleVisible] as const
+}
+
+export default useAnimatedComponents
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
new file mode 100644
index 000000000..92746e951
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
@@ -0,0 +1,65 @@
+/**
+ * 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 React, {useCallback} from 'react'
+import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
+
+import {Dimensions} from '../@types'
+
+const DOUBLE_TAP_DELAY = 300
+let lastTapTS: number | null = null
+
+/**
+ * This is iOS only.
+ * Same functionality for Android implemented inside usePanResponder hook.
+ */
+function useDoubleTapToZoom(
+  scrollViewRef: React.RefObject<ScrollView>,
+  scaled: boolean,
+  screen: Dimensions,
+) {
+  const handleDoubleTap = useCallback(
+    (event: NativeSyntheticEvent<NativeTouchEvent>) => {
+      const nowTS = new Date().getTime()
+      const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
+
+      if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
+        const {pageX, pageY} = event.nativeEvent
+        let targetX = 0
+        let targetY = 0
+        let targetWidth = screen.width
+        let targetHeight = screen.height
+
+        // Zooming in
+        // TODO: Add more precise calculation of targetX, targetY based on touch
+        if (!scaled) {
+          targetX = pageX / 2
+          targetY = pageY / 2
+          targetWidth = screen.width / 2
+          targetHeight = screen.height / 2
+        }
+
+        // @ts-ignore
+        scrollResponderRef?.scrollResponderZoomTo({
+          x: targetX,
+          y: targetY,
+          width: targetWidth,
+          height: targetHeight,
+          animated: true,
+        })
+      } else {
+        lastTapTS = nowTS
+      }
+    },
+    [scaled, screen.height, screen.width, scrollViewRef],
+  )
+
+  return handleDoubleTap
+}
+
+export default useDoubleTapToZoom
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
new file mode 100644
index 000000000..bab136c50
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
@@ -0,0 +1,88 @@
+/**
+ * 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 {useEffect, useState} from 'react'
+import {Image, ImageURISource} from 'react-native'
+
+import {createCache} from '../utils'
+import {Dimensions, ImageSource} from '../@types'
+
+const CACHE_SIZE = 50
+const imageDimensionsCache = createCache(CACHE_SIZE)
+
+const useImageDimensions = (image: ImageSource): Dimensions | null => {
+  const [dimensions, setDimensions] = useState<Dimensions | null>(null)
+
+  // eslint-disable-next-line @typescript-eslint/no-shadow
+  const getImageDimensions = (image: ImageSource): Promise<Dimensions> => {
+    return new Promise(resolve => {
+      if (typeof image === 'number') {
+        const cacheKey = `${image}`
+        let imageDimensions = imageDimensionsCache.get(cacheKey)
+
+        if (!imageDimensions) {
+          const {width, height} = Image.resolveAssetSource(image)
+          imageDimensions = {width, height}
+          imageDimensionsCache.set(cacheKey, imageDimensions)
+        }
+
+        resolve(imageDimensions)
+
+        return
+      }
+
+      // @ts-ignore
+      if (image.uri) {
+        const source = image as ImageURISource
+
+        const cacheKey = source.uri as string
+
+        const imageDimensions = imageDimensionsCache.get(cacheKey)
+
+        if (imageDimensions) {
+          resolve(imageDimensions)
+        } else {
+          // @ts-ignore
+          Image.getSizeWithHeaders(
+            source.uri,
+            source.headers,
+            (width: number, height: number) => {
+              imageDimensionsCache.set(cacheKey, {width, height})
+              resolve({width, height})
+            },
+            () => {
+              resolve({width: 0, height: 0})
+            },
+          )
+        }
+      } else {
+        resolve({width: 0, height: 0})
+      }
+    })
+  }
+
+  let isImageUnmounted = false
+
+  useEffect(() => {
+    // eslint-disable-next-line @typescript-eslint/no-shadow
+    getImageDimensions(image).then(dimensions => {
+      if (!isImageUnmounted) {
+        setDimensions(dimensions)
+      }
+    })
+
+    return () => {
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+      isImageUnmounted = true
+    }
+  }, [image])
+
+  return dimensions
+}
+
+export default useImageDimensions
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
new file mode 100644
index 000000000..16430f3aa
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
@@ -0,0 +1,32 @@
+/**
+ * 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 {useState} from 'react'
+import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
+
+import {Dimensions} from '../@types'
+
+const useImageIndexChange = (imageIndex: number, screen: Dimensions) => {
+  const [currentImageIndex, setImageIndex] = useState(imageIndex)
+  const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
+    const {
+      nativeEvent: {
+        contentOffset: {x: scrollX},
+      },
+    } = event
+
+    if (screen.width) {
+      const nextIndex = Math.round(scrollX / screen.width)
+      setImageIndex(nextIndex < 0 ? 0 : nextIndex)
+    }
+  }
+
+  return [currentImageIndex, onScroll] as const
+}
+
+export default useImageIndexChange
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts
new file mode 100644
index 000000000..3969945bb
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts
@@ -0,0 +1,25 @@
+/**
+ * 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 {useEffect} from 'react'
+import {Image} from 'react-native'
+import {ImageSource} from '../@types'
+
+const useImagePrefetch = (images: ImageSource[]) => {
+  useEffect(() => {
+    images.forEach(image => {
+      //@ts-ignore
+      if (image.uri) {
+        //@ts-ignore
+        return Image.prefetch(image.uri)
+      }
+    })
+  }, [images])
+}
+
+export default useImagePrefetch
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
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
new file mode 100644
index 000000000..4cd03fe71
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
@@ -0,0 +1,24 @@
+/**
+ * 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 {useState} from 'react'
+
+const useRequestClose = (onRequestClose: () => void) => {
+  const [opacity, setOpacity] = useState(1)
+
+  return [
+    opacity,
+    () => {
+      setOpacity(0)
+      onRequestClose()
+      setTimeout(() => setOpacity(1), 0)
+    },
+  ] as const
+}
+
+export default useRequestClose