about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/lightbox.tsx14
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx83
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx108
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx2
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx289
-rw-r--r--src/view/com/lightbox/Lightbox.tsx9
6 files changed, 277 insertions, 228 deletions
diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx
index 1dae67932..06541106e 100644
--- a/src/state/lightbox.tsx
+++ b/src/state/lightbox.tsx
@@ -1,10 +1,12 @@
 import React from 'react'
 import type {MeasuredDimensions} from 'react-native-reanimated'
+import {nanoid} from 'nanoid/non-secure'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
 
-type Lightbox = {
+export type Lightbox = {
+  id: string
   images: ImageSource[]
   thumbDims: MeasuredDimensions | null
   index: number
@@ -17,7 +19,7 @@ const LightboxContext = React.createContext<{
 })
 
 const LightboxControlContext = React.createContext<{
-  openLightbox: (lightbox: Lightbox) => void
+  openLightbox: (lightbox: Omit<Lightbox, 'id'>) => void
   closeLightbox: () => boolean
 }>({
   openLightbox: () => {},
@@ -29,9 +31,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     null,
   )
 
-  const openLightbox = useNonReactiveCallback((lightbox: Lightbox) => {
-    setActiveLightbox(lightbox)
-  })
+  const openLightbox = useNonReactiveCallback(
+    (lightbox: Omit<Lightbox, 'id'>) => {
+      setActiveLightbox({...lightbox, id: nanoid()})
+    },
+  )
 
   const closeLightbox = useNonReactiveCallback(() => {
     let wasActive = !!activeLightbox
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 487acf931..ea77ec273 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -1,7 +1,9 @@
 import React, {useState} from 'react'
-import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {Gesture, GestureDetector} from 'react-native-gesture-handler'
 import Animated, {
+  AnimatedRef,
+  measure,
   runOnJS,
   useAnimatedReaction,
   useAnimatedRef,
@@ -24,13 +26,6 @@ import {
   TransformMatrix,
 } from '../../transforms'
 
-const windowDim = Dimensions.get('window')
-const screenDim = Dimensions.get('screen')
-const statusBarHeight = windowDim.height - screenDim.height
-const SCREEN = {
-  width: windowDim.width,
-  height: windowDim.height + statusBarHeight,
-}
 const MIN_DOUBLE_TAP_SCALE = 2
 const MAX_ORIGINAL_IMAGE_ZOOM = 2
 
@@ -43,6 +38,7 @@ type Props = {
   onZoom: (isZoomed: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
+  safeAreaRef: AnimatedRef<View>
 }
 const ImageItem = ({
   imageSrc,
@@ -50,6 +46,7 @@ const ImageItem = ({
   onZoom,
   onRequestClose,
   isScrollViewBeingDragged,
+  safeAreaRef,
 }: Props) => {
   const [isScaled, setIsScaled] = useState(false)
   const [imageAspect, imageDimensions] = useImageDimensions({
@@ -102,10 +99,10 @@ const ImageItem = ({
     const [translateX, translateY, scale] = readTransform(t)
 
     const dismissDistance = dismissSwipeTranslateY.value
-    const dismissProgress = Math.min(
-      Math.abs(dismissDistance) / (SCREEN.height / 2),
-      1,
-    )
+    const screenSize = measure(safeAreaRef)
+    const dismissProgress = screenSize
+      ? Math.min(Math.abs(dismissDistance) / (screenSize.height / 2), 1)
+      : 0
     return {
       opacity: 1 - dismissProgress,
       transform: [
@@ -120,6 +117,7 @@ const ImageItem = ({
   // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
   function getExtraTranslationToStayInBounds(
     candidateTransform: TransformMatrix,
+    screenSize: {width: number; height: number},
   ) {
     'worklet'
     if (!imageAspect) {
@@ -127,16 +125,20 @@ const ImageItem = ({
     }
     const [nextTranslateX, nextTranslateY, nextScale] =
       readTransform(candidateTransform)
-    const scaledDimensions = getScaledDimensions(imageAspect, nextScale)
+    const scaledDimensions = getScaledDimensions(
+      imageAspect,
+      nextScale,
+      screenSize,
+    )
     const clampedTranslateX = clampTranslation(
       nextTranslateX,
       scaledDimensions.width,
-      SCREEN.width,
+      screenSize.width,
     )
     const clampedTranslateY = clampTranslation(
       nextTranslateY,
       scaledDimensions.height,
-      SCREEN.height,
+      screenSize.height,
     )
     const dx = clampedTranslateX - nextTranslateX
     const dy = clampedTranslateY - nextTranslateY
@@ -146,21 +148,26 @@ const ImageItem = ({
   const pinch = Gesture.Pinch()
     .onStart(e => {
       'worklet'
+      const screenSize = measure(safeAreaRef)
+      if (!screenSize) {
+        return
+      }
       pinchOrigin.value = {
-        x: e.focalX - SCREEN.width / 2,
-        y: e.focalY - SCREEN.height / 2,
+        x: e.focalX - screenSize.width / 2,
+        y: e.focalY - screenSize.height / 2,
       }
     })
     .onChange(e => {
       'worklet'
-      if (!imageDimensions) {
+      const screenSize = measure(safeAreaRef)
+      if (!imageDimensions || !screenSize) {
         return
       }
       // Don't let the picture zoom in so close that it gets blurry.
       // Also, like in stock Android apps, don't let the user zoom out further than 1:1.
       const [, , committedScale] = readTransform(committedTransform.value)
       const maxCommittedScale =
-        (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+        (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM
       const minPinchScale = 1 / committedScale
       const maxPinchScale = maxCommittedScale / committedScale
       const nextPinchScale = Math.min(
@@ -175,7 +182,7 @@ const ImageItem = ({
       prependPan(t, panTranslation.value)
       prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
       prependTransform(t, committedTransform.value)
-      const [dx, dy] = getExtraTranslationToStayInBounds(t)
+      const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
       if (dx !== 0 || dy !== 0) {
         pinchTranslation.value = {
           x: pinchTranslation.value.x + dx,
@@ -209,9 +216,11 @@ const ImageItem = ({
     .minPointers(isScaled ? 1 : 2)
     .onChange(e => {
       'worklet'
-      if (!imageDimensions) {
+      const screenSize = measure(safeAreaRef)
+      if (!imageDimensions || !screenSize) {
         return
       }
+
       const nextPanTranslation = {x: e.translationX, y: e.translationY}
       let t = createTransform()
       prependPan(t, nextPanTranslation)
@@ -224,7 +233,7 @@ const ImageItem = ({
       prependTransform(t, committedTransform.value)
 
       // Prevent panning from going out of bounds.
-      const [dx, dy] = getExtraTranslationToStayInBounds(t)
+      const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize)
       nextPanTranslation.x += dx
       nextPanTranslation.y += dy
       panTranslation.value = nextPanTranslation
@@ -251,7 +260,8 @@ const ImageItem = ({
     .numberOfTaps(2)
     .onEnd(e => {
       'worklet'
-      if (!imageDimensions || !imageAspect) {
+      const screenSize = measure(safeAreaRef)
+      if (!imageDimensions || !imageAspect || !screenSize) {
         return
       }
       const [, , committedScale] = readTransform(committedTransform.value)
@@ -263,7 +273,7 @@ const ImageItem = ({
       }
 
       // Try to zoom in so that we get rid of the black bars (whatever the orientation was).
-      const screenAspect = SCREEN.width / SCREEN.height
+      const screenAspect = screenSize.width / screenSize.height
       const candidateScale = Math.max(
         imageAspect / screenAspect,
         screenAspect / imageAspect,
@@ -271,20 +281,23 @@ const ImageItem = ({
       )
       // But don't zoom in so close that the picture gets blurry.
       const maxScale =
-        (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+        (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM
       const scale = Math.min(candidateScale, maxScale)
 
       // Calculate where we would be if the user pinched into the double tapped point.
       // We won't use this transform directly because it may go out of bounds.
       const candidateTransform = createTransform()
       const origin = {
-        x: e.absoluteX - SCREEN.width / 2,
-        y: e.absoluteY - SCREEN.height / 2,
+        x: e.absoluteX - screenSize.width / 2,
+        y: e.absoluteY - screenSize.height / 2,
       }
       prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
 
       // Now we know how much we went out of bounds, so we can shoot correctly.
-      const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
+      const [dx, dy] = getExtraTranslationToStayInBounds(
+        candidateTransform,
+        screenSize,
+      )
       const finalTransform = createTransform()
       prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
       committedTransform.value = withClampedSpring(finalTransform)
@@ -348,8 +361,7 @@ const ImageItem = ({
 
 const styles = StyleSheet.create({
   container: {
-    width: SCREEN.width,
-    height: SCREEN.height,
+    height: '100%',
     overflow: 'hidden',
   },
   image: {
@@ -367,19 +379,20 @@ const styles = StyleSheet.create({
 function getScaledDimensions(
   imageAspect: number,
   scale: number,
+  screenSize: {width: number; height: number},
 ): ImageDimensions {
   'worklet'
-  const screenAspect = SCREEN.width / SCREEN.height
+  const screenAspect = screenSize.width / screenSize.height
   const isLandscape = imageAspect > screenAspect
   if (isLandscape) {
     return {
-      width: scale * SCREEN.width,
-      height: (scale * SCREEN.width) / imageAspect,
+      width: scale * screenSize.width,
+      height: (scale * screenSize.width) / imageAspect,
     }
   } else {
     return {
-      width: scale * SCREEN.height * imageAspect,
-      height: scale * SCREEN.height,
+      width: scale * screenSize.height * imageAspect,
+      height: scale * screenSize.height,
     }
   }
 }
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 a96a1c913..e8f36d520 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -7,15 +7,18 @@
  */
 
 import React, {useState} from 'react'
-import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {Gesture, GestureDetector} 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 {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
@@ -24,7 +27,6 @@ import {ImageSource} from '../../@types'
 
 const SWIPE_CLOSE_OFFSET = 75
 const SWIPE_CLOSE_VELOCITY = 1
-const SCREEN = Dimensions.get('screen')
 const MAX_ORIGINAL_IMAGE_ZOOM = 2
 const MIN_DOUBLE_TAP_SCALE = 2
 
@@ -35,6 +37,7 @@ type Props = {
   onZoom: (scaled: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
+  safeAreaRef: AnimatedRef<View>
 }
 
 const ImageItem = ({
@@ -43,20 +46,24 @@ const ImageItem = ({
   onZoom,
   onRequestClose,
   showControls,
+  safeAreaRef,
 }: 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 = imageDimensions
-    ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+    ? (imageDimensions.width / screenSizeDelayedForJSThreadOnly.width) *
+      MAX_ORIGINAL_IMAGE_ZOOM
     : 1
 
   const animatedStyle = useAnimatedStyle(() => {
     return {
+      flex: 1,
       opacity: interpolate(
         translationY.value,
         [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
@@ -90,24 +97,13 @@ const ImageItem = ({
     setScaled(nextIsScaled)
   }
 
-  function handleDoubleTap(absoluteX: number, absoluteY: number) {
+  function zoomTo(nextZoomRect: {
+    x: number
+    y: number
+    width: number
+    height: number
+  }) {
     const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
-    let nextZoomRect = {
-      x: 0,
-      y: 0,
-      width: SCREEN.width,
-      height: SCREEN.height,
-    }
-
-    const willZoom = !scaled
-    if (willZoom) {
-      nextZoomRect = getZoomRectAfterDoubleTap(
-        imageAspect,
-        absoluteX,
-        absoluteY,
-      )
-    }
-
     // @ts-ignore
     scrollResponderRef?.scrollResponderZoomTo({
       ...nextZoomRect, // This rect is in screen coordinates
@@ -124,8 +120,27 @@ const ImageItem = ({
     .numberOfTaps(2)
     .onEnd(e => {
       'worklet'
+      const screenSize = measure(safeAreaRef)
+      if (!screenSize) {
+        return
+      }
       const {absoluteX, absoluteY} = e
-      runOnJS(handleDoubleTap)(absoluteX, absoluteY)
+      let nextZoomRect = {
+        x: 0,
+        y: 0,
+        width: screenSize.width,
+        height: screenSize.height,
+      }
+      const willZoom = !scaled
+      if (willZoom) {
+        nextZoomRect = getZoomRectAfterDoubleTap(
+          imageAspect,
+          absoluteX,
+          absoluteY,
+          screenSize,
+        )
+      }
+      runOnJS(zoomTo)(nextZoomRect)
     })
 
   const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
@@ -135,13 +150,13 @@ const ImageItem = ({
       <Animated.ScrollView
         // @ts-ignore Something's up with the types here
         ref={scrollViewRef}
-        style={styles.listItem}
         pinchGestureEnabled
         showsHorizontalScrollIndicator={false}
         showsVerticalScrollIndicator={false}
         maximumZoomScale={maxZoomScale}
-        onScroll={scrollHandler}>
-        <Animated.View style={[styles.imageScrollContainer, animatedStyle]}>
+        onScroll={scrollHandler}
+        contentContainerStyle={styles.scrollContainer}>
+        <Animated.View style={animatedStyle}>
           <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
           <Image
             contentFit="contain"
@@ -161,17 +176,6 @@ const ImageItem = ({
 }
 
 const styles = StyleSheet.create({
-  imageScrollContainer: {
-    height: SCREEN.height,
-  },
-  listItem: {
-    width: SCREEN.width,
-    height: SCREEN.height,
-  },
-  image: {
-    width: SCREEN.width,
-    height: SCREEN.height,
-  },
   loading: {
     position: 'absolute',
     top: 0,
@@ -179,30 +183,38 @@ const styles = StyleSheet.create({
     right: 0,
     bottom: 0,
   },
+  scrollContainer: {
+    flex: 1,
+  },
+  image: {
+    flex: 1,
+  },
 })
 
 const getZoomRectAfterDoubleTap = (
   imageAspect: number | undefined,
   touchX: number,
   touchY: number,
+  screenSize: {width: number; height: number},
 ): {
   x: number
   y: number
   width: number
   height: number
 } => {
+  'worklet'
   if (!imageAspect) {
     return {
       x: 0,
       y: 0,
-      width: SCREEN.width,
-      height: SCREEN.height,
+      width: screenSize.width,
+      height: screenSize.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 screenAspect = SCREEN.width / SCREEN.height
+  const screenAspect = screenSize.width / screenSize.height
   const zoom = Math.max(
     imageAspect / screenAspect,
     screenAspect / imageAspect,
@@ -213,25 +225,25 @@ const getZoomRectAfterDoubleTap = (
 
   // 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
+  let rectWidth = screenSize.width / zoom
+  let rectHeight = screenSize.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
+  let maxX = screenSize.width - rectWidth
+  let maxY = screenSize.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
+    const renderedHeight = screenSize.width / imageAspect
+    const horizontalBarHeight = (screenSize.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
+    const renderedWidth = screenSize.height * imageAspect
+    const verticalBarWidth = (screenSize.width - renderedWidth) / 2
     minX += verticalBarWidth
     maxX -= verticalBarWidth
   }
@@ -246,7 +258,7 @@ const getZoomRectAfterDoubleTap = (
     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
+    rectX = screenSize.width / 2 - rectWidth / 2
   }
   let rectY
   if (maxY >= minY) {
@@ -257,7 +269,7 @@ const getZoomRectAfterDoubleTap = (
     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
+    rectY = screenSize.height / 2 - rectHeight / 2
   }
 
   return {
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
index 4cb7903ef..383bec995 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -2,6 +2,7 @@
 
 import React from 'react'
 import {View} from 'react-native'
+import {AnimatedRef} from 'react-native-reanimated'
 
 import {ImageSource} from '../../@types'
 
@@ -12,6 +13,7 @@ type Props = {
   onZoom: (scaled: boolean) => void
   isScrollViewBeingDragged: boolean
   showControls: boolean
+  safeAreaRef: AnimatedRef<View>
 }
 
 const ImageItem = (_props: Props) => {
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 40df4c819..791701bca 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -8,24 +8,22 @@
 // Original code copied and simplified from the link below as the codebase is currently not maintained:
 // https://github.com/jobtoday/react-native-image-viewing
 
-import React, {useCallback, useMemo, useState} from 'react'
-import {
-  Dimensions,
-  LayoutAnimation,
-  Platform,
-  StyleSheet,
-  View,
-} from 'react-native'
+import React, {useCallback, useState} from 'react'
+import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native'
 import PagerView from 'react-native-pager-view'
-import {MeasuredDimensions} from 'react-native-reanimated'
-import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import Animated, {
+  AnimatedRef,
+  useAnimatedRef,
+  useAnimatedStyle,
+  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 {colors, s} from '#/lib/styles'
 import {isIOS} from '#/platform/detection'
+import {Lightbox} from '#/state/lightbox'
 import {Button} from '#/view/com/util/forms/Button'
 import {Text} from '#/view/com/util/text/Text'
 import {ScrollView} from '#/view/com/util/Views'
@@ -33,37 +31,68 @@ import {ImageSource} from './@types'
 import ImageDefaultHeader from './components/ImageDefaultHeader'
 import ImageItem from './components/ImageItem/ImageItem'
 
-type Props = {
-  images: ImageSource[]
-  thumbDims: MeasuredDimensions | null
-  initialImageIndex: number
-  visible: boolean
+const EDGES =
+  Platform.OS === 'android'
+    ? (['top', 'bottom', 'left', 'right'] satisfies Edge[])
+    : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area
+
+export default function ImageViewRoot({
+  lightbox,
+  onRequestClose,
+  onPressSave,
+  onPressShare,
+}: {
+  lightbox: Lightbox | null
   onRequestClose: () => void
-  backgroundColor?: string
   onPressSave: (uri: string) => void
   onPressShare: (uri: string) => void
+}) {
+  const ref = useAnimatedRef<View>()
+  return (
+    // Keep it always mounted to avoid flicker on the first frame.
+    <SafeAreaView
+      style={[styles.screen, !lightbox && styles.screenHidden]}
+      edges={EDGES}
+      aria-modal
+      accessibilityViewIsModal
+      aria-hidden={!lightbox}>
+      <Animated.View ref={ref} style={{flex: 1}} collapsable={false}>
+        {lightbox && (
+          <ImageView
+            key={lightbox.id}
+            lightbox={lightbox}
+            onRequestClose={onRequestClose}
+            onPressSave={onPressSave}
+            onPressShare={onPressShare}
+            safeAreaRef={ref}
+          />
+        )}
+      </Animated.View>
+    </SafeAreaView>
+  )
 }
 
-const SCREEN_HEIGHT = Dimensions.get('window').height
-const DEFAULT_BG_COLOR = '#000'
-
-function ImageViewing({
-  images,
-  thumbDims: _thumbDims, // TODO: Pass down and use for animation.
-  initialImageIndex,
-  visible,
+function ImageView({
+  lightbox,
   onRequestClose,
-  backgroundColor = DEFAULT_BG_COLOR,
   onPressSave,
   onPressShare,
-}: Props) {
+  safeAreaRef,
+}: {
+  lightbox: Lightbox
+  onRequestClose: () => void
+  onPressSave: (uri: string) => void
+  onPressShare: (uri: string) => void
+  safeAreaRef: AnimatedRef<View>
+}) {
+  const {images, index: initialImageIndex} = lightbox
   const [isScaled, setIsScaled] = useState(false)
   const [isDragging, setIsDragging] = useState(false)
   const [imageIndex, setImageIndex] = useState(initialImageIndex)
   const [showControls, setShowControls] = useState(true)
 
   const animatedHeaderStyle = useAnimatedStyle(() => ({
-    pointerEvents: showControls ? 'auto' : 'none',
+    pointerEvents: showControls ? 'box-none' : 'none',
     opacity: withClampedSpring(showControls ? 1 : 0),
     transform: [
       {
@@ -72,7 +101,8 @@ function ImageViewing({
     ],
   }))
   const animatedFooterStyle = useAnimatedStyle(() => ({
-    pointerEvents: showControls ? 'auto' : 'none',
+    flexGrow: 1,
+    pointerEvents: showControls ? 'box-none' : 'none',
     opacity: withClampedSpring(showControls ? 1 : 0),
     transform: [
       {
@@ -92,53 +122,39 @@ function ImageViewing({
     }
   }, [])
 
-  const edges = useMemo(() => {
-    if (Platform.OS === 'android') {
-      return ['top', 'bottom', 'left', 'right'] satisfies Edge[]
-    }
-    return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
-  }, [])
-
-  if (!visible) {
-    return null
-  }
-
   return (
-    <SafeAreaView
-      style={styles.screen}
-      edges={edges}
-      aria-modal
-      accessibilityViewIsModal>
-      <View style={[styles.container, {backgroundColor}]}>
-        <Animated.View style={[styles.header, animatedHeaderStyle]}>
+    <View style={[styles.container]}>
+      <PagerView
+        scrollEnabled={!isScaled}
+        initialPage={initialImageIndex}
+        onPageSelected={e => {
+          setImageIndex(e.nativeEvent.position)
+          setIsScaled(false)
+        }}
+        onPageScrollStateChanged={e => {
+          setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
+        }}
+        overdrag={true}
+        style={styles.pager}>
+        {images.map(imageSrc => (
+          <View key={imageSrc.uri}>
+            <ImageItem
+              onTap={onTap}
+              onZoom={onZoom}
+              imageSrc={imageSrc}
+              onRequestClose={onRequestClose}
+              isScrollViewBeingDragged={isDragging}
+              showControls={showControls}
+              safeAreaRef={safeAreaRef}
+            />
+          </View>
+        ))}
+      </PagerView>
+      <View style={styles.controls}>
+        <Animated.View style={animatedHeaderStyle}>
           <ImageDefaultHeader onRequestClose={onRequestClose} />
         </Animated.View>
-        <PagerView
-          scrollEnabled={!isScaled}
-          initialPage={initialImageIndex}
-          onPageSelected={e => {
-            setImageIndex(e.nativeEvent.position)
-            setIsScaled(false)
-          }}
-          onPageScrollStateChanged={e => {
-            setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
-          }}
-          overdrag={true}
-          style={styles.pager}>
-          {images.map(imageSrc => (
-            <View key={imageSrc.uri}>
-              <ImageItem
-                onTap={onTap}
-                onZoom={onZoom}
-                imageSrc={imageSrc}
-                onRequestClose={onRequestClose}
-                isScrollViewBeingDragged={isDragging}
-                showControls={showControls}
-              />
-            </View>
-          ))}
-        </PagerView>
-        <Animated.View style={[styles.footer, animatedFooterStyle]}>
+        <Animated.View style={animatedFooterStyle}>
           <LightboxFooter
             images={images}
             index={imageIndex}
@@ -147,7 +163,7 @@ function ImageViewing({
           />
         </Animated.View>
       </View>
-    </SafeAreaView>
+    </View>
   )
 }
 
@@ -164,17 +180,10 @@ function LightboxFooter({
 }) {
   const {alt: altText, uri} = images[index]
   const [isAltExpanded, setAltExpanded] = React.useState(false)
-  const insets = useSafeAreaInsets()
-  const svMaxHeight = SCREEN_HEIGHT - insets.top - 50
   const isMomentumScrolling = React.useRef(false)
   return (
     <ScrollView
-      style={[
-        {
-          backgroundColor: '#000d',
-        },
-        {maxHeight: svMaxHeight},
-      ]}
+      style={styles.footerScrollView}
       scrollEnabled={isAltExpanded}
       onMomentumScrollBegin={() => {
         isMomentumScrolling.current = true
@@ -183,51 +192,52 @@ function LightboxFooter({
         isMomentumScrolling.current = false
       }}
       contentContainerStyle={{
-        paddingTop: 16,
-        paddingBottom: insets.bottom + 10,
+        paddingVertical: 12,
         paddingHorizontal: 24,
       }}>
-      {altText ? (
-        <View accessibilityRole="button" style={styles.footerText}>
-          <Text
-            style={[s.gray3]}
-            numberOfLines={isAltExpanded ? undefined : 3}
-            selectable
-            onPress={() => {
-              if (isMomentumScrolling.current) {
-                return
-              }
-              LayoutAnimation.configureNext({
-                duration: 450,
-                update: {type: 'spring', springDamping: 1},
-              })
-              setAltExpanded(prev => !prev)
-            }}
-            onLongPress={() => {}}>
-            {altText}
-          </Text>
+      <SafeAreaView edges={['bottom']}>
+        {altText ? (
+          <View accessibilityRole="button" style={styles.footerText}>
+            <Text
+              style={[s.gray3]}
+              numberOfLines={isAltExpanded ? undefined : 3}
+              selectable
+              onPress={() => {
+                if (isMomentumScrolling.current) {
+                  return
+                }
+                LayoutAnimation.configureNext({
+                  duration: 450,
+                  update: {type: 'spring', springDamping: 1},
+                })
+                setAltExpanded(prev => !prev)
+              }}
+              onLongPress={() => {}}>
+              {altText}
+            </Text>
+          </View>
+        ) : null}
+        <View style={styles.footerBtns}>
+          <Button
+            type="primary-outline"
+            style={styles.footerBtn}
+            onPress={() => onPressSave(uri)}>
+            <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
+            <Text type="xl" style={s.white}>
+              <Trans context="action">Save</Trans>
+            </Text>
+          </Button>
+          <Button
+            type="primary-outline"
+            style={styles.footerBtn}
+            onPress={() => onPressShare(uri)}>
+            <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
+            <Text type="xl" style={s.white}>
+              <Trans context="action">Share</Trans>
+            </Text>
+          </Button>
         </View>
-      ) : null}
-      <View style={styles.footerBtns}>
-        <Button
-          type="primary-outline"
-          style={styles.footerBtn}
-          onPress={() => onPressSave(uri)}>
-          <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
-          <Text type="xl" style={s.white}>
-            <Trans context="action">Save</Trans>
-          </Text>
-        </Button>
-        <Button
-          type="primary-outline"
-          style={styles.footerBtn}
-          onPress={() => onPressShare(uri)}>
-          <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
-          <Text type="xl" style={s.white}>
-            <Trans context="action">Share</Trans>
-          </Text>
-        </Button>
-      </View>
+      </SafeAreaView>
     </ScrollView>
   )
 }
@@ -240,25 +250,46 @@ const styles = StyleSheet.create({
     bottom: 0,
     right: 0,
   },
+  screenHidden: {
+    opacity: 0,
+    pointerEvents: 'none',
+  },
   container: {
     flex: 1,
     backgroundColor: '#000',
   },
+  controls: {
+    position: 'absolute',
+    top: 0,
+    bottom: 0,
+    left: 0,
+    right: 0,
+    gap: 20,
+    zIndex: 1,
+    pointerEvents: 'box-none',
+  },
   pager: {
     flex: 1,
   },
   header: {
     position: 'absolute',
     width: '100%',
-    zIndex: 1,
     top: 0,
     pointerEvents: 'box-none',
   },
   footer: {
     position: 'absolute',
     width: '100%',
-    zIndex: 1,
+    maxHeight: '100%',
+    bottom: 0,
+  },
+  footerScrollView: {
+    backgroundColor: '#000d',
+    flex: 1,
+    position: 'absolute',
     bottom: 0,
+    width: '100%',
+    maxHeight: '100%',
   },
   footerText: {
     paddingBottom: isIOS ? 20 : 16,
@@ -277,13 +308,7 @@ const styles = StyleSheet.create({
   },
 })
 
-const EnhancedImageViewing = (props: Props) => (
-  <ImageViewing key={props.initialImageIndex} {...props} />
-)
-
 function withClampedSpring(value: any) {
   'worklet'
   return withSpring(value, {overshootClamping: true, stiffness: 300})
 }
-
-export default EnhancedImageViewing
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index ed570d5a7..628bd2b9a 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -49,16 +49,9 @@ export function Lightbox() {
     [permissionResponse, requestPermission, _],
   )
 
-  if (!activeLightbox) {
-    return null
-  }
-
   return (
     <ImageView
-      images={activeLightbox.images}
-      initialImageIndex={activeLightbox.index}
-      thumbDims={activeLightbox.thumbDims}
-      visible
+      lightbox={activeLightbox}
       onRequestClose={onClose}
       onPressSave={saveImageToAlbumWithToasts}
       onPressShare={uri => shareImageModal({uri})}