about summary refs log tree commit diff
path: root/src/view/com/lightbox/ImageViewing
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/lightbox/ImageViewing')
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx467
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx302
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx6
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts47
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts150
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts41
-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.ts431
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts24
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx190
-rw-r--r--src/view/com/lightbox/ImageViewing/transforms.ts98
-rw-r--r--src/view/com/lightbox/ImageViewing/utils.ts139
13 files changed, 747 insertions, 1205 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 f5e858209..7c7ad0616 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -1,157 +1,386 @@
-/**
- * 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, {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, {
+  runOnJS,
+  useAnimatedRef,
+  useAnimatedStyle,
+  useAnimatedReaction,
+  useSharedValue,
+  withDecay,
+  withSpring,
+} from 'react-native-reanimated'
+import {GestureDetector, Gesture} from 'react-native-gesture-handler'
 import useImageDimensions from '../../hooks/useImageDimensions'
-import usePanResponder from '../../hooks/usePanResponder'
-
-import {getImageStyles, 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
+  onTap: () => void
   onZoom: (isZoomed: boolean) => void
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
-
-const AnimatedImage = Animated.createAnimatedComponent(Image)
-
 const ImageItem = ({
   imageSrc,
+  onTap,
   onZoom,
   onRequestClose,
-  onLongPress,
-  delayLongPress,
-  swipeToCloseEnabled = true,
-  doubleTapToZoomEnabled = true,
+  isScrollViewBeingDragged,
 }: Props) => {
-  const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
+  const [isScaled, setIsScaled] = useState(false)
+  const [isLoaded, setIsLoaded] = useState(false)
   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,
-        })
+  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()
+
+  // Keep track of when we're entering or leaving scaled rendering.
+  // Note: DO NOT move any logic reading animated values outside this function.
+  useAnimatedReaction(
+    () => {
+      if (pinchScale.value !== 1) {
+        // We're currently pinching.
+        return true
+      }
+      const [, , committedScale] = readTransform(committedTransform.value)
+      if (committedScale !== 1) {
+        // We started from a pinched in state.
+        return true
+      }
+      // We're at rest.
+      return false
+    },
+    (nextIsScaled, prevIsScaled) => {
+      if (nextIsScaled !== prevIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
       }
     },
-    [onZoom],
   )
 
-  const onLongPressHandler = useCallback(() => {
-    onLongPress(imageSrc)
-  }, [imageSrc, onLongPress])
-
-  const [panHandlers, scaleValue, translateValue] = usePanResponder({
-    initialScale: scale || 1,
-    initialTranslate: translate || {x: 0, y: 0},
-    onZoom: onZoomPerformed,
-    doubleTapToZoomEnabled,
-    onLongPress: onLongPressHandler,
-    delayLongPress,
-  })
+  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
+  const pinch = Gesture.Pinch()
+    .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] = readTransform(committedTransform.value)
+      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
 
-    scrollValueY.setValue(offsetY)
-  }
+      // 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 singleTap = Gesture.Tap().onEnd(() => {
+    runOnJS(onTap)()
+  })
+
+  const doubleTap = Gesture.Tap()
+    .numberOfTaps(2)
+    .onEnd(e => {
+      if (!imageDimensions) {
+        return
+      }
+      const [, , committedScale] = readTransform(committedTransform.value)
+      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 composedGesture = isScrollViewBeingDragged
+    ? // If the parent is not at rest, provide a no-op gesture.
+      Gesture.Manual()
+    : Gesture.Exclusive(
+        dismissSwipePan,
+        Gesture.Simultaneous(pinch, pan),
+        doubleTap,
+        singleTap,
+      )
+
+  const isLoading = !isLoaded || !imageDimensions
   return (
-    <ScrollView
-      ref={imageContainer}
-      style={styles.listItem}
-      pagingEnabled
-      nestedScrollEnabled
-      showsHorizontalScrollIndicator={false}
-      showsVerticalScrollIndicator={false}
-      contentContainerStyle={styles.imageScrollContainer}
-      scrollEnabled={swipeToCloseEnabled}
-      {...(swipeToCloseEnabled && {
-        onScroll,
-        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={composedGesture}>
+        <AnimatedImage
+          contentFit="contain"
+          // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
+          source={{uri: imageSrc.uri}}
+          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,
   },
 })
 
+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,
+    }
+  }
+}
+
+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
+}
+
+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 03bf45af1..f73f355ac 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -6,159 +6,251 @@
  *
  */
 
