about summary refs log tree commit diff
path: root/src/view/com/lightbox/ImageViewing
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-11-08 02:49:32 +0000
committerGitHub <noreply@github.com>2024-11-08 02:49:32 +0000
commit5d0610d419906be0ef2c7c7ab0d1f66c366f3aed (patch)
treed120b486b15a3720691530264509cd2d11e51b87 /src/view/com/lightbox/ImageViewing
parent6570f56d8e22b22d099338c24731f525b860583a (diff)
downloadvoidsky-5d0610d419906be0ef2c7c7ab0d1f66c366f3aed.tar.zst
[Lightbox] New dismiss gesture (#6135)
* Make iOS scrollview bounded to the image

I've had to remove the dismiss handling because the scroll view no longer scrolls at rest.

* Fix double-tap not working right after a vertical swipe

It seems like for some reason the vertical swipe is still being handled by the scroll view, so double tap gets eaten while it's "coming back". But you don't really see it moving. Weird.

* Add an intermediate LightboxImage component

* Hoist useImageDimensions up

* Implement xplat dismiss gesture

This is now shared between platforms, letting us animate the backdrop and add a consistent "fly away" behavior.

* Optimize Android compositing perf

* Fix supertall images

For example, https://bsky.app/profile/schlagteslinks.bsky.social/post/3l7y4l6yur72e

* Fix oopsie
Diffstat (limited to 'src/view/com/lightbox/ImageViewing')
-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',