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:
@@ -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'},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -91,6 +91,7 @@ export type CommonNavigatorParams = {
|
||||
VideoFeed: VideoFeedSourceContext
|
||||
Bookmarks: undefined
|
||||
FindContactsFlow: undefined
|
||||
Lightbox: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
||||
@@ -95,4 +95,5 @@ export const router = new Router<AllNavigatableRoutes>({
|
||||
VideoFeed: '/video-feed',
|
||||
Bookmarks: '/saved',
|
||||
FindContactsFlow: '/find-contacts',
|
||||
Lightbox: '/lightbox',
|
||||
})
|
||||
|
||||
@@ -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})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user