about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx105
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx95
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx9
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx210
4 files changed, 274 insertions, 145 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 ed6020000..17c386771 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -1,6 +1,10 @@
 import React, {useState} from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {Gesture, GestureDetector} from 'react-native-gesture-handler'
+import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
+import {
+  Gesture,
+  GestureDetector,
+  PanGesture,
+} from 'react-native-gesture-handler'
 import Animated, {
   AnimatedRef,
   measure,
@@ -9,12 +13,10 @@ import Animated, {
   useAnimatedRef,
   useAnimatedStyle,
   useSharedValue,
-  withDecay,
   withSpring,
 } from 'react-native-reanimated'
-import {Image} from 'expo-image'
+import {Image, ImageStyle} from 'expo-image'
 
-import {useImageDimensions} from '#/lib/media/image-sizes'
 import type {Dimensions as ImageDimensions, ImageSource} from '../../@types'
 import {
   applyRounding,
@@ -26,6 +28,8 @@ import {
   TransformMatrix,
 } from '../../transforms'
 
+const AnimatedImage = Animated.createAnimatedComponent(Image)
+
 const MIN_SCREEN_ZOOM = 2
 const MAX_ORIGINAL_IMAGE_ZOOM = 2
 
@@ -39,26 +43,28 @@ type Props = {
   isScrollViewBeingDragged: boolean
   showControls: boolean
   safeAreaRef: AnimatedRef<View>
+  imageAspect: number | undefined
+  imageDimensions: ImageDimensions | undefined
+  imageStyle: StyleProp<ImageStyle>
+  dismissSwipePan: PanGesture
 }
 const ImageItem = ({
   imageSrc,
   onTap,
   onZoom,
-  onRequestClose,
   isScrollViewBeingDragged,
   safeAreaRef,
+  imageAspect,
+  imageDimensions,
+  imageStyle,
+  dismissSwipePan,
 }: Props) => {
   const [isScaled, setIsScaled] = useState(false)
-  const [imageAspect, imageDimensions] = useImageDimensions({
-    src: imageSrc.uri,
-    knownDimensions: imageSrc.dimensions,
-  })
   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.
@@ -97,19 +103,8 @@ const ImageItem = ({
     prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
     prependTransform(t, committedTransform.value)
     const [translateX, translateY, scale] = readTransform(t)
-
-    const dismissDistance = dismissSwipeTranslateY.value
-    const screenSize = measure(safeAreaRef)
-    const dismissProgress = screenSize
-      ? Math.min(Math.abs(dismissDistance) / (screenSize.height / 2), 1)
-      : 0
     return {
-      opacity: 1 - dismissProgress,
-      transform: [
-        {translateX},
-        {translateY: translateY + dismissDistance},
-        {scale},
-      ],
+      transform: [{translateX}, {translateY: translateY}, {scale}],
     }
   })
 
@@ -307,28 +302,6 @@ const ImageItem = ({
       committedTransform.value = withClampedSpring(finalTransform)
     })
 
-  const dismissSwipePan = Gesture.Pan()
-    .enabled(!isScaled)
-    .activeOffsetY([-10, 10])
-    .failOffsetX([-10, 10])
-    .maxPointers(1)
-    .onUpdate(e => {
-      'worklet'
-      dismissSwipeTranslateY.value = e.translationY
-    })
-    .onEnd(e => {
-      'worklet'
-      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()
@@ -340,26 +313,28 @@ const ImageItem = ({
       )
 
   return (
-    <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} />
-      <GestureDetector gesture={composedGesture}>
-        <Image
-          contentFit="contain"
-          source={{uri: imageSrc.uri}}
-          placeholderContentFit="contain"
-          placeholder={{uri: imageSrc.thumbUri}}
-          style={styles.image}
-          accessibilityLabel={imageSrc.alt}
-          accessibilityHint=""
-          accessibilityIgnoresInvertColors
-          cachePolicy="memory"
-        />
-      </GestureDetector>
-    </Animated.View>
+    <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={[styles.image]}
+            accessibilityLabel={imageSrc.alt}
+            accessibilityHint=""
+            accessibilityIgnoresInvertColors
+            cachePolicy="memory"
+          />
+        </Animated.View>
+      </Animated.View>
+    </GestureDetector>
   )
 }
 
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 a17d4fe66..b4bbfb4d5 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,27 @@
  */
 
 import React, {useState} from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {Gesture, GestureDetector} from 'react-native-gesture-handler'
+import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native'
+import {
+  Gesture,
+  GestureDetector,
+  PanGesture,
+} from 'react-native-gesture-handler'
 import Animated, {
   AnimatedRef,
-  interpolate,
   measure,
   runOnJS,
   useAnimatedRef,
   useAnimatedStyle,
-  useSharedValue,
 } from 'react-native-reanimated'
 import {useSafeAreaFrame} from 'react-native-safe-area-context'
-import {Image} from 'expo-image'
+import {Image, ImageStyle} from 'expo-image'
 
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
-import {useImageDimensions} from '#/lib/media/image-sizes'
-import {ImageSource} from '../../@types'
+import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
+
+const AnimatedImage = Animated.createAnimatedComponent(Image)
 
-const SWIPE_CLOSE_OFFSET = 75
-const SWIPE_CLOSE_VELOCITY = 1
 const MAX_ORIGINAL_IMAGE_ZOOM = 2
 const MIN_SCREEN_ZOOM = 2
 
@@ -38,24 +39,26 @@ type Props = {
   isScrollViewBeingDragged: boolean
   showControls: boolean
   safeAreaRef: AnimatedRef<View>
+  imageAspect: number | undefined
+  imageDimensions: ImageDimensions | undefined
+  imageStyle: StyleProp<ImageStyle>
+  dismissSwipePan: PanGesture
 }
 
 const ImageItem = ({
   imageSrc,
   onTap,
   onZoom,
-  onRequestClose,
   showControls,
   safeAreaRef,
+  imageAspect,
+  imageDimensions,
+  imageStyle,
+  dismissSwipePan,
 }: Props) => {
   const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
-  const translationY = useSharedValue(0)
   const [scaled, setScaled] = useState(false)
   const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame()
-  const [imageAspect, imageDimensions] = useImageDimensions({
-    src: imageSrc.uri,
-    knownDimensions: imageSrc.dimensions,
-  })
   const maxZoomScale = Math.max(
     MIN_SCREEN_ZOOM,
     imageDimensions
@@ -65,33 +68,21 @@ const ImageItem = ({
   )
 
   const animatedStyle = useAnimatedStyle(() => {
+    const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly
     return {
-      flex: 1,
-      opacity: interpolate(
-        translationY.value,
-        [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
-        [0.5, 1, 0.5],
-      ),
+      width: screenSize.width,
+      maxHeight: screenSize.height,
+      alignSelf: 'center',
+      aspectRatio: imageAspect,
     }
   })
 
   const scrollHandler = useAnimatedScrollHandler({
     onScroll(e) {
       const nextIsScaled = e.zoomScale > 1
-      translationY.value = nextIsScaled ? 0 : e.contentOffset.y
-      if (scaled !== nextIsScaled) {
-        runOnJS(handleZoom)(nextIsScaled)
-      }
-    },
-    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)()
-      }
     },
   })
 
@@ -146,7 +137,11 @@ const ImageItem = ({
       runOnJS(zoomTo)(nextZoomRect)
     })
 
-  const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
+  const composedGesture = Gesture.Exclusive(
+    dismissSwipePan,
+    doubleTap,
+    singleTap,
+  )
 
   return (
     <GestureDetector gesture={composedGesture}>
@@ -158,21 +153,22 @@ const ImageItem = ({
         showsVerticalScrollIndicator={false}
         maximumZoomScale={maxZoomScale}
         onScroll={scrollHandler}
-        contentContainerStyle={styles.scrollContainer}>
-        <Animated.View style={animatedStyle}>
-          <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
-          <Image
-            contentFit="contain"
-            source={{uri: imageSrc.uri}}
-            placeholderContentFit="contain"
-            placeholder={{uri: imageSrc.thumbUri}}
-            style={styles.image}
-            accessibilityLabel={imageSrc.alt}
-            accessibilityHint=""
-            enableLiveTextInteraction={showControls && !scaled}
-            accessibilityIgnoresInvertColors
-          />
-        </Animated.View>
+        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}
+          accessibilityLabel={imageSrc.alt}
+          accessibilityHint=""
+          enableLiveTextInteraction={showControls && !scaled}
+          accessibilityIgnoresInvertColors
+        />
       </Animated.ScrollView>
     </GestureDetector>
   )
@@ -186,9 +182,6 @@ const styles = StyleSheet.create({
     right: 0,
     bottom: 0,
   },
-  scrollContainer: {
-    flex: 1,
-  },
   image: {
     flex: 1,
   },
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
index 383bec995..1cd6b0020 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -1,10 +1,11 @@
 // default implementation fallback for web
 
 import React from 'react'
-import {View} from 'react-native'
+import {ImageStyle, StyleProp, View} from 'react-native'
+import {PanGesture} from 'react-native-gesture-handler'
 import {AnimatedRef} from 'react-native-reanimated'
 
-import {ImageSource} from '../../@types'
+import {Dimensions as ImageDimensions, ImageSource} from '../../@types'
 
 type Props = {
   imageSrc: ImageSource
@@ -14,6 +15,10 @@ type Props = {
   isScrollViewBeingDragged: boolean
   showControls: boolean
   safeAreaRef: AnimatedRef<View>
+  imageAspect: number | undefined
+  imageDimensions: ImageDimensions | undefined
+  imageStyle: StyleProp<ImageStyle>
+  dismissSwipePan: PanGesture
 }
 
 const ImageItem = (_props: Props) => {
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 791701bca..7a3a50691 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -10,17 +10,26 @@
 
 import React, {useCallback, useState} from 'react'
 import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native'
+import {Gesture} from 'react-native-gesture-handler'
 import PagerView from 'react-native-pager-view'
 import Animated, {
   AnimatedRef,
+  cancelAnimation,
+  measure,
+  runOnJS,
+  SharedValue,
+  useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
+  useSharedValue,
+  withDecay,
   withSpring,
 } from 'react-native-reanimated'
 import {Edge, SafeAreaView} from 'react-native-safe-area-context'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Trans} from '@lingui/macro'
 
+import {useImageDimensions} from '#/lib/media/image-sizes'
 import {colors, s} from '#/lib/styles'
 import {isIOS} from '#/platform/detection'
 import {Lightbox} from '#/state/lightbox'
@@ -90,26 +99,55 @@ function ImageView({
   const [isDragging, setIsDragging] = useState(false)
   const [imageIndex, setImageIndex] = useState(initialImageIndex)
   const [showControls, setShowControls] = useState(true)
+  const [isAltExpanded, setAltExpanded] = React.useState(false)
+  const dismissSwipeTranslateY = useSharedValue(0)
+  const isFlyingAway = useSharedValue(false)
 
-  const animatedHeaderStyle = useAnimatedStyle(() => ({
-    pointerEvents: showControls ? 'box-none' : 'none',
-    opacity: withClampedSpring(showControls ? 1 : 0),
-    transform: [
-      {
-        translateY: withClampedSpring(showControls ? 0 : -30),
-      },
-    ],
-  }))
-  const animatedFooterStyle = useAnimatedStyle(() => ({
-    flexGrow: 1,
-    pointerEvents: showControls ? 'box-none' : 'none',
-    opacity: withClampedSpring(showControls ? 1 : 0),
-    transform: [
-      {
-        translateY: withClampedSpring(showControls ? 0 : 30),
-      },
-    ],
-  }))
+  const containerStyle = useAnimatedStyle(() => {
+    if (isFlyingAway.value) {
+      return {pointerEvents: 'none'}
+    }
+    return {pointerEvents: 'auto'}
+  })
+  const backdropStyle = useAnimatedStyle(() => {
+    const screenSize = measure(safeAreaRef)
+    let opacity = 1
+    if (screenSize) {
+      const dragProgress = Math.min(
+        Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2),
+        1,
+      )
+      opacity -= dragProgress
+    }
+    return {
+      opacity,
+    }
+  })
+  const animatedHeaderStyle = useAnimatedStyle(() => {
+    const show = showControls && dismissSwipeTranslateY.value === 0
+    return {
+      pointerEvents: show ? 'box-none' : 'none',
+      opacity: withClampedSpring(show ? 1 : 0),
+      transform: [
+        {
+          translateY: withClampedSpring(show ? 0 : -30),
+        },
+      ],
+    }
+  })
+  const animatedFooterStyle = useAnimatedStyle(() => {
+    const show = showControls && dismissSwipeTranslateY.value === 0
+    return {
+      flexGrow: 1,
+      pointerEvents: show ? 'box-none' : 'none',
+      opacity: withClampedSpring(show ? 1 : 0),
+      transform: [
+        {
+          translateY: withClampedSpring(show ? 0 : 30),
+        },
+      ],
+    }
+  })
 
   const onTap = useCallback(() => {
     setShowControls(show => !show)
@@ -123,7 +161,11 @@ function ImageView({
   }, [])
 
   return (
-    <View style={[styles.container]}>
+    <Animated.View style={[styles.container, containerStyle]}>
+      <Animated.View
+        style={[styles.backdrop, backdropStyle]}
+        renderToHardwareTextureAndroid
+      />
       <PagerView
         scrollEnabled={!isScaled}
         initialPage={initialImageIndex}
@@ -136,9 +178,9 @@ function ImageView({
         }}
         overdrag={true}
         style={styles.pager}>
-        {images.map(imageSrc => (
+        {images.map((imageSrc, i) => (
           <View key={imageSrc.uri}>
-            <ImageItem
+            <LightboxImage
               onTap={onTap}
               onZoom={onZoom}
               imageSrc={imageSrc}
@@ -146,40 +188,147 @@ function ImageView({
               isScrollViewBeingDragged={isDragging}
               showControls={showControls}
               safeAreaRef={safeAreaRef}
+              isScaled={isScaled}
+              isFlyingAway={isFlyingAway}
+              isActive={i === imageIndex}
+              dismissSwipeTranslateY={dismissSwipeTranslateY}
             />
           </View>
         ))}
       </PagerView>
       <View style={styles.controls}>
-        <Animated.View style={animatedHeaderStyle}>
+        <Animated.View
+          style={animatedHeaderStyle}
+          renderToHardwareTextureAndroid>
           <ImageDefaultHeader onRequestClose={onRequestClose} />
         </Animated.View>
-        <Animated.View style={animatedFooterStyle}>
+        <Animated.View
+          style={animatedFooterStyle}
+          renderToHardwareTextureAndroid={!isAltExpanded}>
           <LightboxFooter
             images={images}
             index={imageIndex}
+            isAltExpanded={isAltExpanded}
+            toggleAltExpanded={() => setAltExpanded(e => !e)}
             onPressSave={onPressSave}
             onPressShare={onPressShare}
           />
         </Animated.View>
       </View>
-    </View>
+    </Animated.View>
+  )
+}
+
+function LightboxImage({
+  imageSrc,
+  onTap,
+  onZoom,
+  onRequestClose,
+  isScrollViewBeingDragged,
+  isScaled,
+  isFlyingAway,
+  isActive,
+  showControls,
+  safeAreaRef,
+  dismissSwipeTranslateY,
+}: {
+  imageSrc: ImageSource
+  onRequestClose: () => void
+  onTap: () => void
+  onZoom: (scaled: boolean) => void
+  isScrollViewBeingDragged: boolean
+  isScaled: boolean
+  isActive: boolean
+  isFlyingAway: SharedValue<boolean>
+  showControls: boolean
+  safeAreaRef: AnimatedRef<View>
+  dismissSwipeTranslateY: SharedValue<number>
+}) {
+  const [imageAspect, imageDimensions] = useImageDimensions({
+    src: imageSrc.uri,
+    knownDimensions: imageSrc.dimensions,
+  })
+
+  const dismissSwipePan = Gesture.Pan()
+    .enabled(isActive && !isScaled)
+    .activeOffsetY([-10, 10])
+    .failOffsetX([-10, 10])
+    .maxPointers(1)
+    .onUpdate(e => {
+      'worklet'
+      dismissSwipeTranslateY.value = e.translationY
+    })
+    .onEnd(e => {
+      'worklet'
+      if (Math.abs(e.velocityY) > 1000) {
+        isFlyingAway.value = true
+        dismissSwipeTranslateY.value = withDecay({
+          velocity: e.velocityY,
+          velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow.
+          deceleration: 1, // Danger! This relies on the reaction below stopping it.
+        })
+      } else {
+        dismissSwipeTranslateY.value = withSpring(0, {
+          stiffness: 700,
+          damping: 50,
+        })
+      }
+    })
+  useAnimatedReaction(
+    () => {
+      const screenSize = measure(safeAreaRef)
+      return (
+        !screenSize ||
+        Math.abs(dismissSwipeTranslateY.value) > screenSize.height
+      )
+    },
+    (isOut, wasOut) => {
+      if (isOut && !wasOut) {
+        // Stop the animation from blocking the screen forever.
+        cancelAnimation(dismissSwipeTranslateY)
+        runOnJS(onRequestClose)()
+      }
+    },
+  )
+
+  const imageStyle = useAnimatedStyle(() => {
+    return {
+      transform: [{translateY: dismissSwipeTranslateY.value}],
+    }
+  })
+  return (
+    <ImageItem
+      imageSrc={imageSrc}
+      onTap={onTap}
+      onZoom={onZoom}
+      onRequestClose={onRequestClose}
+      isScrollViewBeingDragged={isScrollViewBeingDragged}
+      showControls={showControls}
+      safeAreaRef={safeAreaRef}
+      imageAspect={imageAspect}
+      imageDimensions={imageDimensions}
+      imageStyle={imageStyle}
+      dismissSwipePan={dismissSwipePan}
+    />
   )
 }
 
 function LightboxFooter({
   images,
   index,
+  isAltExpanded,
+  toggleAltExpanded,
   onPressSave,
   onPressShare,
 }: {
   images: ImageSource[]
   index: number
+  isAltExpanded: boolean
+  toggleAltExpanded: () => void
   onPressSave: (uri: string) => void
   onPressShare: (uri: string) => void
 }) {
   const {alt: altText, uri} = images[index]
-  const [isAltExpanded, setAltExpanded] = React.useState(false)
   const isMomentumScrolling = React.useRef(false)
   return (
     <ScrollView
@@ -210,7 +359,7 @@ function LightboxFooter({
                   duration: 450,
                   update: {type: 'spring', springDamping: 1},
                 })
-                setAltExpanded(prev => !prev)
+                toggleAltExpanded()
               }}
               onLongPress={() => {}}>
               {altText}
@@ -256,7 +405,14 @@ const styles = StyleSheet.create({
   },
   container: {
     flex: 1,
+  },
+  backdrop: {
     backgroundColor: '#000',
+    position: 'absolute',
+    top: 0,
+    bottom: 0,
+    left: 0,
+    right: 0,
   },
   controls: {
     position: 'absolute',