-import React, {useCallback, useRef, useState} from 'react'
-
-import {
-  Animated,
-  Dimensions,
-  ScrollView,
-  StyleSheet,
-  View,
-  NativeScrollEvent,
-  NativeSyntheticEvent,
-  TouchableWithoutFeedback,
-} from 'react-native'
+import React, {useState} from 'react'
+
+import {Dimensions, StyleSheet} from 'react-native'
 import {Image} from 'expo-image'
+import Animated, {
+  interpolate,
+  runOnJS,
+  useAnimatedRef,
+  useAnimatedScrollHandler,
+  useAnimatedStyle,
+  useSharedValue,
+} from 'react-native-reanimated'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
 
-import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
 import useImageDimensions from '../../hooks/useImageDimensions'
 
-import {getImageStyles, getImageTransform} from '../../utils'
-import {ImageSource} from '../../@types'
+import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
 import {ImageLoading} from './ImageLoading'
 
 const SWIPE_CLOSE_OFFSET = 75
 const SWIPE_CLOSE_VELOCITY = 1
 const SCREEN = Dimensions.get('screen')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
-const MAX_SCALE = 2
+const MAX_ORIGINAL_IMAGE_ZOOM = 2
+const MIN_DOUBLE_TAP_SCALE = 2
 
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (scaled: boolean) => void
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
 
 const AnimatedImage = Animated.createAnimatedComponent(Image)
 
