about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2023-10-06 03:54:36 +0100
committerGitHub <noreply@github.com>2023-10-05 19:54:36 -0700
commit64153067e3387b71ae5b5c67ae2837c317a08b0d (patch)
tree06f0f32a12d8ed523cee7a0ab325e5330a75adf5 /src/view/com
parent8366fe2c4aae18ef67025386425ea90a83174a72 (diff)
downloadvoidsky-64153067e3387b71ae5b5c67ae2837c317a08b0d.tar.zst
Rewrite Android lightbox (#1624)
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx479
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx45
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx5
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts423
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx42
-rw-r--r--src/view/com/lightbox/ImageViewing/transforms.ts98
-rw-r--r--src/view/com/lightbox/ImageViewing/utils.ts42
7 files changed, 538 insertions, 596 deletions
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
index 6276a1a14..6ff4dee2e 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -1,159 +1,398 @@
-/**
- * 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, {MutableRefObject, useState} from 'react'
 
-import React, {useCallback, useRef, useState} from 'react'
-
-import {
-  Animated,
-  ScrollView,
-  Dimensions,
-  StyleSheet,
-  NativeScrollEvent,
-  NativeSyntheticEvent,
-  NativeMethodsMixin,
-} from 'react-native'
+import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
 import {Image} from 'expo-image'
-
+import Animated, {
+  measure,
+  runOnJS,
+  useAnimatedRef,
+  useAnimatedStyle,
+  useAnimatedReaction,
+  useSharedValue,
+  withDecay,
+  withSpring,
+} from 'react-native-reanimated'
+import {
+  GestureDetector,
+  Gesture,
+  GestureType,
+} from 'react-native-gesture-handler'
 import useImageDimensions from '../../hooks/useImageDimensions'
-import usePanResponder from '../../hooks/usePanResponder'
-
-import {getImageTransform} from '../../utils'
-import {ImageSource} from '../../@types'
-import {ImageLoading} from './ImageLoading'
+import {
+  createTransform,
+  readTransform,
+  applyRounding,
+  prependPan,
+  prependPinch,
+  prependTransform,
+  TransformMatrix,
+} from '../../transforms'
+import type {ImageSource, Dimensions as ImageDimensions} from '../../@types'
 
-const SWIPE_CLOSE_OFFSET = 75
-const SWIPE_CLOSE_VELOCITY = 1.75
 const SCREEN = Dimensions.get('window')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
+const MIN_DOUBLE_TAP_SCALE = 2
+const MAX_ORIGINAL_IMAGE_ZOOM = 2
+
+const AnimatedImage = Animated.createAnimatedComponent(Image)
+const initialTransform = createTransform()
 
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
   onZoom: (isZoomed: boolean) => void
+  pinchGestureRef: MutableRefObject<GestureType | undefined>
+  isScrollViewBeingDragged: boolean
 }
+const ImageItem = ({
+  imageSrc,
+  onZoom,
+  onRequestClose,
+  isScrollViewBeingDragged,
+  pinchGestureRef,
+}: Props) => {
+  const [isScaled, setIsScaled] = useState(false)
+  const [isLoaded, setIsLoaded] = useState(false)
+  const imageDimensions = useImageDimensions(imageSrc)
+  const committedTransform = useSharedValue(initialTransform)
+  const panTranslation = useSharedValue({x: 0, y: 0})
+  const pinchOrigin = useSharedValue({x: 0, y: 0})
+  const pinchScale = useSharedValue(1)
+  const pinchTranslation = useSharedValue({x: 0, y: 0})
+  const dismissSwipeTranslateY = useSharedValue(0)
+  const containerRef = useAnimatedRef()
 
-const AnimatedImage = Animated.createAnimatedComponent(Image)
+  function getCommittedScale(): number {
+    'worklet'
+    const [, , committedScale] = readTransform(committedTransform.value)
+    return committedScale
+  }
 
-const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
-  const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
-  const imageDimensions = useImageDimensions(imageSrc)
-  const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
-  const scrollValueY = new Animated.Value(0)
-  const [isLoaded, setLoadEnd] = useState(false)
-
-  const onLoaded = useCallback(() => setLoadEnd(true), [])
-  const onZoomPerformed = useCallback(
-    (isZoomed: boolean) => {
-      onZoom(isZoomed)
-      if (imageContainer?.current) {
-        imageContainer.current.setNativeProps({
-          scrollEnabled: !isZoomed,
-        })
+  // Keep track of when we're entering or leaving scaled rendering.
+  useAnimatedReaction(
+    () => {
+      return pinchScale.value !== 1 || getCommittedScale() !== 1
+    },
+    (nextIsScaled, prevIsScaled) => {
+      if (nextIsScaled !== prevIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
       }
     },
-    [onZoom],
   )
 
-  const [panHandlers, scaleValue, translateValue] = usePanResponder({
-    initialScale: scale || 1,
-    initialTranslate: translate || {x: 0, y: 0},
-    onZoom: onZoomPerformed,
-  })
+  function handleZoom(nextIsScaled: boolean) {
+    setIsScaled(nextIsScaled)
+    onZoom(nextIsScaled)
+  }
 
-  const imagesStyles = getImageStyles(
-    imageDimensions,
-    translateValue,
-    scaleValue,
-  )
-  const imageOpacity = scrollValueY.interpolate({
-    inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
-    outputRange: [0.7, 1, 0.7],
+  const animatedStyle = useAnimatedStyle(() => {
+    // Apply the active adjustments on top of the committed transform before the gestures.
+    // This is matrix multiplication, so operations are applied in the reverse order.
+    let t = createTransform()
+    prependPan(t, panTranslation.value)
+    prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
+    prependTransform(t, committedTransform.value)
+    const [translateX, translateY, scale] = readTransform(t)
+
+    const dismissDistance = dismissSwipeTranslateY.value
+    const dismissProgress = Math.min(
+      Math.abs(dismissDistance) / (SCREEN.height / 2),
+      1,
+    )
+    return {
+      opacity: 1 - dismissProgress,
+      transform: [
+        {translateX},
+        {translateY: translateY + dismissDistance},
+        {scale},
+      ],
+    }
   })
-  const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
-
-  const onScrollEndDrag = ({
-    nativeEvent,
-  }: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const velocityY = nativeEvent?.velocity?.y ?? 0
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
-
-    if (
-      (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
-        offsetY > SWIPE_CLOSE_OFFSET) ||
-      offsetY > SCREEN_HEIGHT / 2
-    ) {
-      onRequestClose()
+
+  // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
+  // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
+  function getExtraTranslationToStayInBounds(
+    candidateTransform: TransformMatrix,
+  ) {
+    'worklet'
+    if (!imageDimensions) {
+      return [0, 0]
     }
+    const [nextTranslateX, nextTranslateY, nextScale] =
+      readTransform(candidateTransform)
+    const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
+    const clampedTranslateX = clampTranslation(
+      nextTranslateX,
+      scaledDimensions.width,
+      SCREEN.width,
+    )
+    const clampedTranslateY = clampTranslation(
+      nextTranslateY,
+      scaledDimensions.height,
+      SCREEN.height,
+    )
+    const dx = clampedTranslateX - nextTranslateX
+    const dy = clampedTranslateY - nextTranslateY
+    return [dx, dy]
   }
 
-  const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
+  // This is a hack.
+  // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it.
+  // However, there is no great reliable way to coordinate this yet in RGNH.
+  // This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest.
+  const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => {
+    if (isScrollViewBeingDragged) {
+      // Steal the gesture (and do nothing, so native ScrollView does its thing).
+      manager.activate()
+      return
+    }
+    const measurement = measure(containerRef)
+    if (!measurement || measurement.pageX !== 0) {
+      // Steal the gesture (and do nothing, so native ScrollView does its thing).
+      manager.activate()
+      return
+    }
+    // Fail this "fake" gesture so that the gestures after it can proceed.
+    manager.fail()
+  })
 
-    scrollValueY.setValue(offsetY)
-  }
+  const pinch = Gesture.Pinch()
+    .withRef(pinchGestureRef)
+    .onStart(e => {
+      pinchOrigin.value = {
+        x: e.focalX - SCREEN.width / 2,
+        y: e.focalY - SCREEN.height / 2,
+      }
+    })
+    .onChange(e => {
+      if (!imageDimensions) {
+        return
+      }
+      // Don't let the picture zoom in so close that it gets blurry.
+      // Also, like in stock Android apps, don't let the user zoom out further than 1:1.
+      const committedScale = getCommittedScale()
+      const maxCommittedScale =
+        (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+      const minPinchScale = 1 / committedScale
+      const maxPinchScale = maxCommittedScale / committedScale
+      const nextPinchScale = Math.min(
+        Math.max(minPinchScale, e.scale),
+        maxPinchScale,
+      )
+      pinchScale.value = nextPinchScale
 
+      // Zooming out close to the corner could push us out of bounds, which we don't want on Android.
+      // Calculate where we'll end up so we know how much to translate back to stay in bounds.
+      const t = createTransform()
+      prependPan(t, panTranslation.value)
+      prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
+      prependTransform(t, committedTransform.value)
+      const [dx, dy] = getExtraTranslationToStayInBounds(t)
+      if (dx !== 0 || dy !== 0) {
+        pinchTranslation.value = {
+          x: pinchTranslation.value.x + dx,
+          y: pinchTranslation.value.y + dy,
+        }
+      }
+    })
+    .onEnd(() => {
+      // Commit just the pinch.
+      let t = createTransform()
+      prependPinch(
+        t,
+        pinchScale.value,
+        pinchOrigin.value,
+        pinchTranslation.value,
+      )
+      prependTransform(t, committedTransform.value)
+      applyRounding(t)
+      committedTransform.value = t
+
+      // Reset just the pinch.
+      pinchScale.value = 1
+      pinchOrigin.value = {x: 0, y: 0}
+      pinchTranslation.value = {x: 0, y: 0}
+    })
+
+  const pan = Gesture.Pan()
+    .averageTouches(true)
+    // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
+    .minPointers(isScaled ? 1 : 2)
+    .onChange(e => {
+      if (!imageDimensions) {
+        return
+      }
+      const nextPanTranslation = {x: e.translationX, y: e.translationY}
+      let t = createTransform()
+      prependPan(t, nextPanTranslation)
+      prependPinch(
+        t,
+        pinchScale.value,
+        pinchOrigin.value,
+        pinchTranslation.value,
+      )
+      prependTransform(t, committedTransform.value)
+
+      // Prevent panning from going out of bounds.
+      const [dx, dy] = getExtraTranslationToStayInBounds(t)
+      nextPanTranslation.x += dx
+      nextPanTranslation.y += dy
+      panTranslation.value = nextPanTranslation
+    })
+    .onEnd(() => {
+      // Commit just the pan.
+      let t = createTransform()
+      prependPan(t, panTranslation.value)
+      prependTransform(t, committedTransform.value)
+      applyRounding(t)
+      committedTransform.value = t
+
+      // Reset just the pan.
+      panTranslation.value = {x: 0, y: 0}
+    })
+
+  const doubleTap = Gesture.Tap()
+    .numberOfTaps(2)
+    .onEnd(e => {
+      if (!imageDimensions) {
+        return
+      }
+      const committedScale = getCommittedScale()
+      if (committedScale !== 1) {
+        // Go back to 1:1 using the identity vector.
+        let t = createTransform()
+        committedTransform.value = withClampedSpring(t)
+        return
+      }
+
+      // Try to zoom in so that we get rid of the black bars (whatever the orientation was).
+      const imageAspect = imageDimensions.width / imageDimensions.height
+      const screenAspect = SCREEN.width / SCREEN.height
+      const candidateScale = Math.max(
+        imageAspect / screenAspect,
+        screenAspect / imageAspect,
+        MIN_DOUBLE_TAP_SCALE,
+      )
+      // But don't zoom in so close that the picture gets blurry.
+      const maxScale =
+        (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+      const scale = Math.min(candidateScale, maxScale)
+
+      // Calculate where we would be if the user pinched into the double tapped point.
+      // We won't use this transform directly because it may go out of bounds.
+      const candidateTransform = createTransform()
+      const origin = {
+        x: e.absoluteX - SCREEN.width / 2,
+        y: e.absoluteY - SCREEN.height / 2,
+      }
+      prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
+
+      // Now we know how much we went out of bounds, so we can shoot correctly.
+      const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
+      const finalTransform = createTransform()
+      prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
+      committedTransform.value = withClampedSpring(finalTransform)
+    })
+
+  const dismissSwipePan = Gesture.Pan()
+    .enabled(!isScaled)
+    .activeOffsetY([-10, 10])
+    .failOffsetX([-10, 10])
+    .maxPointers(1)
+    .onUpdate(e => {
+      dismissSwipeTranslateY.value = e.translationY
+    })
+    .onEnd(e => {
+      if (Math.abs(e.velocityY) > 1000) {
+        dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
+        runOnJS(onRequestClose)()
+      } else {
+        dismissSwipeTranslateY.value = withSpring(0, {
+          stiffness: 700,
+          damping: 50,
+        })
+      }
+    })
+
+  const isLoading = !isLoaded || !imageDimensions
   return (
-    <ScrollView
-      ref={imageContainer}
-      style={styles.listItem}
-      pagingEnabled
-      nestedScrollEnabled
-      showsHorizontalScrollIndicator={false}
-      showsVerticalScrollIndicator={false}
-      contentContainerStyle={styles.imageScrollContainer}
-      scrollEnabled={true}
-      onScroll={onScroll}
-      onScrollEndDrag={onScrollEndDrag}>
-      <AnimatedImage
-        {...panHandlers}
-        source={imageSrc}
-        style={imageStylesWithOpacity}
-        onLoad={onLoaded}
-        accessibilityLabel={imageSrc.alt}
-        accessibilityHint=""
-      />
-      {(!isLoaded || !imageDimensions) && <ImageLoading />}
-    </ScrollView>
+    <Animated.View ref={containerRef} style={styles.container}>
+      {isLoading && (
+        <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
+      )}
+      <GestureDetector
+        gesture={Gesture.Exclusive(
+          consumeHScroll,
+          dismissSwipePan,
+          Gesture.Simultaneous(pinch, pan),
+          doubleTap,
+        )}>
+        <AnimatedImage
+          source={imageSrc}
+          contentFit="contain"
+          style={[styles.image, animatedStyle]}
+          accessibilityLabel={imageSrc.alt}
+          accessibilityHint=""
+          onLoad={() => setIsLoaded(true)}
+        />
+      </GestureDetector>
+    </Animated.View>
   )
 }
 
 const styles = StyleSheet.create({
-  listItem: {
-    width: SCREEN_WIDTH,
-    height: SCREEN_HEIGHT,
+  container: {
+    width: SCREEN.width,
+    height: SCREEN.height,
+    overflow: 'hidden',
+  },
+  image: {
+    flex: 1,
   },
-  imageScrollContainer: {
-    height: SCREEN_HEIGHT * 2,
+  loading: {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    top: 0,
+    bottom: 0,
   },
 })
 
-const getImageStyles = (
-  image: {width: number; height: number} | null,
-  translate: Animated.ValueXY,
-  scale?: Animated.Value,
-) => {
-  if (!image?.width || !image?.height) {
-    return {width: 0, height: 0}
+function getScaledDimensions(
+  imageDimensions: ImageDimensions,
+  scale: number,
+): ImageDimensions {
+  'worklet'
+  const imageAspect = imageDimensions.width / imageDimensions.height
+  const screenAspect = SCREEN.width / SCREEN.height
+  const isLandscape = imageAspect > screenAspect
+  if (isLandscape) {
+    return {
+      width: scale * SCREEN.width,
+      height: (scale * SCREEN.width) / imageAspect,
+    }
+  } else {
+    return {
+      width: scale * SCREEN.height * imageAspect,
+      height: scale * SCREEN.height,
+    }
   }
+}
 
-  const transform = translate.getTranslateTransform()
-
-  if (scale) {
-    // @ts-ignore TODO - is scale incorrect? might need to remove -prf
-    transform.push({scale}, {perspective: new Animated.Value(1000)})
-  }
+function clampTranslation(
+  value: number,
+  scaledSize: number,
+  screenSize: number,
+): number {
+  'worklet'
+  // Figure out how much the user should be allowed to pan, and constrain the translation.
+  const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
+  const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
+  return clampedValue
+}
 
-  return {
-    width: image.width,
-    height: image.height,
-    transform,
-  }
+function withClampedSpring(value: any) {
+  'worklet'
+  return withSpring(value, {overshootClamping: true})
 }
 
 export default React.memo(ImageItem)
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
index b9f3ae510..598b18ed2 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -6,7 +6,7 @@
  *
  */
 
-import React, {useCallback, useRef, useState} from 'react'
+import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
 
 import {
   Animated,
@@ -20,11 +20,11 @@ import {
   TouchableWithoutFeedback,
 } from 'react-native'
 import {Image} from 'expo-image'
+import {GestureType} from 'react-native-gesture-handler'
 
 import useImageDimensions from '../../hooks/useImageDimensions'
 
-import {getImageTransform} from '../../utils'
-import {ImageSource} from '../../@types'
+import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
 import {ImageLoading} from './ImageLoading'
 
 const DOUBLE_TAP_DELAY = 300
@@ -40,6 +40,8 @@ type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
   onZoom: (scaled: boolean) => void
+  pinchGestureRef: MutableRefObject<GestureType>
+  isScrollViewBeingDragged: boolean
 }
 
 const AnimatedImage = Animated.createAnimatedComponent(Image)
@@ -164,7 +166,7 @@ const styles = StyleSheet.create({
 })
 
 const getZoomRectAfterDoubleTap = (
-  imageDimensions: {width: number; height: number} | null,
+  imageDimensions: ImageDimensions | null,
   touchX: number,
   touchY: number,
 ): {
@@ -252,7 +254,7 @@ const getZoomRectAfterDoubleTap = (
 }
 
 const getImageStyles = (
-  image: {width: number; height: number} | null,
+  image: ImageDimensions | null,
   translate: {readonly x: number; readonly y: number} | undefined,
   scale?: number,
 ) => {
@@ -275,4 +277,37 @@ const getImageStyles = (
   }
 }
 
+const getImageTransform = (
+  image: ImageDimensions | null,
+  screen: ImageDimensions,
+) => {
+  if (!image?.width || !image?.height) {
+    return [] as const
+  }
+
+  const wScale = screen.width / image.width
+  const hScale = screen.height / image.height
+  const scale = Math.min(wScale, hScale)
+  const {x, y} = getImageTranslate(image, screen)
+
+  return [{x, y}, scale] as const
+}
+
+const getImageTranslate = (
+  image: ImageDimensions,
+  screen: ImageDimensions,
+): {x: number; y: number} => {
+  const getTranslateForAxis = (axis: 'x' | 'y'): number => {
+    const imageSize = axis === 'x' ? image.width : image.height
+    const screenSize = axis === 'x' ? screen.width : screen.height
+
+    return (screenSize - imageSize) / 2
+  }
+
+  return {
+    x: getTranslateForAxis('x'),
+    y: getTranslateForAxis('y'),
+  }
+}
+
 export default React.memo(ImageItem)
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
index 82ee86d7d..898b00c78 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -1,13 +1,16 @@
 // default implementation fallback for web
 
-import React from 'react'
+import React, {MutableRefObject} from 'react'
 import {View} from 'react-native'
+import {GestureType} from 'react-native-gesture-handler'
 import {ImageSource} from '../../@types'
 
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
   onZoom: (scaled: boolean) => void
+  pinchGestureRef: MutableRefObject<GestureType | undefined>
+  isScrollViewBeingDragged: boolean
 }
 
 const ImageItem = (_props: Props) => {
diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
deleted file mode 100644
index 85454e37e..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
+++ /dev/null
@@ -1,423 +0,0 @@
-/**
- * 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 {
-  Animated,
-  Dimensions,
-  GestureResponderEvent,
-  GestureResponderHandlers,
-  NativeTouchEvent,
-  PanResponder,
-  PanResponderGestureState,
-} from 'react-native'
-
-import {Position} from '../@types'
-import {getImageTranslate} from '../utils'
-
-const SCREEN = Dimensions.get('window')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
-const ANDROID_BAR_HEIGHT = 24
-
-const MIN_ZOOM = 2
-const MAX_SCALE = 2
-const DOUBLE_TAP_DELAY = 300
-const OUT_BOUND_MULTIPLIER = 0.75
-
-type Props = {
-  initialScale: number
-  initialTranslate: Position
-  onZoom: (isZoomed: boolean) => void
-}
-
-const usePanResponder = ({
-  initialScale,
-  initialTranslate,
-  onZoom,
-}: 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
-
-  // TODO: It's not valid to reinitialize Animated values during render.
-  // This is a bug.
-  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 getTransformAfterDoubleTap = (
-    touchX: number,
-    touchY: number,
-  ): [number, Position] => {
-    let nextScale = initialScale
-    let nextTranslateX = initialTranslate.x
-    let nextTranslateY = initialTranslate.y
-
-    // First, let's figure out how much we want to zoom in.
-    // We want to try to zoom in at least close enough to get rid of black bars.
-    const imageAspect = imageDimensions.width / imageDimensions.height
-    const screenAspect = SCREEN.width / SCREEN.height
-    let zoom = Math.max(
-      imageAspect / screenAspect,
-      screenAspect / imageAspect,
-      MIN_ZOOM,
-    )
-    // Don't zoom so hard that the original image's pixels become blurry.
-    zoom = Math.min(zoom, MAX_SCALE / initialScale)
-    nextScale = initialScale * zoom
-
-    // Next, let's see if we need to adjust the scaled image translation.
-    // Ideally, we want the tapped point to stay under the finger after the scaling.
-    const dx = SCREEN.width / 2 - touchX
-    const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
-    // Before we try to adjust the translation, check how much wiggle room we have.
-    // We don't want to introduce new black bars or make existing black bars unbalanced.
-    const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
-    if (leftBound > rightBound) {
-      // Content fills the screen horizontally so we have horizontal wiggle room.
-      // Try to keep the tapped point under the finger after zoom.
-      nextTranslateX += dx * zoom - dx
-      nextTranslateX = Math.min(nextTranslateX, leftBound)
-      nextTranslateX = Math.max(nextTranslateX, rightBound)
-    }
-    if (topBound > bottomBound) {
-      // Content fills the screen vertically so we have vertical wiggle room.
-      // Try to keep the tapped point under the finger after zoom.
-      nextTranslateY += dy * zoom - dy
-      nextTranslateY = Math.min(nextTranslateY, topBound)
-      nextTranslateY = Math.max(nextTranslateY, bottomBound)
-    }
-
-    return [
-      nextScale,
-      {
-        x: nextTranslateX,
-        y: nextTranslateY,
-      },
-    ]
-  }
-
-  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 panResponder = PanResponder.create({
-    onStartShouldSetPanResponder: () => true,
-    onStartShouldSetPanResponderCapture: () => true,
-    onMoveShouldSetPanResponder: () => true,
-    onMoveShouldSetPanResponderCapture: () => true,
-    onPanResponderGrant: (
-      _: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      numberInitialTouches = gestureState.numberActiveTouches
-
-      if (gestureState.numberActiveTouches > 1) {
-        return
-      }
-    },
-    onPanResponderStart: (
-      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 (isDoubleTapPerformed) {
-        let nextScale = initialScale
-        let nextTranslate = initialTranslate
-
-        const willZoom = currentScale === initialScale
-        if (willZoom) {
-          const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
-          ;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
-            touchX,
-            touchY,
-          )
-        }
-        onZoom(willZoom)
-
-        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()
-      }
-    },
-    onPanResponderMove: (
-      event: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      // Don't need to handle move because double tap in progress (was handled in onStart)
-      if (isDoubleTapPerformed) {
-        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) {
-        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
-
-        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}
-      }
-    },
-    onPanResponderRelease: () => {
-      if (isDoubleTapPerformed) {
-        isDoubleTapPerformed = false
-      }
-
-      if (tmpScale > 0) {
-        if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
-          tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
-          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
-      }
-    },
-    onPanResponderTerminationRequest: () => false,
-    onShouldBlockNativeResponder: () => false,
-  })
-
-  return [panResponder.panHandlers, scaleValue, translateValue]
-}
-
-const getImageDimensionsByTranslate = (
-  translate: Position,
-  screen: {width: number; height: number},
-): {width: number; height: number} => ({
-  width: screen.width - translate.x * 2,
-  height: screen.height - translate.y * 2,
-})
-
-const getDistanceBetweenTouches = (touches: NativeTouchEvent[]): number => {
-  const [a, b] = touches
-
-  if (a == null || b == null) {
-    return 0
-  }
-
-  return Math.sqrt(
-    Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2),
-  )
-}
-
-export default usePanResponder
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 4b7208a22..bc2a8a448 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -10,6 +10,7 @@
 
 import React, {
   ComponentType,
+  createRef,
   useCallback,
   useRef,
   useMemo,
@@ -32,6 +33,7 @@ import ImageItem from './components/ImageItem/ImageItem'
 import ImageDefaultHeader from './components/ImageDefaultHeader'
 
 import {ImageSource} from './@types'
+import {ScrollView, GestureType} from 'react-native-gesture-handler'
 import {Edge, SafeAreaView} from 'react-native-safe-area-context'
 
 type Props = {
@@ -67,6 +69,8 @@ function ImageViewing({
   FooterComponent,
 }: Props) {
   const imageList = useRef<VirtualizedList<ImageSource>>(null)
+  const [isScaled, setIsScaled] = useState(false)
+  const [isDragging, setIsDragging] = useState(false)
   const [opacity, setOpacity] = useState(1)
   const [currentImageIndex, setImageIndex] = useState(imageIndex)
   const [headerTranslate] = useState(
@@ -115,10 +119,9 @@ function ImageViewing({
     }
   }
 
-  const onZoom = (isScaled: boolean) => {
-    // @ts-ignore
-    imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
-    toggleBarsVisible(!isScaled)
+  const onZoom = (nextIsScaled: boolean) => {
+    toggleBarsVisible(!nextIsScaled)
+    setIsScaled(false)
   }
 
   const edges = useMemo(() => {
@@ -134,6 +137,17 @@ function ImageViewing({
     }
   }, [imageList, imageIndex])
 
+  // This is a hack.
+  // RNGH doesn't have an easy way to express that pinch of individual items
+  // should "steal" all pinches from the scroll view. So we're keeping a ref
+  // to all pinch gestures so that we may give them to <ScrollView waitFor={...}>.
+  const [pinchGestureRefs] = useState(new Map())
+  for (let imageSrc of images) {
+    if (!pinchGestureRefs.get(imageSrc)) {
+      pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>())
+    }
+  }
+
   if (!visible) {
     return null
   }
@@ -163,6 +177,7 @@ function ImageViewing({
           data={images}
           horizontal
           pagingEnabled
+          scrollEnabled={!isScaled || isDragging}
           showsHorizontalScrollIndicator={false}
           showsVerticalScrollIndicator={false}
           getItem={(_, index) => images[index]}
@@ -177,9 +192,26 @@ function ImageViewing({
               onZoom={onZoom}
               imageSrc={imageSrc}
               onRequestClose={onRequestCloseEnhanced}
+              pinchGestureRef={pinchGestureRefs.get(imageSrc)}
+              isScrollViewBeingDragged={isDragging}
+            />
+          )}
+          renderScrollComponent={props => (
+            <ScrollView
+              {...props}
+              waitFor={Array.from(pinchGestureRefs.values())}
             />
           )}
-          onMomentumScrollEnd={onScroll}
+          onScrollBeginDrag={() => {
+            setIsDragging(true)
+          }}
+          onScrollEndDrag={() => {
+            setIsDragging(false)
+          }}
+          onMomentumScrollEnd={e => {
+            setIsScaled(false)
+            onScroll(e)
+          }}
           //@ts-ignore
           keyExtractor={(imageSrc, index) =>
             keyExtractor
diff --git a/src/view/com/lightbox/ImageViewing/transforms.ts b/src/view/com/lightbox/ImageViewing/transforms.ts
new file mode 100644
index 000000000..05476678f
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/transforms.ts
@@ -0,0 +1,98 @@
+import type {Position} from './@types'
+
+export type TransformMatrix = [
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+]
+
+// These are affine transforms. See explanation of every cell here:
+// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
+
+export function createTransform(): TransformMatrix {
+  'worklet'
+  return [1, 0, 0, 0, 1, 0, 0, 0, 1]
+}
+
+export function applyRounding(t: TransformMatrix) {
+  'worklet'
+  t[2] = Math.round(t[2])
+  t[5] = Math.round(t[5])
+  // For example: 0.985, 0.99, 0.995, then 1:
+  t[0] = Math.round(t[0] * 200) / 200
+  t[4] = Math.round(t[0] * 200) / 200
+}
+
+// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
+// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
+
+// All write operations are applied in-place to avoid unnecessary allocations.
+
+export function readTransform(t: TransformMatrix): [number, number, number] {
+  'worklet'
+  const scale = t[0]
+  const translateX = t[2]
+  const translateY = t[5]
+  return [translateX, translateY, scale]
+}
+
+export function prependTranslate(t: TransformMatrix, x: number, y: number) {
+  'worklet'
+  t[2] += t[0] * x + t[1] * y
+  t[5] += t[3] * x + t[4] * y
+}
+
+export function prependScale(t: TransformMatrix, value: number) {
+  'worklet'
+  t[0] *= value
+  t[1] *= value
+  t[3] *= value
+  t[4] *= value
+}
+
+export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
+  'worklet'
+  // In-place matrix multiplication.
+  const a00 = ta[0],
+    a01 = ta[1],
+    a02 = ta[2]
+  const a10 = ta[3],
+    a11 = ta[4],
+    a12 = ta[5]
+  const a20 = ta[6],
+    a21 = ta[7],
+    a22 = ta[8]
+  ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]
+  ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]
+  ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]
+  ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]
+  ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]
+  ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]
+  ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]
+  ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]
+  ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]
+}
+
+export function prependPan(t: TransformMatrix, translation: Position) {
+  'worklet'
+  prependTranslate(t, translation.x, translation.y)
+}
+
+export function prependPinch(
+  t: TransformMatrix,
+  scale: number,
+  origin: Position,
+  translation: Position,
+) {
+  'worklet'
+  prependTranslate(t, translation.x, translation.y)
+  prependTranslate(t, origin.x, origin.y)
+  prependScale(t, scale)
+  prependTranslate(t, -origin.x, -origin.y)
+}
diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts
deleted file mode 100644
index 6fc411638..000000000
--- a/src/view/com/lightbox/ImageViewing/utils.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * 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 {Dimensions, Position} from './@types'
-
-export const getImageTransform = (
-  image: Dimensions | null,
-  screen: Dimensions,
-) => {
-  if (!image?.width || !image?.height) {
-    return [] as const
-  }
-
-  const wScale = screen.width / image.width
-  const hScale = screen.height / image.height
-  const scale = Math.min(wScale, hScale)
-  const {x, y} = getImageTranslate(image, screen)
-
-  return [{x, y}, scale] as const
-}
-
-export const getImageTranslate = (
-  image: Dimensions,
-  screen: Dimensions,
-): Position => {
-  const getTranslateForAxis = (axis: 'x' | 'y'): number => {
-    const imageSize = axis === 'x' ? image.width : image.height
-    const screenSize = axis === 'x' ? screen.width : screen.height
-
-    return (screenSize - imageSize) / 2
-  }
-
-  return {
-    x: getTranslateForAxis('x'),
-    y: getTranslateForAxis('y'),
-  }
-}