From 3358e1947b607da0123e2a6e01b226c73ca88cda Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 16 Apr 2026 12:24:11 -0700 Subject: [PATCH] Lazily measure lightbox thumbnails (#10270) --- src/components/Post/Embed/ImageEmbed.tsx | 41 +++----- src/screens/Profile/Header/Shell.tsx | 22 ++--- src/state/lightbox.tsx | 47 ++++++++-- .../com/lightbox/ImageViewing/@types/index.ts | 6 +- src/view/com/lightbox/ImageViewing/index.tsx | 93 +++++++++++++++---- src/view/com/profile/ProfileSubpageHeader.tsx | 36 ++----- 6 files changed, 144 insertions(+), 101 deletions(-) diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index fba3256d56..6ca166ff10 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -1,11 +1,5 @@ import {InteractionManager, View} from 'react-native' -import { - type AnimatedRef, - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, -} from 'react-native-reanimated' +import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' import {useLightboxControls} from '#/state/lightbox' @@ -37,34 +31,21 @@ export function ImageEmbed({ alt: img.alt, dimensions: img.aspectRatio ?? null, })) - const _openLightbox = ( - index: number, - thumbRects: (MeasuredDimensions | null)[], - fetchedDims: (Dimensions | null)[], - ) => { - openLightbox({ - images: items.map((item, i) => ({ - ...item, - thumbRect: thumbRects[i] ?? null, - thumbDimensions: fetchedDims[i] ?? null, - type: 'image', - })), - index, - }) - } const onPress = ( index: number, refs: AnimatedRef[], fetchedDims: (Dimensions | null)[], ) => { - runOnUI(() => { - 'worklet' - const rects: (MeasuredDimensions | null)[] = [] - for (const r of refs) { - rects.push(measure(r)) - } - runOnJS(_openLightbox)(index, rects, fetchedDims) - })() + openLightbox({ + images: items.map((item, i) => ({ + ...item, + thumbRect: null, + thumbRef: refs[i] ?? null, + thumbDimensions: fetchedDims[i] ?? null, + type: 'image', + })), + index, + }) } const onPressIn = (_: number) => { InteractionManager.runAfterInteractions(() => { diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index b050ea9ef8..1d33539e25 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,10 +1,7 @@ import {memo, useCallback, useEffect, useMemo} from 'react' import {Pressable, View} from 'react-native' import Animated, { - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, + type AnimatedRef, useAnimatedRef, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' @@ -76,7 +73,7 @@ let ProfileHeaderShell = ({ const _openLightbox = useCallback( ( uri: string, - thumbRect: MeasuredDimensions | null, + thumbRef: AnimatedRef, type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi', ) => { openLightbox({ @@ -84,7 +81,8 @@ let ProfileHeaderShell = ({ { uri, thumbUri: uri, - thumbRect, + thumbRect: null, + thumbRef, dimensions: type === 'circle-avi' || type === 'rect-avi' ? { @@ -130,11 +128,7 @@ let ProfileHeaderShell = ({ const avatar = profile.avatar const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi' if (avatar && !(modui.blur && modui.noOverride)) { - runOnUI(() => { - 'worklet' - const rect = measure(aviRef) - runOnJS(_openLightbox)(avatar, rect, type) - })() + _openLightbox(avatar, aviRef, type) } } }, [ @@ -152,11 +146,7 @@ let ProfileHeaderShell = ({ const modui = moderation.ui('banner') const banner = profile.banner if (banner && !(modui.blur && modui.noOverride)) { - runOnUI(() => { - 'worklet' - const rect = measure(bannerRef) - runOnJS(_openLightbox)(banner, rect, 'image') - })() + _openLightbox(banner, bannerRef, 'image') } }, [profile.banner, moderation, _openLightbox, bannerRef]) diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx index 1e22cc98a4..7d688dab20 100644 --- a/src/state/lightbox.tsx +++ b/src/state/lightbox.tsx @@ -1,4 +1,10 @@ import {createContext, useContext, useEffect, useMemo, useState} from 'react' +import { + measure, + type MeasuredDimensions, + runOnJS, + runOnUI, +} from 'react-native-reanimated' import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -39,17 +45,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, [activeLightbox, disableScope, enableScope]) + const doOpen = useNonReactiveCallback((lightbox: Omit) => { + 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 { + return {...lightbox, id: nanoid()} + } + }) + }) + const openLightbox = useNonReactiveCallback( (lightbox: Omit) => { - 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 { - return {...lightbox, id: nanoid()} + const thumbRef = lightbox.images[lightbox.index]?.thumbRef + if (thumbRef) { + // Measure the tapped image on the UI thread, then open with + // the rect baked in so it's available from the first render. + // Only the rect (plain data) goes through runOnJS — AnimatedRef + // objects can't survive serialization across threads. + const openWithRect = (rect: MeasuredDimensions | null) => { + doOpen({ + ...lightbox, + images: lightbox.images.map((img, i) => + i === lightbox.index ? {...img, thumbRect: rect} : img, + ), + }) } - }) + runOnUI(() => { + 'worklet' + const rect = measure(thumbRef) + runOnJS(openWithRect)(rect) + })() + } else { + doOpen(lightbox) + } }, ) diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts index 55b3db8fea..8435513af0 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -7,7 +7,10 @@ */ import {type TransformsStyle} from 'react-native' -import {type MeasuredDimensions} from 'react-native-reanimated' +import { + type AnimatedRef, + type MeasuredDimensions, +} from 'react-native-reanimated' export type Dimensions = { width: number @@ -25,6 +28,7 @@ export type ImageSource = { thumbUri: string thumbDimensions: Dimensions | null thumbRect: MeasuredDimensions | null + thumbRef?: AnimatedRef | null alt?: string type: 'image' | 'circle-avi' | 'rect-avi' } diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index f68900971e..82d1ce27a5 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -23,8 +23,10 @@ import Animated, { cancelAnimation, interpolate, measure, + type MeasuredDimensions, ReduceMotion, runOnJS, + runOnUI, type SharedValue, useAnimatedReaction, useAnimatedRef, @@ -73,12 +75,11 @@ const FAST_SPRING: WithSpringConfig = { } function canAnimate(lightbox: Lightbox): boolean { - return ( - !PlatformInfo.getIsReducedMotionEnabled() && - lightbox.images.every( - img => img.thumbRect && (img.dimensions || img.thumbDimensions), - ) - ) + if (PlatformInfo.getIsReducedMotionEnabled()) { + return false + } + const img = lightbox.images[lightbox.index] + return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions) } export default function ImageViewRoot({ @@ -99,6 +100,9 @@ export default function ImageViewRoot({ 'portrait', ) const openProgress = useSharedValue(0) + const thumbRects = useSharedValue>( + {}, + ) if (!activeLightbox && nextLightbox) { setActiveLightbox(nextLightbox) @@ -109,6 +113,12 @@ export default function ImageViewRoot({ return } + const initial: Record = {} + nextLightbox.images.forEach((img, i) => { + initial[i] = img.thumbRect ?? null + }) + thumbRects.set(initial) + const isAnimated = canAnimate(nextLightbox) // https://github.com/software-mansion/react-native-reanimated/issues/6677 @@ -125,13 +135,21 @@ export default function ImageViewRoot({ ) }) } - }, [nextLightbox, openProgress]) + }, [nextLightbox, openProgress, thumbRects]) + + const onFullyClosed = useCallback(() => { + setActiveLightbox(null) + runOnUI(() => { + 'worklet' + thumbRects.set({}) + })() + }, [thumbRects]) useAnimatedReaction( () => openProgress.get() === 0, (isGone, wasGone) => { if (isGone && !wasGone) { - runOnJS(setActiveLightbox)(null) + runOnJS(onFullyClosed)() } }, ) @@ -184,6 +202,7 @@ export default function ImageViewRoot({ onFlyAway={onFlyAway} safeAreaRef={ref} openProgress={openProgress} + thumbRects={thumbRects} /> )} @@ -200,6 +219,7 @@ function ImageView({ onFlyAway, safeAreaRef, openProgress, + thumbRects, }: { lightbox: Lightbox orientation: 'portrait' | 'landscape' @@ -209,6 +229,7 @@ function ImageView({ onFlyAway: () => void safeAreaRef: AnimatedRef openProgress: SharedValue + thumbRects: SharedValue> }) { const {images, index: initialImageIndex} = lightbox const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) @@ -216,7 +237,7 @@ function ImageView({ const [isDragging, setIsDragging] = useState(false) const [imageIndex, setImageIndex] = useState(initialImageIndex) const [showControls, setShowControls] = useState(true) - const [isAltExpanded, setAltExpanded] = useState(false) + const [isAltExpanded, setIsAltExpanded] = useState(false) const dismissSwipeTranslateY = useSharedValue(0) const isFlyingAway = useSharedValue(false) @@ -287,6 +308,24 @@ function ImageView({ } }) + const handleRequestClose = useCallback(() => { + const activeRef = images[imageIndex]?.thumbRef + if (isAnimated && activeRef) { + runOnUI(() => { + 'worklet' + const rect = measure(activeRef) + thumbRects.modify(rects => { + 'worklet' + rects[imageIndex] = rect + return rects + }) + runOnJS(onRequestClose)() + })() + } else { + onRequestClose() + } + }, [isAnimated, images, imageIndex, thumbRects, onRequestClose]) + const onTap = useCallback(() => { setShowControls(show => !show) }, []) @@ -355,7 +394,7 @@ function ImageView({ onTap={onTap} onZoom={onZoom} imageSrc={imageSrc} - onRequestClose={onRequestClose} + onRequestClose={handleRequestClose} isScrollViewBeingDragged={isDragging} showControls={showControls} safeAreaRef={safeAreaRef} @@ -364,6 +403,8 @@ function ImageView({ isActive={i === imageIndex} dismissSwipeTranslateY={dismissSwipeTranslateY} openProgress={openProgress} + thumbRects={thumbRects} + imageIndex={i} /> ))} @@ -372,7 +413,7 @@ function ImageView({ - + setAltExpanded(e => !e)} + toggleAltExpanded={() => setIsAltExpanded(e => !e)} onPressSave={onPressSave} onPressShare={onPressShare} /> @@ -404,6 +445,8 @@ function LightboxImage({ safeAreaRef, openProgress, dismissSwipeTranslateY, + thumbRects, + imageIndex, }: { imageSrc: ImageSource onRequestClose: () => void @@ -417,6 +460,8 @@ function LightboxImage({ safeAreaRef: AnimatedRef openProgress: SharedValue dismissSwipeTranslateY: SharedValue + thumbRects: SharedValue> + imageIndex: number }) { const [fetchedDims, setFetchedDims] = useState(null) const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions @@ -449,7 +494,7 @@ function LightboxImage({ return safeArea }, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly]) - const {thumbRect} = imageSrc + const {thumbRect: thumbRectJS} = imageSrc const transforms = useDerivedValue(() => { 'worklet' const safeArea = measureSafeArea() @@ -467,13 +512,21 @@ function LightboxImage({ } } - if (isActive && thumbRect && imageAspect && openProgressValue < 1) { - return interpolateTransform( - openProgressValue, - thumbRect, - safeArea, - imageAspect, - ) + if (isActive && imageAspect && openProgressValue < 1) { + let thumbRect + if (_WORKLET) { + thumbRect = thumbRects.get()[imageIndex] + } else { + thumbRect = thumbRectJS + } + if (thumbRect) { + return interpolateTransform( + openProgressValue, + thumbRect, + safeArea, + imageAspect, + ) + } } return { isHidden: false, diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 41ce2b2f7d..1c0bf40843 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -1,12 +1,6 @@ import {useCallback} from 'react' import {Pressable, View} from 'react-native' -import Animated, { - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, - useAnimatedRef, -} from 'react-native-reanimated' +import Animated, {useAnimatedRef} from 'react-native-reanimated' import {type AppBskyGraphDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -60,14 +54,17 @@ export function ProfileSubpageHeader({ const canGoBack = navigation.canGoBack() const aviRef = useAnimatedRef() - const _openLightbox = useCallback( - (uri: string, thumbRect: MeasuredDimensions | null) => { + const onPressAvi = useCallback(() => { + if ( + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { openLightbox({ images: [ { - uri, - thumbUri: uri, - thumbRect, + uri: avatar, + thumbUri: avatar, + thumbRect: null, + thumbRef: aviRef, dimensions: { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, @@ -79,21 +76,8 @@ export function ProfileSubpageHeader({ ], index: 0, }) - }, - [openLightbox], - ) - - const onPressAvi = useCallback(() => { - if ( - avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) - ) { - runOnUI(() => { - 'worklet' - const rect = measure(aviRef) - runOnJS(_openLightbox)(avatar, rect) - })() } - }, [_openLightbox, avatar, aviRef]) + }, [openLightbox, avatar, aviRef]) return ( <>