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 {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen' import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {LightboxScreen} from '#/screens/Lightbox'
import {LogScreen} from '#/screens/Log' import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
@@ -644,6 +645,20 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
gestureEnabled: false, 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' // On native the lightbox is presented as a transparentModal screen
// (see src/screens/Lightbox/index.tsx) so that react-native-screens can
import {shareImageModal} from '#/lib/media/manip' // allow per-screen orientation. The shell-level <Lightbox /> therefore
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image' // renders nothing on native — the route handles mounting the viewer.
import ImageView from '#/components/Lightbox/pager/ImagePager'
import {useLightbox, useLightboxControls} from '#/components/Lightbox/state'
export function Lightbox() { export function Lightbox() {
const {activeLightbox} = useLightbox() return null
const {closeLightbox} = useLightboxControls()
const onClose = useCallback(() => {
closeLightbox()
}, [closeLightbox])
const saveImageToAlbum = useSaveImageToMediaLibrary()
return (
<ImageView
lightbox={activeLightbox}
onRequestClose={onClose}
onPressSave={saveImageToAlbum}
onPressShare={uri => shareImageModal({uri})}
/>
)
} }
+4 -17
View File
@@ -32,7 +32,6 @@ import Animated, {
withSpring, withSpring,
type WithSpringConfig, type WithSpringConfig,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import * as ScreenOrientation from 'expo-screen-orientation'
import {type Dimensions} from '#/lib/media/types' import {type Dimensions} from '#/lib/media/types'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
@@ -51,7 +50,6 @@ import ImageItem from './ImageItem/ImageItem'
type Rect = {x: number; y: number; width: number; height: number} type Rect = {x: number; y: number; width: number; height: number}
const PORTRAIT_UP = ScreenOrientation.OrientationLock.PORTRAIT_UP
const PIXEL_RATIO = PixelRatio.get() const PIXEL_RATIO = PixelRatio.get()
const SLOW_SPRING: WithSpringConfig = { const SLOW_SPRING: WithSpringConfig = {
@@ -80,11 +78,13 @@ export default function ImageViewRoot({
onRequestClose, onRequestClose,
onPressSave, onPressSave,
onPressShare, onPressShare,
onAnimationEnd,
}: { }: {
lightbox: Lightbox | null lightbox: Lightbox | null
onRequestClose: () => void onRequestClose: () => void
onPressSave: (uri: string) => void onPressSave: (uri: string) => void
onPressShare: (uri: string) => void onPressShare: (uri: string) => void
onAnimationEnd?: () => void
}) { }) {
'use no memo' 'use no memo'
const ref = useAnimatedRef<View>() const ref = useAnimatedRef<View>()
@@ -136,7 +136,8 @@ export default function ImageViewRoot({
'worklet' 'worklet'
thumbRects.set({}) thumbRects.set({})
})() })()
}, [thumbRects]) onAnimationEnd?.()
}, [thumbRects, onAnimationEnd])
useAnimatedReaction( useAnimatedReaction(
() => openProgress.get() === 0, () => 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(() => { const onFlyAway = useCallback(() => {
'worklet' 'worklet'
openProgress.set(0) openProgress.set(0)
+11
View File
@@ -10,6 +10,8 @@ import {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useHotkeysContext} from '#/lib/hotkeys' import {useHotkeysContext} from '#/lib/hotkeys'
import {type ImageSource} from '#/components/Lightbox/types' import {type ImageSource} from '#/components/Lightbox/types'
import {IS_NATIVE} from '#/env'
import {navigate} from '#/Navigation'
export type Lightbox = { export type Lightbox = {
id: string id: string
@@ -46,15 +48,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [activeLightbox, disableScope, enableScope]) }, [activeLightbox, disableScope, enableScope])
const doOpen = useNonReactiveCallback((lightbox: Omit<Lightbox, 'id'>) => { const doOpen = useNonReactiveCallback((lightbox: Omit<Lightbox, 'id'>) => {
let didOpen = false
setActiveLightbox(prevLightbox => { setActiveLightbox(prevLightbox => {
if (prevLightbox) { if (prevLightbox) {
// Ignore duplicate open requests. If it's already open, // Ignore duplicate open requests. If it's already open,
// the user has to explicitly close the previous one first. // the user has to explicitly close the previous one first.
return prevLightbox return prevLightbox
} else { } else {
didOpen = true
return {...lightbox, id: nanoid()} 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( const openLightbox = useNonReactiveCallback(
+1
View File
@@ -91,6 +91,7 @@ export type CommonNavigatorParams = {
VideoFeed: VideoFeedSourceContext VideoFeed: VideoFeedSourceContext
Bookmarks: undefined Bookmarks: undefined
FindContactsFlow: undefined FindContactsFlow: undefined
Lightbox: undefined
} }
export type BottomTabNavigatorParams = CommonNavigatorParams & { export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
View File
@@ -95,4 +95,5 @@ export const router = new Router<AllNavigatableRoutes>({
VideoFeed: '/video-feed', VideoFeed: '/video-feed',
Bookmarks: '/saved', Bookmarks: '/saved',
FindContactsFlow: '/find-contacts', 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
}