Lazily measure lightbox thumbnails (#10270)

This commit is contained in:
Samuel Newman
2026-04-16 12:24:11 -07:00
committed by GitHub
parent 6e3c9c3a9f
commit 3358e1947b
6 changed files with 144 additions and 101 deletions
+11 -30
View File
@@ -1,11 +1,5 @@
import {InteractionManager, View} from 'react-native' import {InteractionManager, View} from 'react-native'
import { import {type AnimatedRef} from 'react-native-reanimated'
type AnimatedRef,
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {useLightboxControls} from '#/state/lightbox' import {useLightboxControls} from '#/state/lightbox'
@@ -37,34 +31,21 @@ export function ImageEmbed({
alt: img.alt, alt: img.alt,
dimensions: img.aspectRatio ?? null, 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 = ( const onPress = (
index: number, index: number,
refs: AnimatedRef<any>[], refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[], fetchedDims: (Dimensions | null)[],
) => { ) => {
runOnUI(() => { openLightbox({
'worklet' images: items.map((item, i) => ({
const rects: (MeasuredDimensions | null)[] = [] ...item,
for (const r of refs) { thumbRect: null,
rects.push(measure(r)) thumbRef: refs[i] ?? null,
} thumbDimensions: fetchedDims[i] ?? null,
runOnJS(_openLightbox)(index, rects, fetchedDims) type: 'image',
})() })),
index,
})
} }
const onPressIn = (_: number) => { const onPressIn = (_: number) => {
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
+6 -16
View File
@@ -1,10 +1,7 @@
import {memo, useCallback, useEffect, useMemo} from 'react' import {memo, useCallback, useEffect, useMemo} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import Animated, { import Animated, {
measure, type AnimatedRef,
type MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef, useAnimatedRef,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -76,7 +73,7 @@ let ProfileHeaderShell = ({
const _openLightbox = useCallback( const _openLightbox = useCallback(
( (
uri: string, uri: string,
thumbRect: MeasuredDimensions | null, thumbRef: AnimatedRef<any>,
type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi', type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi',
) => { ) => {
openLightbox({ openLightbox({
@@ -84,7 +81,8 @@ let ProfileHeaderShell = ({
{ {
uri, uri,
thumbUri: uri, thumbUri: uri,
thumbRect, thumbRect: null,
thumbRef,
dimensions: dimensions:
type === 'circle-avi' || type === 'rect-avi' type === 'circle-avi' || type === 'rect-avi'
? { ? {
@@ -130,11 +128,7 @@ let ProfileHeaderShell = ({
const avatar = profile.avatar const avatar = profile.avatar
const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi' const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi'
if (avatar && !(modui.blur && modui.noOverride)) { if (avatar && !(modui.blur && modui.noOverride)) {
runOnUI(() => { _openLightbox(avatar, aviRef, type)
'worklet'
const rect = measure(aviRef)
runOnJS(_openLightbox)(avatar, rect, type)
})()
} }
} }
}, [ }, [
@@ -152,11 +146,7 @@ let ProfileHeaderShell = ({
const modui = moderation.ui('banner') const modui = moderation.ui('banner')
const banner = profile.banner const banner = profile.banner
if (banner && !(modui.blur && modui.noOverride)) { if (banner && !(modui.blur && modui.noOverride)) {
runOnUI(() => { _openLightbox(banner, bannerRef, 'image')
'worklet'
const rect = measure(bannerRef)
runOnJS(_openLightbox)(banner, rect, 'image')
})()
} }
}, [profile.banner, moderation, _openLightbox, bannerRef]) }, [profile.banner, moderation, _openLightbox, bannerRef])
+39 -8
View File
@@ -1,4 +1,10 @@
import {createContext, useContext, useEffect, useMemo, useState} from 'react' 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 {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -39,17 +45,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
}, [activeLightbox, disableScope, enableScope]) }, [activeLightbox, disableScope, enableScope])
const doOpen = useNonReactiveCallback((lightbox: Omit<Lightbox, 'id'>) => {
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( const openLightbox = useNonReactiveCallback(
(lightbox: Omit<Lightbox, 'id'>) => { (lightbox: Omit<Lightbox, 'id'>) => {
setActiveLightbox(prevLightbox => { const thumbRef = lightbox.images[lightbox.index]?.thumbRef
if (prevLightbox) { if (thumbRef) {
// Ignore duplicate open requests. If it's already open, // Measure the tapped image on the UI thread, then open with
// the user has to explicitly close the previous one first. // the rect baked in so it's available from the first render.
return prevLightbox // Only the rect (plain data) goes through runOnJS — AnimatedRef
} else { // objects can't survive serialization across threads.
return {...lightbox, id: nanoid()} 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)
}
}, },
) )
@@ -7,7 +7,10 @@
*/ */
import {type TransformsStyle} from 'react-native' 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 = { export type Dimensions = {
width: number width: number
@@ -25,6 +28,7 @@ export type ImageSource = {
thumbUri: string thumbUri: string
thumbDimensions: Dimensions | null thumbDimensions: Dimensions | null
thumbRect: MeasuredDimensions | null thumbRect: MeasuredDimensions | null
thumbRef?: AnimatedRef<any> | null
alt?: string alt?: string
type: 'image' | 'circle-avi' | 'rect-avi' type: 'image' | 'circle-avi' | 'rect-avi'
} }
+73 -20
View File
@@ -23,8 +23,10 @@ import Animated, {
cancelAnimation, cancelAnimation,
interpolate, interpolate,
measure, measure,
type MeasuredDimensions,
ReduceMotion, ReduceMotion,
runOnJS, runOnJS,
runOnUI,
type SharedValue, type SharedValue,
useAnimatedReaction, useAnimatedReaction,
useAnimatedRef, useAnimatedRef,
@@ -73,12 +75,11 @@ const FAST_SPRING: WithSpringConfig = {
} }
function canAnimate(lightbox: Lightbox): boolean { function canAnimate(lightbox: Lightbox): boolean {
return ( if (PlatformInfo.getIsReducedMotionEnabled()) {
!PlatformInfo.getIsReducedMotionEnabled() && return false
lightbox.images.every( }
img => img.thumbRect && (img.dimensions || img.thumbDimensions), const img = lightbox.images[lightbox.index]
) return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions)
)
} }
export default function ImageViewRoot({ export default function ImageViewRoot({
@@ -99,6 +100,9 @@ export default function ImageViewRoot({
'portrait', 'portrait',
) )
const openProgress = useSharedValue(0) const openProgress = useSharedValue(0)
const thumbRects = useSharedValue<Record<number, MeasuredDimensions | null>>(
{},
)
if (!activeLightbox && nextLightbox) { if (!activeLightbox && nextLightbox) {
setActiveLightbox(nextLightbox) setActiveLightbox(nextLightbox)
@@ -109,6 +113,12 @@ export default function ImageViewRoot({
return return
} }
const initial: Record<number, MeasuredDimensions | null> = {}
nextLightbox.images.forEach((img, i) => {
initial[i] = img.thumbRect ?? null
})
thumbRects.set(initial)
const isAnimated = canAnimate(nextLightbox) const isAnimated = canAnimate(nextLightbox)
// https://github.com/software-mansion/react-native-reanimated/issues/6677 // 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( useAnimatedReaction(
() => openProgress.get() === 0, () => openProgress.get() === 0,
(isGone, wasGone) => { (isGone, wasGone) => {
if (isGone && !wasGone) { if (isGone && !wasGone) {
runOnJS(setActiveLightbox)(null) runOnJS(onFullyClosed)()
} }
}, },
) )
@@ -184,6 +202,7 @@ export default function ImageViewRoot({
onFlyAway={onFlyAway} onFlyAway={onFlyAway}
safeAreaRef={ref} safeAreaRef={ref}
openProgress={openProgress} openProgress={openProgress}
thumbRects={thumbRects}
/> />
)} )}
</Animated.View> </Animated.View>
@@ -200,6 +219,7 @@ function ImageView({
onFlyAway, onFlyAway,
safeAreaRef, safeAreaRef,
openProgress, openProgress,
thumbRects,
}: { }: {
lightbox: Lightbox lightbox: Lightbox
orientation: 'portrait' | 'landscape' orientation: 'portrait' | 'landscape'
@@ -209,6 +229,7 @@ function ImageView({
onFlyAway: () => void onFlyAway: () => void
safeAreaRef: AnimatedRef<View> safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number> openProgress: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
}) { }) {
const {images, index: initialImageIndex} = lightbox const {images, index: initialImageIndex} = lightbox
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
@@ -216,7 +237,7 @@ function ImageView({
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [imageIndex, setImageIndex] = useState(initialImageIndex) const [imageIndex, setImageIndex] = useState(initialImageIndex)
const [showControls, setShowControls] = useState(true) const [showControls, setShowControls] = useState(true)
const [isAltExpanded, setAltExpanded] = useState(false) const [isAltExpanded, setIsAltExpanded] = useState(false)
const dismissSwipeTranslateY = useSharedValue(0) const dismissSwipeTranslateY = useSharedValue(0)
const isFlyingAway = useSharedValue(false) 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(() => { const onTap = useCallback(() => {
setShowControls(show => !show) setShowControls(show => !show)
}, []) }, [])
@@ -355,7 +394,7 @@ function ImageView({
onTap={onTap} onTap={onTap}
onZoom={onZoom} onZoom={onZoom}
imageSrc={imageSrc} imageSrc={imageSrc}
onRequestClose={onRequestClose} onRequestClose={handleRequestClose}
isScrollViewBeingDragged={isDragging} isScrollViewBeingDragged={isDragging}
showControls={showControls} showControls={showControls}
safeAreaRef={safeAreaRef} safeAreaRef={safeAreaRef}
@@ -364,6 +403,8 @@ function ImageView({
isActive={i === imageIndex} isActive={i === imageIndex}
dismissSwipeTranslateY={dismissSwipeTranslateY} dismissSwipeTranslateY={dismissSwipeTranslateY}
openProgress={openProgress} openProgress={openProgress}
thumbRects={thumbRects}
imageIndex={i}
/> />
</View> </View>
))} ))}
@@ -372,7 +413,7 @@ function ImageView({
<Animated.View <Animated.View
style={animatedHeaderStyle} style={animatedHeaderStyle}
renderToHardwareTextureAndroid> renderToHardwareTextureAndroid>
<ImageDefaultHeader onRequestClose={onRequestClose} /> <ImageDefaultHeader onRequestClose={handleRequestClose} />
</Animated.View> </Animated.View>
<Animated.View <Animated.View
style={animatedFooterStyle} style={animatedFooterStyle}
@@ -381,7 +422,7 @@ function ImageView({
images={images} images={images}
index={imageIndex} index={imageIndex}
isAltExpanded={isAltExpanded} isAltExpanded={isAltExpanded}
toggleAltExpanded={() => setAltExpanded(e => !e)} toggleAltExpanded={() => setIsAltExpanded(e => !e)}
onPressSave={onPressSave} onPressSave={onPressSave}
onPressShare={onPressShare} onPressShare={onPressShare}
/> />
@@ -404,6 +445,8 @@ function LightboxImage({
safeAreaRef, safeAreaRef,
openProgress, openProgress,
dismissSwipeTranslateY, dismissSwipeTranslateY,
thumbRects,
imageIndex,
}: { }: {
imageSrc: ImageSource imageSrc: ImageSource
onRequestClose: () => void onRequestClose: () => void
@@ -417,6 +460,8 @@ function LightboxImage({
safeAreaRef: AnimatedRef<View> safeAreaRef: AnimatedRef<View>
openProgress: SharedValue<number> openProgress: SharedValue<number>
dismissSwipeTranslateY: SharedValue<number> dismissSwipeTranslateY: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
imageIndex: number
}) { }) {
const [fetchedDims, setFetchedDims] = useState<Dimensions | null>(null) const [fetchedDims, setFetchedDims] = useState<Dimensions | null>(null)
const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions
@@ -449,7 +494,7 @@ function LightboxImage({
return safeArea return safeArea
}, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly]) }, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly])
const {thumbRect} = imageSrc const {thumbRect: thumbRectJS} = imageSrc
const transforms = useDerivedValue(() => { const transforms = useDerivedValue(() => {
'worklet' 'worklet'
const safeArea = measureSafeArea() const safeArea = measureSafeArea()
@@ -467,13 +512,21 @@ function LightboxImage({
} }
} }
if (isActive && thumbRect && imageAspect && openProgressValue < 1) { if (isActive && imageAspect && openProgressValue < 1) {
return interpolateTransform( let thumbRect
openProgressValue, if (_WORKLET) {
thumbRect, thumbRect = thumbRects.get()[imageIndex]
safeArea, } else {
imageAspect, thumbRect = thumbRectJS
) }
if (thumbRect) {
return interpolateTransform(
openProgressValue,
thumbRect,
safeArea,
imageAspect,
)
}
} }
return { return {
isHidden: false, isHidden: false,
+10 -26
View File
@@ -1,12 +1,6 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import Animated, { import Animated, {useAnimatedRef} from 'react-native-reanimated'
measure,
type MeasuredDimensions,
runOnJS,
runOnUI,
useAnimatedRef,
} from 'react-native-reanimated'
import {type AppBskyGraphDefs} from '@atproto/api' import {type AppBskyGraphDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -60,14 +54,17 @@ export function ProfileSubpageHeader({
const canGoBack = navigation.canGoBack() const canGoBack = navigation.canGoBack()
const aviRef = useAnimatedRef() const aviRef = useAnimatedRef()
const _openLightbox = useCallback( const onPressAvi = useCallback(() => {
(uri: string, thumbRect: MeasuredDimensions | null) => { if (
avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
) {
openLightbox({ openLightbox({
images: [ images: [
{ {
uri, uri: avatar,
thumbUri: uri, thumbUri: avatar,
thumbRect, thumbRect: null,
thumbRef: aviRef,
dimensions: { dimensions: {
// It's fine if it's actually smaller but we know it's 1:1. // It's fine if it's actually smaller but we know it's 1:1.
height: 1000, height: 1000,
@@ -79,21 +76,8 @@ export function ProfileSubpageHeader({
], ],
index: 0, 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 ( return (
<> <>