From c051bda3db2e037bed2e474f070b2541dcf2fbb9 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Thu, 2 Apr 2026 13:04:38 -0400 Subject: [PATCH] refactor lightbox orientation transform --- src/view/com/lightbox/ImageViewing/index.tsx | 29 ++++++++++--- src/view/com/util/List.tsx | 43 ++++++++++++++++++-- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 4a70bbb81a..143e55c729 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -59,6 +59,10 @@ type Rect = {x: number; y: number; width: number; height: number} const PORTRAIT_UP = ScreenOrientation.OrientationLock.PORTRAIT_UP const PIXEL_RATIO = PixelRatio.get() +function lockOrientationToPortrait() { + void ScreenOrientation.lockAsync(PORTRAIT_UP) +} + const SLOW_SPRING: WithSpringConfig = { mass: IS_IOS ? 1.25 : 0.75, damping: 300, @@ -137,23 +141,38 @@ 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) } }, ) + // Lock orientation back to portrait BEFORE triggering the close animation. + // This ensures the feed re-layouts while the lightbox is still fully opaque, + // preventing the scroll-to-top issue caused by orientation change. + const lockAndClose = useCallback(() => { + if (orientation === 'landscape') { + void ScreenOrientation.lockAsync(PORTRAIT_UP).then(() => { + // Give the layout time to settle behind the lightbox. + setTimeout(onRequestClose, 500) + }) + } else { + void ScreenOrientation.lockAsync(PORTRAIT_UP) + onRequestClose() + } + }, [orientation, onRequestClose]) + const onFlyAway = useCallback(() => { 'worklet' + // Fly-away: image is already off-screen, dismiss immediately. + // Lock orientation after — the fast swipe makes any brief scroll + // glitch less noticeable than the close button path. openProgress.set(0) runOnJS(onRequestClose)() + runOnJS(lockOrientationToPortrait)() }, [onRequestClose, openProgress]) return ( @@ -178,7 +197,7 @@ export default function ImageViewRoot({ key={activeLightbox.id + '-' + orientation} lightbox={activeLightbox} orientation={orientation} - onRequestClose={onRequestClose} + onRequestClose={lockAndClose} onPressSave={onPressSave} onPressShare={onPressShare} onFlyAway={onFlyAway} diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index c8c4007f02..f740ad7488 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -1,4 +1,11 @@ -import {forwardRef, memo, useDeferredValue, useMemo} from 'react' +import { + forwardRef, + memo, + useDeferredValue, + useEffect, + useMemo, + useRef, +} from 'react' import {RefreshControl, type ViewToken} from 'react-native' import { type FlatListPropsWithLayout, @@ -60,9 +67,12 @@ let List = forwardRef( ref, ): React.ReactElement => { const isScrolledDown = useSharedValue(false) + const scrollOffsetY = useSharedValue(0) + const localRef = useRef(null) const t = useTheme() const dedupe = useDedupe(400) const scrollsToTop = useAllowScrollToTop() + useSaveScrollOnLightbox(localRef, scrollOffsetY) const handleScrolledDownChange = useNonReactiveCallback( (didScrollDown: boolean) => { @@ -88,6 +98,7 @@ let List = forwardRef( }, onScroll(e, ctx) { onScrollFromContext?.(e, ctx) + scrollOffsetY.set(e.contentOffset.y) const didScrollDown = e.contentOffset.y > SCROLLED_DOWN_LIMIT if (isScrolledDown.get() !== didScrollDown) { @@ -174,8 +185,11 @@ let List = forwardRef( scrollsToTop={scrollsToTop} scrollEventThrottle={1} style={style} - // @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn - ref={ref} + ref={node => { + localRef.current = node + if (typeof ref === 'function') ref(node) + else if (ref) (ref as React.MutableRefObject).current = node + }} /> ) }, @@ -192,3 +206,26 @@ function useAllowScrollToTopIOS() { const {activeLightbox} = useLightbox() return useDeferredValue(!activeLightbox) } + +// Save scroll position when lightbox opens, restore it when it closes. +// This prevents the scroll-to-top caused by orientation changes during lightbox viewing. +function useSaveScrollOnLightbox( + listRef: React.RefObject, + scrollOffsetY: {get(): number}, +) { + const {activeLightbox} = useLightbox() + const savedOffset = useRef(null) + + useEffect(() => { + if (activeLightbox) { + savedOffset.current = scrollOffsetY.get() + } else if (savedOffset.current !== null) { + const offset = savedOffset.current + savedOffset.current = null + // Restore after layout settles from any orientation change. + requestAnimationFrame(() => { + listRef.current?.scrollToOffset({offset, animated: false}) + }) + } + }, [activeLightbox, listRef, scrollOffsetY]) +}