about summary refs log tree commit diff
path: root/src/view/com/lightbox/ImageViewing/components/ImageItem
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/components/ImageItem')
-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
3 files changed, 547 insertions, 228 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) => {