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