about summary refs log tree commit diff
path: root/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx')
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx302
1 files changed, 197 insertions, 105 deletions
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)