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.tsx176
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx132
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx27
3 files changed, 235 insertions, 100 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 f882dcf9e..069f9eb40 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -1,23 +1,26 @@
 import React, {useState} from 'react'
-import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet} from 'react-native'
 import {
   Gesture,
   GestureDetector,
   PanGesture,
 } from 'react-native-gesture-handler'
 import Animated, {
-  AnimatedRef,
-  measure,
   runOnJS,
+  SharedValue,
   useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
   useSharedValue,
   withSpring,
 } from 'react-native-reanimated'
-import {Image, ImageStyle} from 'expo-image'
+import {Image} from 'expo-image'
 
-import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
+import type {
+  Dimensions as ImageDimensions,
+  ImageSource,
+  Transform,
+} from '../../@types'
 import {
   applyRounding,
   createTransform,
@@ -28,8 +31,6 @@ import {
   TransformMatrix,
 } from '../../transforms'
 
-const AnimatedImage = Animated.createAnimatedComponent(Image)
-
 const MIN_SCREEN_ZOOM = 2
 const MAX_ORIGINAL_IMAGE_ZOOM = 2
 
@@ -42,22 +43,35 @@ type Props = {
   onZoom: (isZoomed: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
-  safeAreaRef: AnimatedRef<View>
+  measureSafeArea: () => {
+    x: number
+    y: number
+    width: number
+    height: number
+  }
   imageAspect: number | undefined
   imageDimensions: ImageDimensions | undefined
-  imageStyle: StyleProp<ImageStyle>
   dismissSwipePan: PanGesture
+  transforms: Readonly<
+    SharedValue<{
+      scaleAndMoveTransform: Transform
+      cropFrameTransform: Transform
+      cropContentTransform: Transform
+      isResting: boolean
+      isHidden: boolean
+    }>
+  >
 }
 const ImageItem = ({
   imageSrc,
   onTap,
   onZoom,
   isScrollViewBeingDragged,
-  safeAreaRef,
+  measureSafeArea,
   imageAspect,
   imageDimensions,
-  imageStyle,
   dismissSwipePan,
+  transforms,
 }: Props) => {
   const [isScaled, setIsScaled] = useState(false)
   const committedTransform = useSharedValue(initialTransform)
@@ -95,19 +109,6 @@ const ImageItem = ({
     onZoom(nextIsScaled)
   }
 
-  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)
-    return {
-      transform: [{translateX}, {translateY: translateY}, {scale}],
-    }
-  })
-
   // 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(
@@ -143,10 +144,7 @@ const ImageItem = ({
   const pinch = Gesture.Pinch()
     .onStart(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!screenSize) {
-        return
-      }
+      const screenSize = measureSafeArea()
       pinchOrigin.value = {
         x: e.focalX - screenSize.width / 2,
         y: e.focalY - screenSize.height / 2,
@@ -154,8 +152,8 @@ const ImageItem = ({
     })
     .onChange(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!imageDimensions || !screenSize) {
+      const screenSize = measureSafeArea()
+      if (!imageDimensions) {
         return
       }
       // Don't let the picture zoom in so close that it gets blurry.
@@ -213,8 +211,8 @@ const ImageItem = ({
     .minPointers(isScaled ? 1 : 2)
     .onChange(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!imageDimensions || !screenSize) {
+      const screenSize = measureSafeArea()
+      if (!imageDimensions) {
         return
       }
 
@@ -257,8 +255,8 @@ const ImageItem = ({
     .numberOfTaps(2)
     .onEnd(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!imageDimensions || !imageAspect || !screenSize) {
+      const screenSize = measureSafeArea()
+      if (!imageDimensions || !imageAspect) {
         return
       }
       const [, , committedScale] = readTransform(committedTransform.value)
@@ -302,11 +300,6 @@ const ImageItem = ({
       committedTransform.value = withClampedSpring(finalTransform)
     })
 
-  const innerStyle = useAnimatedStyle(() => ({
-    width: '100%',
-    aspectRatio: imageAspect,
-  }))
-
   const composedGesture = isScrollViewBeingDragged
     ? // If the parent is not at rest, provide a no-op gesture.
       Gesture.Manual()
@@ -317,29 +310,97 @@ const ImageItem = ({
         singleTap,
       )
 
+  const containerStyle = useAnimatedStyle(() => {
+    const {scaleAndMoveTransform, isHidden} = transforms.value
+    // 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 manipulationTransform = [
+      {translateX},
+      {translateY: translateY},
+      {scale},
+    ]
+    const screenSize = measureSafeArea()
+    return {
+      opacity: isHidden ? 0 : 1,
+      transform: scaleAndMoveTransform.concat(manipulationTransform),
+      width: screenSize.width,
+      maxHeight: screenSize.height,
+      aspectRatio: imageAspect,
+      alignSelf: 'center',
+    }
+  })
+
+  const imageCropStyle = useAnimatedStyle(() => {
+    const {cropFrameTransform} = transforms.value
+    return {
+      flex: 1,
+      overflow: 'hidden',
+      transform: cropFrameTransform,
+    }
+  })
+
+  const imageStyle = useAnimatedStyle(() => {
+    const {cropContentTransform} = transforms.value
+    return {
+      flex: 1,
+      transform: cropContentTransform,
+    }
+  })
+
+  const [showLoader, setShowLoader] = useState(false)
+  const [hasLoaded, setHasLoaded] = useState(false)
+  useAnimatedReaction(
+    () => {
+      return transforms.value.isResting && !hasLoaded
+    },
+    (show, prevShow) => {
+      if (show && !prevShow) {
+        runOnJS(setShowLoader)(false)
+      } else if (!prevShow && show) {
+        runOnJS(setShowLoader)(true)
+      }
+    },
+  )
+
   const type = imageSrc.type
   const borderRadius =
     type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
+
   return (
     <GestureDetector gesture={composedGesture}>
-      <Animated.View style={imageStyle} renderToHardwareTextureAndroid>
-        <Animated.View
-          ref={containerRef}
-          // Necessary to make opacity work for both children together.
-          renderToHardwareTextureAndroid
-          style={[styles.container, animatedStyle]}>
-          <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
-          <AnimatedImage
-            contentFit="contain"
-            source={{uri: imageSrc.uri}}
-            placeholderContentFit="contain"
-            placeholder={{uri: imageSrc.thumbUri}}
-            style={[innerStyle, {borderRadius}]}
-            accessibilityLabel={imageSrc.alt}
-            accessibilityHint=""
-            accessibilityIgnoresInvertColors
-            cachePolicy="memory"
-          />
+      <Animated.View
+        ref={containerRef}
+        style={[styles.container]}
+        renderToHardwareTextureAndroid>
+        <Animated.View style={containerStyle}>
+          {showLoader && (
+            <ActivityIndicator
+              size="small"
+              color="#FFF"
+              style={styles.loading}
+            />
+          )}
+          <Animated.View style={imageCropStyle}>
+            <Animated.View style={imageStyle}>
+              <Image
+                contentFit="cover"
+                source={{uri: imageSrc.uri}}
+                placeholderContentFit="cover"
+                placeholder={{uri: imageSrc.thumbUri}}
+                accessibilityLabel={imageSrc.alt}
+                onLoad={() => setHasLoaded(false)}
+                style={{flex: 1, borderRadius}}
+                accessibilityHint=""
+                accessibilityIgnoresInvertColors
+                cachePolicy="memory"
+              />
+            </Animated.View>
+          </Animated.View>
         </Animated.View>
       </Animated.View>
     </GestureDetector>
@@ -358,6 +419,7 @@ const styles = StyleSheet.create({
     right: 0,
     top: 0,
     bottom: 0,
+    justifyContent: 'center',
   },
 })
 
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 e876479a3..7a9a18b91 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -7,26 +7,28 @@
  */
 
 import React, {useState} from 'react'
-import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet} from 'react-native'
 import {
   Gesture,
   GestureDetector,
   PanGesture,
 } from 'react-native-gesture-handler'
 import Animated, {
-  AnimatedRef,
-  measure,
   runOnJS,
+  SharedValue,
+  useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
 } from 'react-native-reanimated'
 import {useSafeAreaFrame} from 'react-native-safe-area-context'
-import {Image, ImageStyle} from 'expo-image'
+import {Image} from 'expo-image'
 
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
-import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
-
-const AnimatedImage = Animated.createAnimatedComponent(Image)
+import {
+  Dimensions as ImageDimensions,
+  ImageSource,
+  Transform,
+} from '../../@types'
 
 const MAX_ORIGINAL_IMAGE_ZOOM = 2
 const MIN_SCREEN_ZOOM = 2
@@ -38,11 +40,24 @@ type Props = {
   onZoom: (scaled: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
-  safeAreaRef: AnimatedRef<View>
+  measureSafeArea: () => {
+    x: number
+    y: number
+    width: number
+    height: number
+  }
   imageAspect: number | undefined
   imageDimensions: ImageDimensions | undefined
-  imageStyle: StyleProp<ImageStyle>
   dismissSwipePan: PanGesture
+  transforms: Readonly<
+    SharedValue<{
+      scaleAndMoveTransform: Transform
+      cropFrameTransform: Transform
+      cropContentTransform: Transform
+      isResting: boolean
+      isHidden: boolean
+    }>
+  >
 }
 
 const ImageItem = ({
@@ -50,11 +65,11 @@ const ImageItem = ({
   onTap,
   onZoom,
   showControls,
-  safeAreaRef,
+  measureSafeArea,
   imageAspect,
   imageDimensions,
-  imageStyle,
   dismissSwipePan,
+  transforms,
 }: Props) => {
   const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
   const [scaled, setScaled] = useState(false)
@@ -67,16 +82,6 @@ const ImageItem = ({
       : 1,
   )
 
-  const animatedStyle = useAnimatedStyle(() => {
-    const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly
-    return {
-      width: screenSize.width,
-      maxHeight: screenSize.height,
-      alignSelf: 'center',
-      aspectRatio: imageAspect,
-    }
-  })
-
   const scrollHandler = useAnimatedScrollHandler({
     onScroll(e) {
       const nextIsScaled = e.zoomScale > 1
@@ -114,10 +119,7 @@ const ImageItem = ({
     .numberOfTaps(2)
     .onEnd(e => {
       'worklet'
-      const screenSize = measure(safeAreaRef)
-      if (!screenSize) {
-        return
-      }
+      const screenSize = measureSafeArea()
       const {absoluteX, absoluteY} = e
       let nextZoomRect = {
         x: 0,
@@ -143,9 +145,56 @@ const ImageItem = ({
     singleTap,
   )
 
+  const containerStyle = useAnimatedStyle(() => {
+    const {scaleAndMoveTransform, isHidden} = transforms.value
+    return {
+      flex: 1,
+      transform: scaleAndMoveTransform,
+      opacity: isHidden ? 0 : 1,
+    }
+  })
+
+  const imageCropStyle = useAnimatedStyle(() => {
+    const screenSize = measureSafeArea()
+    const {cropFrameTransform} = transforms.value
+    return {
+      overflow: 'hidden',
+      transform: cropFrameTransform,
+      width: screenSize.width,
+      maxHeight: screenSize.height,
+      aspectRatio: imageAspect,
+      alignSelf: 'center',
+    }
+  })
+
+  const imageStyle = useAnimatedStyle(() => {
+    const {cropContentTransform} = transforms.value
+    return {
+      transform: cropContentTransform,
+      width: '100%',
+      aspectRatio: imageAspect,
+    }
+  })
+
+  const [showLoader, setShowLoader] = useState(false)
+  const [hasLoaded, setHasLoaded] = useState(false)
+  useAnimatedReaction(
+    () => {
+      return transforms.value.isResting && !hasLoaded
+    },
+    (show, prevShow) => {
+      if (show && !prevShow) {
+        runOnJS(setShowLoader)(false)
+      } else if (!prevShow && show) {
+        runOnJS(setShowLoader)(true)
+      }
+    },
+  )
+
   const type = imageSrc.type
   const borderRadius =
     type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0
+
   return (
     <GestureDetector gesture={composedGesture}>
       <Animated.ScrollView
@@ -156,22 +205,29 @@ const ImageItem = ({
         showsVerticalScrollIndicator={false}
         maximumZoomScale={maxZoomScale}
         onScroll={scrollHandler}
+        style={containerStyle}
         bounces={scaled}
         bouncesZoom={true}
-        style={imageStyle}
         centerContent>
-        <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
-        <AnimatedImage
-          contentFit="contain"
-          source={{uri: imageSrc.uri}}
-          placeholderContentFit="contain"
-          placeholder={{uri: imageSrc.thumbUri}}
-          style={[animatedStyle, {borderRadius}]}
-          accessibilityLabel={imageSrc.alt}
-          accessibilityHint=""
-          enableLiveTextInteraction={showControls && !scaled}
-          accessibilityIgnoresInvertColors
-        />
+        {showLoader && (
+          <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
+        )}
+        <Animated.View style={imageCropStyle}>
+          <Animated.View style={imageStyle}>
+            <Image
+              contentFit="contain"
+              source={{uri: imageSrc.uri}}
+              placeholderContentFit="contain"
+              placeholder={{uri: imageSrc.thumbUri}}
+              style={{flex: 1, borderRadius}}
+              accessibilityLabel={imageSrc.alt}
+              accessibilityHint=""
+              enableLiveTextInteraction={showControls && !scaled}
+              accessibilityIgnoresInvertColors
+              onLoad={() => setHasLoaded(true)}
+            />
+          </Animated.View>
+        </Animated.View>
       </Animated.ScrollView>
     </GestureDetector>
   )
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
index 1cd6b0020..543fad772 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -1,11 +1,15 @@
 // default implementation fallback for web
 
 import React from 'react'
-import {ImageStyle, StyleProp, View} from 'react-native'
+import {View} from 'react-native'
 import {PanGesture} from 'react-native-gesture-handler'
-import {AnimatedRef} from 'react-native-reanimated'
+import {SharedValue} from 'react-native-reanimated'
 
-import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
+import {
+  Dimensions as ImageDimensions,
+  ImageSource,
+  Transform,
+} from '../../@types'
 
 type Props = {
   imageSrc: ImageSource
@@ -14,11 +18,24 @@ type Props = {
   onZoom: (scaled: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
-  safeAreaRef: AnimatedRef<View>
+  measureSafeArea: () => {
+    x: number
+    y: number
+    width: number
+    height: number
+  }
   imageAspect: number | undefined
   imageDimensions: ImageDimensions | undefined
-  imageStyle: StyleProp<ImageStyle>
   dismissSwipePan: PanGesture
+  transforms: Readonly<
+    SharedValue<{
+      scaleAndMoveTransform: Transform
+      cropFrameTransform: Transform
+      cropContentTransform: Transform
+      isResting: boolean
+      isHidden: boolean
+    }>
+  >
 }
 
 const ImageItem = (_props: Props) => {