Present the native lightbox as a transparentModal screen

Register a new Lightbox route (native only) with presentation:
'transparentModal', animation: 'none', orientation: 'all'. The
per-screen orientation option lets the lightbox rotate without taking
the underlying shell screen along for the ride.

openLightbox() still measures the thumbnail on the UI thread and sets
activeLightbox synchronously before dispatching navigate('Lightbox'),
so the screen mounts with state populated and the opening spring
animates from the first frame. usePreventRemove holds the pop until
the close spring finishes (onAnimationEnd), covering hardware back,
programmatic goBack, and the internal close/swipe-dismiss paths with
one callback surface.

The ImageViewRoot animation engine is unchanged other than accepting
onAnimationEnd and losing the now-redundant ScreenOrientation
unlock/lock reaction. Web keeps its existing shell-level overlay.
This commit is contained in:
Claude
2026-04-18 20:55:17 +00:00
committed by Samuel Newman
parent 945d8b229d
commit ed3de2c1c3
8 changed files with 100 additions and 41 deletions
+15
View File
@@ -75,6 +75,7 @@ import {BookmarksScreen} from '#/screens/Bookmarks'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
import HashtagScreen from '#/screens/Hashtag'
import {LightboxScreen} from '#/screens/Lightbox'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
@@ -644,6 +645,20 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
gestureEnabled: false,
}}
/>
{IS_NATIVE && (
<Stack.Screen
name="Lightbox"
getComponent={() => LightboxScreen}
options={{
headerShown: false,
presentation: 'transparentModal',
animation: 'none',
orientation: 'all',
gestureEnabled: false,
contentStyle: {backgroundColor: 'transparent'},
}}
/>
)}
</>
)
}
+5 -24
View File
@@ -1,26 +1,7 @@
import {useCallback} from 'react'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import ImageView from '#/components/Lightbox/pager/ImagePager'
import {useLightbox, useLightboxControls} from '#/components/Lightbox/state'
// On native the lightbox is presented as a transparentModal screen
// (see src/screens/Lightbox/index.tsx) so that react-native-screens can
// allow per-screen orientation. The shell-level <Lightbox /> therefore
// renders nothing on native — the route handles mounting the viewer.
export function Lightbox() {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const onClose = useCallback(() => {
closeLightbox()
}, [closeLightbox])
const saveImageToAlbum = useSaveImageToMediaLibrary()
return (
<ImageView
lightbox={activeLightbox}
onRequestClose={onClose}
onPressSave={saveImageToAlbum}
onPressShare={uri => shareImageModal({uri})}
/>
)
return null
}
+4 -17
View File
@@ -32,7 +32,6 @@ import Animated, {
withSpring,
type WithSpringConfig,
} from 'react-native-reanimated'
import * as ScreenOrientation from 'expo-screen-orientation'
import {type Dimensions} from '#/lib/media/types'
import {useTheme} from '#/alf'
@@ -51,7 +50,6 @@ import ImageItem from './ImageItem/ImageItem'
type Rect = {x: number; y: number; width: number; height: number}
const PORTRAIT_UP = ScreenOrientation.OrientationLock.PORTRAIT_UP
const PIXEL_RATIO = PixelRatio.get()
const SLOW_SPRING: WithSpringConfig = {
@@ -80,11 +78,13 @@ export default function ImageViewRoot({
onRequestClose,
onPressSave,
onPressShare,
onAnimationEnd,
}: {
lightbox: Lightbox | null
onRequestClose: () => void
onPressSave: (uri: string) => void
onPressShare: (uri: string) => void
onAnimationEnd?: () => void
}) {
'use no memo'
const ref = useAnimatedRef<View>()
@@ -136,7 +136,8 @@ export default function ImageViewRoot({
'worklet'
thumbRects.set({})
})()
}, [thumbRects])
onAnimationEnd?.()
}, [thumbRects, onAnimationEnd])
useAnimatedReaction(
() => openProgress.get() === 0,
@@ -147,20 +148,6 @@ export default function ImageViewRoot({
},
)
// Delay the unlock until after we've finished the scale up animation.
// It's complicated to do the same for locking it back so we don't attempt that.
useAnimatedReaction(
() => openProgress.get() === 1,
(isOpen, wasOpen) => {
if (isOpen && !wasOpen) {
runOnJS(ScreenOrientation.unlockAsync)()
} else if (!isOpen && wasOpen) {
// default is PORTRAIT_UP - set via config plugin in app.config.js -sfn
runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP)
}
},
)
const onFlyAway = useCallback(() => {
'worklet'
openProgress.set(0)
+11
View File
@@ -10,6 +10,8 @@ import {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useHotkeysContext} from '#/lib/hotkeys'
import {type ImageSource} from '#/components/Lightbox/types'
import {IS_NATIVE} from '#/env'
import {navigate} from '#/Navigation'
export type Lightbox = {
id: string
@@ -46,15 +48,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [activeLightbox, disableScope, enableScope])
const doOpen = useNonReactiveCallback((lightbox: Omit<Lightbox, 'id'>) => {
let didOpen = false
setActiveLightbox(prevLightbox => {
if (prevLightbox) {
// Ignore duplicate open requests. If it's already open,
// the user has to explicitly close the previous one first.
return prevLightbox
} else {
didOpen = true
return {...lightbox, id: nanoid()}
}
})
// On native the lightbox is a transparentModal screen so that
// react-native-screens can allow per-screen orientation. State is set
// synchronously above, so the screen mounts with activeLightbox already
// populated and the opening spring runs from the first frame.
if (didOpen && IS_NATIVE) {
navigate('Lightbox')
}
})
const openLightbox = useNonReactiveCallback(
+1
View File
@@ -91,6 +91,7 @@ export type CommonNavigatorParams = {
VideoFeed: VideoFeedSourceContext
Bookmarks: undefined
FindContactsFlow: undefined
Lightbox: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
View File
@@ -95,4 +95,5 @@ export const router = new Router<AllNavigatableRoutes>({
VideoFeed: '/video-feed',
Bookmarks: '/saved',
FindContactsFlow: '/find-contacts',
Lightbox: '/lightbox',
})
+55
View File
@@ -0,0 +1,55 @@
import {useCallback, useRef} from 'react'
import {type NavigationAction, usePreventRemove} from '@react-navigation/native'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useLightbox, useLightboxControls} from '#/state/lightbox'
import ImageView from '#/view/com/lightbox/ImageViewing'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lightbox'>
export function LightboxScreen({navigation}: Props) {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const saveImageToAlbum = useSaveImageToMediaLibrary()
const pendingAction = useRef<NavigationAction | null>(null)
// Block the pop while the lightbox is still open. When a pop is attempted
// (hardware back, programmatic goBack, etc.), start the close spring and
// stash the action to replay once the animation completes.
usePreventRemove(!!activeLightbox, ({data}) => {
pendingAction.current = data.action
closeLightbox()
})
const onRequestClose = useCallback(() => {
closeLightbox()
}, [closeLightbox])
// Fires from ImageViewRoot once the openProgress spring reaches 0. By then
// activeLightbox is already null, so usePreventRemove no longer blocks and
// the stashed action (or a fresh goBack) pops the screen cleanly.
const onAnimationEnd = useCallback(() => {
const action = pendingAction.current
pendingAction.current = null
if (action) {
navigation.dispatch(action)
} else if (navigation.canGoBack()) {
navigation.goBack()
}
}, [navigation])
return (
<ImageView
lightbox={activeLightbox}
onRequestClose={onRequestClose}
onAnimationEnd={onAnimationEnd}
onPressSave={saveImageToAlbum}
onPressShare={uri => shareImageModal({uri})}
/>
)
}
+8
View File
@@ -0,0 +1,8 @@
// The native lightbox is presented as a transparentModal screen so that
// react-native-screens can manage per-screen orientation. On web the lightbox
// remains a global overlay (see src/view/com/lightbox/Lightbox.web.tsx) and
// this screen is never registered — this stub exists only to satisfy imports
// on the web bundler.
export function LightboxScreen() {
return null
}