-const ImageItem = ({
-  imageSrc,
-  onZoom,
-  onRequestClose,
-  onLongPress,
-  delayLongPress,
-  swipeToCloseEnabled = true,
-  doubleTapToZoomEnabled = true,
-}: Props) => {
-  const scrollViewRef = useRef<ScrollView>(null)
+const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
+  const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
+  const translationY = useSharedValue(0)
   const [loaded, setLoaded] = useState(false)
   const [scaled, setScaled] = useState(false)
   const imageDimensions = useImageDimensions(imageSrc)
-  const handleDoubleTap = useDoubleTapToZoom(
-    scrollViewRef,
-    scaled,
-    SCREEN,
-    imageDimensions,
-  )
-
-  const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
-  const scrollValueY = new Animated.Value(0)
-  const scaleValue = new Animated.Value(scale || 1)
-  const translateValue = new Animated.ValueXY(translate)
-  const maxScrollViewZoom = MAX_SCALE / (scale || 1)
+  const maxZoomScale = imageDimensions
+    ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+    : 1
 
-  const imageOpacity = scrollValueY.interpolate({
-    inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
-    outputRange: [0.5, 1, 0.5],
+  const animatedStyle = useAnimatedStyle(() => {
+    return {
+      opacity: interpolate(
+        translationY.value,
+        [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
+        [0.5, 1, 0.5],
+      ),
+    }
   })
-  const imagesStyles = getImageStyles(
-    imageDimensions,
-    translateValue,
-    scaleValue,
-  )
-  const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
-
-  const onScrollEndDrag = useCallback(
-    ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-      const velocityY = nativeEvent?.velocity?.y ?? 0
-      const currentScaled = nativeEvent?.zoomScale > 1
-
-      onZoom(currentScaled)
-      setScaled(currentScaled)
-
-      if (
-        !currentScaled &&
-        swipeToCloseEnabled &&
-        Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY
-      ) {
-        onRequestClose()
+
+  const scrollHandler = useAnimatedScrollHandler({
+    onScroll(e) {
+      const nextIsScaled = e.zoomScale > 1
+      translationY.value = nextIsScaled ? 0 : e.contentOffset.y
+      if (scaled !== nextIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
       }
     },
-    [onRequestClose, onZoom, swipeToCloseEnabled],
-  )
+    onEndDrag(e) {
+      const velocityY = e.velocity?.y ?? 0
+      const nextIsScaled = e.zoomScale > 1
+      if (scaled !== nextIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
+      }
+      if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
+        runOnJS(onRequestClose)()
+      }
+    },
+  })
+
+  function handleZoom(nextIsScaled: boolean) {
+    onZoom(nextIsScaled)
+    setScaled(nextIsScaled)
+  }
 
-  const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
+  function handleDoubleTap(absoluteX: number, absoluteY: number) {
+    const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
+    let nextZoomRect = {
+      x: 0,
+      y: 0,
+      width: SCREEN.width,
+      height: SCREEN.height,
+    }
 
-    if (nativeEvent?.zoomScale > 1) {
-      return
+    const willZoom = !scaled
+    if (willZoom) {
+      nextZoomRect = getZoomRectAfterDoubleTap(
+        imageDimensions,
+        absoluteX,
+        absoluteY,
+      )
     }
 
-    scrollValueY.setValue(offsetY)
+    // @ts-ignore
+    scrollResponderRef?.scrollResponderZoomTo({
+      ...nextZoomRect, // This rect is in screen coordinates
+      animated: true,
+    })
   }
 
-  const onLongPressHandler = useCallback(() => {
-    onLongPress(imageSrc)
-  }, [imageSrc, onLongPress])
+  const singleTap = Gesture.Tap().onEnd(() => {
+    runOnJS(onTap)()
+  })
+
+  const doubleTap = Gesture.Tap()
+    .numberOfTaps(2)
+    .onEnd(e => {
+      const {absoluteX, absoluteY} = e
+      runOnJS(handleDoubleTap)(absoluteX, absoluteY)
+    })
+
+  const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
 
   return (
-    <View>
-      <ScrollView
+    <GestureDetector gesture={composedGesture}>
+      <Animated.ScrollView
+        // @ts-ignore Something's up with the types here
         ref={scrollViewRef}
         style={styles.listItem}
         pinchGestureEnabled
         showsHorizontalScrollIndicator={false}
         showsVerticalScrollIndicator={false}
-        maximumZoomScale={maxScrollViewZoom}
+        maximumZoomScale={maxZoomScale}
         contentContainerStyle={styles.imageScrollContainer}
-        scrollEnabled={swipeToCloseEnabled}
-        onScrollEndDrag={onScrollEndDrag}
-        scrollEventThrottle={1}
-        {...(swipeToCloseEnabled && {
-          onScroll,
-        })}>
+        onScroll={scrollHandler}>
         {(!loaded || !imageDimensions) && <ImageLoading />}
-        <TouchableWithoutFeedback
-          onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined}
-          onLongPress={onLongPressHandler}
-          delayLongPress={delayLongPress}
-          accessibilityRole="image"
+        <AnimatedImage
+          contentFit="contain"
+          // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
+          source={{uri: imageSrc.uri}}
+          style={[styles.image, animatedStyle]}
           accessibilityLabel={imageSrc.alt}
-          accessibilityHint="">
-          <AnimatedImage
-            source={imageSrc}
-            style={imageStylesWithOpacity}
-            onLoad={() => setLoaded(true)}
-          />
-        </TouchableWithoutFeedback>
-      </ScrollView>
-    </View>
+          accessibilityHint=""
+          onLoad={() => setLoaded(true)}
+        />
+      </Animated.ScrollView>
+    </GestureDetector>
   )
 }
 
 const styles = StyleSheet.create({
+  imageScrollContainer: {
+    height: SCREEN.height,
+  },
   listItem: {
-    width: SCREEN_WIDTH,
-    height: SCREEN_HEIGHT,
+    width: SCREEN.width,
+    height: SCREEN.height,
   },
-  imageScrollContainer: {
-    height: SCREEN_HEIGHT,
+  image: {
+    width: SCREEN.width,
+    height: SCREEN.height,
   },
 })
 
+const getZoomRectAfterDoubleTap = (
+  imageDimensions: ImageDimensions | null,
+  touchX: number,
+  touchY: number,
+): {
+  x: number
+  y: number
+  width: number
+  height: number
+} => {
+  if (!imageDimensions) {
+    return {
+      x: 0,
+      y: 0,
+      width: SCREEN.width,
+      height: SCREEN.height,
+    }
+  }
+
+  // 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
+  const zoom = Math.max(
+    imageAspect / screenAspect,
+    screenAspect / imageAspect,
+    MIN_DOUBLE_TAP_SCALE,
+  )
+  // Unlike in the Android version, we don't constrain the *max* zoom level here.
+  // Instead, this is done in the ScrollView props so that it constraints pinch too.
+
+  // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
+  // We already know the zoom level, so this gives us the rectangle size.
+  let rectWidth = SCREEN.width / zoom
+  let rectHeight = SCREEN.height / zoom
+
+  // Before we settle on the zoomed rect, figure out the safe area it has to be inside.
+  // We don't want to introduce new black bars or make existing black bars unbalanced.
+  let minX = 0
+  let minY = 0
+  let maxX = SCREEN.width - rectWidth
+  let maxY = SCREEN.height - rectHeight
+  if (imageAspect >= screenAspect) {
+    // The image has horizontal black bars. Exclude them from the safe area.
+    const renderedHeight = SCREEN.width / imageAspect
+    const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2
+    minY += horizontalBarHeight
+    maxY -= horizontalBarHeight
+  } else {
+    // The image has vertical black bars. Exclude them from the safe area.
+    const renderedWidth = SCREEN.height * imageAspect
+    const verticalBarWidth = (SCREEN.width - renderedWidth) / 2
+    minX += verticalBarWidth
+    maxX -= verticalBarWidth
+  }
+
+  // Finally, we can position the rect according to its size and the safe area.
+  let rectX
+  if (maxX >= minX) {
+    // Content fills the screen horizontally so we have horizontal wiggle room.
+    // Try to keep the tapped point under the finger after zoom.
+    rectX = touchX - touchX / zoom
+    rectX = Math.min(rectX, maxX)
+    rectX = Math.max(rectX, minX)
+  } else {
+    // Keep the rect centered on the screen so that black bars are balanced.
+    rectX = SCREEN.width / 2 - rectWidth / 2
+  }
+  let rectY
+  if (maxY >= minY) {
+    // Content fills the screen vertically so we have vertical wiggle room.
+    // Try to keep the tapped point under the finger after zoom.
+    rectY = touchY - touchY / zoom
+    rectY = Math.min(rectY, maxY)
+    rectY = Math.max(rectY, minY)
+  } else {
+    // Keep the rect centered on the screen so that black bars are balanced.
+    rectY = SCREEN.height / 2 - rectHeight / 2
+  }
+
+  return {
+    x: rectX,
+    y: rectY,
+    height: rectHeight,
+    width: rectWidth,
+  }
+}
+
 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 fd377dde2..16688b820 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -7,11 +7,9 @@ import {ImageSource} from '../../@types'
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (scaled: boolean) => void
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
 
 const ImageItem = (_props: Props) => {
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
deleted file mode 100644
index c21cd7f2c..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
+++ /dev/null
@@ -1,47 +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 {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
deleted file mode 100644
index ea81d9f1c..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
+++ /dev/null
@@ -1,150 +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 React, {useCallback} from 'react'
-import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
-
-import {Dimensions} from '../@types'
-
-const DOUBLE_TAP_DELAY = 300
-const MIN_ZOOM = 2
-
-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,
-  imageDimensions: Dimensions | null,
-) {
-  const handleDoubleTap = useCallback(
-    (event: NativeSyntheticEvent<NativeTouchEvent>) => {
-      const nowTS = new Date().getTime()
-      const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
-
-      const getZoomRectAfterDoubleTap = (
-        touchX: number,
-        touchY: number,
-      ): {
-        x: number
-        y: number
-        width: number
-        height: number
-      } => {
-        if (!imageDimensions) {
-          return {
-            x: 0,
-            y: 0,
-            width: screen.width,
-            height: screen.height,
-          }
-        }
-
-        // 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
-        const zoom = Math.max(
-          imageAspect / screenAspect,
-          screenAspect / imageAspect,
-          MIN_ZOOM,
-        )
-        // Unlike in the Android version, we don't constrain the *max* zoom level here.
-        // Instead, this is done in the ScrollView props so that it constraints pinch too.
-
-        // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
-        // We already know the zoom level, so this gives us the rectangle size.
-        let rectWidth = screen.width / zoom
-        let rectHeight = screen.height / zoom
-
-        // Before we settle on the zoomed rect, figure out the safe area it has to be inside.
-        // We don't want to introduce new black bars or make existing black bars unbalanced.
-        let minX = 0
-        let minY = 0
-        let maxX = screen.width - rectWidth
-        let maxY = screen.height - rectHeight
-        if (imageAspect >= screenAspect) {
-          // The image has horizontal black bars. Exclude them from the safe area.
-          const renderedHeight = screen.width / imageAspect
-          const horizontalBarHeight = (screen.height - renderedHeight) / 2
-          minY += horizontalBarHeight
-          maxY -= horizontalBarHeight
-        } else {
-          // The image has vertical black bars. Exclude them from the safe area.
-          const renderedWidth = screen.height * imageAspect
-          const verticalBarWidth = (screen.width - renderedWidth) / 2
-          minX += verticalBarWidth
-          maxX -= verticalBarWidth
-        }
-
-        // Finally, we can position the rect according to its size and the safe area.
-        let rectX
-        if (maxX >= minX) {
-          // Content fills the screen horizontally so we have horizontal wiggle room.
-          // Try to keep the tapped point under the finger after zoom.
-          rectX = touchX - touchX / zoom
-          rectX = Math.min(rectX, maxX)
-          rectX = Math.max(rectX, minX)
-        } else {
-          // Keep the rect centered on the screen so that black bars are balanced.
-          rectX = screen.width / 2 - rectWidth / 2
-        }
-        let rectY
-        if (maxY >= minY) {
-          // Content fills the screen vertically so we have vertical wiggle room.
-          // Try to keep the tapped point under the finger after zoom.
-          rectY = touchY - touchY / zoom
-          rectY = Math.min(rectY, maxY)
-          rectY = Math.max(rectY, minY)
-        } else {
-          // Keep the rect centered on the screen so that black bars are balanced.
-          rectY = screen.height / 2 - rectHeight / 2
-        }
-
-        return {
-          x: rectX,
-          y: rectY,
-          height: rectHeight,
-          width: rectWidth,
-        }
-      }
-
-      if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
-        let nextZoomRect = {
-          x: 0,
-          y: 0,
-          width: screen.width,
-          height: screen.height,
-        }
-
-        const willZoom = !scaled
-        if (willZoom) {
-          const {pageX, pageY} = event.nativeEvent
-          nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY)
-        }
-
-        // @ts-ignore
-        scrollResponderRef?.scrollResponderZoomTo({
-          ...nextZoomRect, // This rect is in screen coordinates
-          animated: true,
-        })
-      } else {
-        lastTapTS = nowTS
-      }
-    },
-    [imageDimensions, 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
index a5b0b6bd4..cb46fd0d9 100644
--- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
+++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
@@ -8,11 +8,29 @@
 
 import {useEffect, useState} from 'react'
 import {Image, ImageURISource} from 'react-native'
-
-import {createCache} from '../utils'
 import {Dimensions, ImageSource} from '../@types'
 
 const CACHE_SIZE = 50
+
+type CacheStorageItem = {key: string; value: any}
+
+const createCache = (cacheSize: number) => ({
+  _storage: [] as CacheStorageItem[],
+  get(key: string): any {
+    const {value} =
+      this._storage.find(({key: storageKey}) => storageKey === key) || {}
+
+    return value
+  },
+  set(key: string, value: any) {
+    if (this._storage.length >= cacheSize) {
+      this._storage.shift()
+    }
+
+    this._storage.push({key, value})
+  },
+})
+
 const imageDimensionsCache = createCache(CACHE_SIZE)
 
 const useImageDimensions = (image: ImageSource): Dimensions | null => {
@@ -21,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | 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 {
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
deleted file mode 100644
index 16430f3aa..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
+++ /dev/null
@@ -1,32 +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 {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
deleted file mode 100644
index 3969945bb..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts
+++ /dev/null
@@ -1,25 +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 {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
deleted file mode 100644
index 7908504ea..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
+++ /dev/null
@@ -1,431 +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 {
-  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 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
-  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: NodeJS.Timeout | 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 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 cancelLongPressHandle = () => {
-    longPressHandlerRef && clearTimeout(longPressHandlerRef)
-  }
-
-  const panResponder = PanResponder.create({
-    onStartShouldSetPanResponder: () => true,
-    onStartShouldSetPanResponderCapture: () => true,
-    onMoveShouldSetPanResponder: () => true,
-    onMoveShouldSetPanResponderCapture: () => true,
-    onPanResponderGrant: (
-      _: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      numberInitialTouches = gestureState.numberActiveTouches
-
-      if (gestureState.numberActiveTouches > 1) {
-        return
-      }
-
-      longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
-    },
-    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 (doubleTapToZoomEnabled && 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,
-    ) => {
-      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}
-      }
-    },
-    onPanResponderRelease: () => {
-      cancelLongPressHandle()
-
-      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]
-}
-
-export default usePanResponder
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
deleted file mode 100644
index 4cd03fe71..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
+++ /dev/null
@@ -1,24 +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 {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
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 1a64fb3af..b6835793d 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -8,91 +8,72 @@
 // Original code copied and simplified from the link below as the codebase is currently not maintained:
 // https://github.com/jobtoday/react-native-image-viewing
 
-import React, {
-  ComponentType,
-  useCallback,
-  useRef,
-  useEffect,
-  useMemo,
-} from 'react'
-import {
-  Animated,
-  Dimensions,
-  StyleSheet,
-  View,
-  VirtualizedList,
-  ModalProps,
-  Platform,
-} from 'react-native'
-import {ModalsContainer} from '../../modals/Modal'
+import React, {ComponentType, useCallback, useMemo, useState} from 'react'
+import {StyleSheet, View, Platform} from 'react-native'
 
 import ImageItem from './components/ImageItem/ImageItem'
 import ImageDefaultHeader from './components/ImageDefaultHeader'
 
-import useAnimatedComponents from './hooks/useAnimatedComponents'
-import useImageIndexChange from './hooks/useImageIndexChange'
-import useRequestClose from './hooks/useRequestClose'
 import {ImageSource} from './@types'
+import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
 import {Edge, SafeAreaView} from 'react-native-safe-area-context'
+import PagerView from 'react-native-pager-view'
 
 type Props = {
   images: ImageSource[]
-  keyExtractor?: (imageSrc: ImageSource, index: number) => string
-  imageIndex: number
+  initialImageIndex: number
   visible: boolean
   onRequestClose: () => void
-  onLongPress?: (image: ImageSource) => void
-  onImageIndexChange?: (imageIndex: number) => void
-  presentationStyle?: ModalProps['presentationStyle']
-  animationType?: ModalProps['animationType']
   backgroundColor?: string
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
-  delayLongPress?: number
   HeaderComponent?: ComponentType<{imageIndex: number}>
   FooterComponent?: ComponentType<{imageIndex: number}>
 }
 
 const DEFAULT_BG_COLOR = '#000'
-const DEFAULT_DELAY_LONG_PRESS = 800
-const SCREEN = Dimensions.get('screen')
-const SCREEN_WIDTH = SCREEN.width
 
 function ImageViewing({
   images,
-  keyExtractor,
-  imageIndex,
+  initialImageIndex,
   visible,
   onRequestClose,
-  onLongPress = () => {},
-  onImageIndexChange,
   backgroundColor = DEFAULT_BG_COLOR,
-  swipeToCloseEnabled,
-  doubleTapToZoomEnabled,
-  delayLongPress = DEFAULT_DELAY_LONG_PRESS,
   HeaderComponent,
   FooterComponent,
 }: Props) {
-  const imageList = useRef<VirtualizedList<ImageSource>>(null)
-  const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose)
-  const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN)
-  const [headerTransform, footerTransform, toggleBarsVisible] =
-    useAnimatedComponents()
-
-  useEffect(() => {
-    if (onImageIndexChange) {
-      onImageIndexChange(currentImageIndex)
+  const [isScaled, setIsScaled] = useState(false)
+  const [isDragging, setIsDragging] = useState(false)
+  const [imageIndex, setImageIndex] = useState(initialImageIndex)
+  const [showControls, setShowControls] = useState(true)
+
+  const animatedHeaderStyle = useAnimatedStyle(() => ({
+    pointerEvents: showControls ? 'auto' : 'none',
+    opacity: withClampedSpring(showControls ? 1 : 0),
+    transform: [
+      {
+        translateY: withClampedSpring(showControls ? 0 : -30),
+      },
+    ],
+  }))
+  const animatedFooterStyle = useAnimatedStyle(() => ({
+    pointerEvents: showControls ? 'auto' : 'none',
+    opacity: withClampedSpring(showControls ? 1 : 0),
+    transform: [
+      {
+        translateY: withClampedSpring(showControls ? 0 : 30),
+      },
+    ],
+  }))
+
+  const onTap = useCallback(() => {
+    setShowControls(show => !show)
+  }, [])
+
+  const onZoom = useCallback((nextIsScaled: boolean) => {
+    setIsScaled(nextIsScaled)
+    if (nextIsScaled) {
+      setShowControls(false)
     }
-  }, [currentImageIndex, onImageIndexChange])
-
-  const onZoom = useCallback(
-    (isScaled: boolean) => {
-      // @ts-ignore
-      imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
-      toggleBarsVisible(!isScaled)
-    },
-    [toggleBarsVisible],
-  )
+  }, [])
 
   const edges = useMemo(() => {
     if (Platform.OS === 'android') {
@@ -101,12 +82,6 @@ function ImageViewing({
     return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
   }, [])
 
-  const onLayout = useCallback(() => {
-    if (imageIndex) {
-      imageList.current?.scrollToIndex({index: imageIndex, animated: false})
-    }
-  }, [imageList, imageIndex])
-
   if (!visible) {
     return null
   }
@@ -114,60 +89,47 @@ function ImageViewing({
   return (
     <SafeAreaView
       style={styles.screen}
-      onLayout={onLayout}
       edges={edges}
       aria-modal
       accessibilityViewIsModal>
-      <ModalsContainer />
-      <View style={[styles.container, {opacity, backgroundColor}]}>
-        <Animated.View style={[styles.header, {transform: headerTransform}]}>
+      <View style={[styles.container, {backgroundColor}]}>
+        <Animated.View style={[styles.header, animatedHeaderStyle]}>
           {typeof HeaderComponent !== 'undefined' ? (
             React.createElement(HeaderComponent, {
-              imageIndex: currentImageIndex,
+              imageIndex,
             })
           ) : (
-            <ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
+            <ImageDefaultHeader onRequestClose={onRequestClose} />
           )}
         </Animated.View>
-        <VirtualizedList
-          ref={imageList}
-          data={images}
-          horizontal
-          pagingEnabled
-          showsHorizontalScrollIndicator={false}
-          showsVerticalScrollIndicator={false}
-          getItem={(_, index) => images[index]}
-          getItemCount={() => images.length}
-          getItemLayout={(_, index) => ({
-            length: SCREEN_WIDTH,
-            offset: SCREEN_WIDTH * index,
-            index,
-          })}
-          renderItem={({item: imageSrc}) => (
-            <ImageItem
-              onZoom={onZoom}
-              imageSrc={imageSrc}
-              onRequestClose={onRequestCloseEnhanced}
-              onLongPress={onLongPress}
-              delayLongPress={delayLongPress}
-              swipeToCloseEnabled={swipeToCloseEnabled}
-              doubleTapToZoomEnabled={doubleTapToZoomEnabled}
-            />
-          )}
-          onMomentumScrollEnd={onScroll}
-          //@ts-ignore
-          keyExtractor={(imageSrc, index) =>
-            keyExtractor
-              ? keyExtractor(imageSrc, index)
-              : typeof imageSrc === 'number'
-              ? `${imageSrc}`
-              : imageSrc.uri
-          }
-        />
+        <PagerView
+          scrollEnabled={!isScaled}
+          initialPage={initialImageIndex}
+          onPageSelected={e => {
+            setImageIndex(e.nativeEvent.position)
+            setIsScaled(false)
+          }}
+          onPageScrollStateChanged={e => {
+            setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
+          }}
+          overdrag={true}
+          style={styles.pager}>
+          {images.map(imageSrc => (
+            <View key={imageSrc.uri}>
+              <ImageItem
+                onTap={onTap}
+                onZoom={onZoom}
+                imageSrc={imageSrc}
+                onRequestClose={onRequestClose}
+                isScrollViewBeingDragged={isDragging}
+              />
+            </View>
+          ))}
+        </PagerView>
         {typeof FooterComponent !== 'undefined' && (
-          <Animated.View style={[styles.footer, {transform: footerTransform}]}>
+          <Animated.View style={[styles.footer, animatedFooterStyle]}>
             {React.createElement(FooterComponent, {
-              imageIndex: currentImageIndex,
+              imageIndex,
             })}
           </Animated.View>
         )}
@@ -179,11 +141,18 @@ function ImageViewing({
 const styles = StyleSheet.create({
   screen: {
     position: 'absolute',
+    top: 0,
+    left: 0,
+    bottom: 0,
+    right: 0,
   },
   container: {
     flex: 1,
     backgroundColor: '#000',
   },
+  pager: {
+    flex: 1,
+  },
   header: {
     position: 'absolute',
     width: '100%',
@@ -200,7 +169,12 @@ const styles = StyleSheet.create({
 })
 
 const EnhancedImageViewing = (props: Props) => (
-  <ImageViewing key={props.imageIndex} {...props} />
+  <ImageViewing key={props.initialImageIndex} {...props} />
 )
 
+function withClampedSpring(value: any) {
+  'worklet'
+  return withSpring(value, {overshootClamping: true, stiffness: 300})
+}
+
 export default EnhancedImageViewing
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 d56eea4f4..000000000
--- a/src/view/com/lightbox/ImageViewing/utils.ts
+++ /dev/null
@@ -1,139 +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 {Animated, NativeTouchEvent} from 'react-native'
-import {Dimensions, Position} from './@types'
-
-type CacheStorageItem = {key: string; value: any}
-
-export const createCache = (cacheSize: number) => ({
-  _storage: [] as CacheStorageItem[],
-  get(key: string): any {
-    const {value} =
-      this._storage.find(({key: storageKey}) => storageKey === key) || {}
-
-    return value
-  },
-  set(key: string, value: any) {
-    if (this._storage.length >= cacheSize) {
-      this._storage.shift()
-    }
-
-    this._storage.push({key, value})
-  },
-})
-
-export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] =>
-  arr.reduce((result, item) => {
-    const batch = result.pop() || []
-
-    if (batch.length < batchSize) {
-      batch.push(item)
-      result.push(batch)
-    } else {
-      result.push(batch, [item])
-    }
-
-    return result
-  }, [])
-
-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 getImageStyles = (
-  image: Dimensions | null,
-  translate: Animated.ValueXY,
-  scale?: Animated.Value,
-) => {
-  if (!image?.width || !image?.height) {
-    return {width: 0, height: 0}
-  }
-
-  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)})
-  }
-
-  return {
-    width: image.width,
-    height: image.height,
-    transform,
-  }
-}
-
-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'),
-  }
-}
-
-export const getImageDimensionsByTranslate = (
-  translate: Position,
-  screen: Dimensions,
-): Dimensions => ({
-  width: screen.width - translate.x * 2,
-  height: screen.height - translate.y * 2,
-})
-
-export const getImageTranslateForScale = (
-  currentTranslate: Position,
-  targetScale: number,
-  screen: Dimensions,
-): Position => {
-  const {width, height} = getImageDimensionsByTranslate(
-    currentTranslate,
-    screen,
-  )
-
-  const targetImageDimensions = {
-    width: width * targetScale,
-    height: height * targetScale,
-  }
-
-  return getImageTranslate(targetImageDimensions, screen)
-}
-
-export 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),
-  )
-}