Hide gallery position badge while the carousel is in motion

The 1/n badge only describes where the carousel has settled, so fade it
out while the carousel is moving and back in once it comes to rest.
Motion is inferred from scroll events, which covers drags, momentum, and
the web settle/keyboard/wheel paths alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UChTFaCAyCiKDQV3HJeZYj
This commit is contained in:
Claude
2026-08-06 20:21:31 +00:00
parent 1c349ad7da
commit 2fd91592d5
2 changed files with 85 additions and 3 deletions
+11
View File
@@ -1,3 +1,14 @@
export const ITEM_GAP = 8 // tokens.space.sm
export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit
export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit
/**
* How long the carousel has to go without a scroll event before we consider it
* at rest. Long enough to bridge the gaps between scroll events on a dropped
* frame, short enough that the position badge feels responsive.
*/
export const REST_DELAY = 200
/** Fade duration for the position badge as the carousel starts and stops. */
export const REST_FADE_DURATION = 150
/** Resting opacity of the position badge, matching the ALT/crop badges. */
export const BADGE_OPACITY = 0.8
+74 -3
View File
@@ -11,7 +11,11 @@ import {
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
type AnimatedRef,
type SharedValue,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
@@ -28,9 +32,12 @@ import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {
BADGE_OPACITY,
ITEM_GAP,
MAX_ASPECT_RATIO,
MIN_ASPECT_RATIO,
REST_DELAY,
REST_FADE_DURATION,
} from '#/components/images/Gallery/const'
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
@@ -197,6 +204,47 @@ export function Gallery({
}
}
/*
* The position badge only makes sense once the carousel has settled on an
* image, so we hide it while the carousel is moving. Motion is inferred from
* scroll events rather than from drag state alone - that way momentum, the
* web settle/keyboard tweens, and trackpad scrolling all count as motion.
*/
const isScrolling = useSharedValue(false)
const isDraggingRef = useRef(false)
const restTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const scheduleRest = () => {
if (restTimeoutRef.current) {
clearTimeout(restTimeoutRef.current)
}
restTimeoutRef.current = setTimeout(() => {
restTimeoutRef.current = null
isScrolling.set(false)
}, REST_DELAY)
}
const onScrollActivity = () => {
isScrolling.set(true)
if (restTimeoutRef.current) {
clearTimeout(restTimeoutRef.current)
restTimeoutRef.current = null
}
/*
* A finger held still mid-drag stops producing scroll events, but the
* carousel isn't at rest yet - wait for the drag to end before scheduling.
*/
if (!isDraggingRef.current) {
scheduleRest()
}
}
useEffect(() => {
return () => {
if (restTimeoutRef.current) {
clearTimeout(restTimeoutRef.current)
}
}
}, [])
const scrollTo = (offset: number) => {
flatListRef.current?.scrollToOffset({offset, animated: false})
}
@@ -306,6 +354,7 @@ export function Gallery({
contentHeight={contentHeight}
index={index}
imageCount={images.length}
isScrolling={isScrolling}
onWidthChange={(i, w) => {
itemWidthsRef.current.set(i, w)
}}
@@ -328,7 +377,20 @@ export function Gallery({
/>
)
}}
onScrollBeginDrag={() => {
isDraggingRef.current = true
onScrollActivity()
}}
onScrollEndDrag={() => {
isDraggingRef.current = false
/*
* Momentum, if there is any, keeps firing scroll events and pushes
* this out.
*/
scheduleRest()
}}
onScroll={e => {
onScrollActivity()
// web handles via onSettle in the web hooks
if (IS_WEB) return
const offsetX = e.nativeEvent.contentOffset.x
@@ -394,6 +456,7 @@ function GalleryImage({
image,
index,
imageCount,
isScrolling,
onWidthChange,
itemRef,
largeAltBadge,
@@ -407,6 +470,8 @@ function GalleryImage({
image: AppBskyEmbedImages.ViewImage
index: number
imageCount: number
/** True while the carousel is moving, which hides the position badge. */
isScrolling: SharedValue<boolean>
onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void
largeAltBadge?: boolean
@@ -426,6 +491,12 @@ function GalleryImage({
const {isCropped, ...dims} = computeDims({height, aspectRatio})
const hasAlt = !!image.alt
const positionBadgeStyle = useAnimatedStyle(() => ({
opacity: withTiming(isScrolling.get() ? 0 : BADGE_OPACITY, {
duration: REST_FADE_DURATION,
}),
}))
useEffect(() => {
onWidthChange(index, dims.width)
}, [index, dims.width, onWidthChange])
@@ -506,7 +577,7 @@ function GalleryImage({
/>
{imageCount > 1 ? (
<View
<Animated.View
accessible={false}
pointerEvents="none"
style={[
@@ -518,11 +589,11 @@ function GalleryImage({
{
top: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
positionBadgeStyle,
]}>
<Text
style={[
@@ -535,7 +606,7 @@ function GalleryImage({
{index + 1}/{imageCount}
</Trans>
</Text>
</View>
</Animated.View>
) : null}
{hasAlt || isCropped ? (