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
+73 -20
View File
@@ -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<Record<number, MeasuredDimensions | null>>(
{},
)
if (!activeLightbox && nextLightbox) {
setActiveLightbox(nextLightbox)
@@ -109,6 +113,12 @@ export default function ImageViewRoot({
return
}
const initial: Record<number, MeasuredDimensions | null> = {}
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}
/>
)}
</Animated.View>
@@ -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<View>
openProgress: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
}) {
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}
/>
</View>
))}
@@ -372,7 +413,7 @@ function ImageView({
<Animated.View
style={animatedHeaderStyle}
renderToHardwareTextureAndroid>
<ImageDefaultHeader onRequestClose={onRequestClose} />
<ImageDefaultHeader onRequestClose={handleRequestClose} />
</Animated.View>
<Animated.View
style={animatedFooterStyle}
@@ -381,7 +422,7 @@ function ImageView({
images={images}
index={imageIndex}
isAltExpanded={isAltExpanded}
toggleAltExpanded={() => 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<View>
openProgress: SharedValue<number>
dismissSwipeTranslateY: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
imageIndex: number
}) {
const [fetchedDims, setFetchedDims] = useState<Dimensions | null>(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,