From 2fd91592d52f7abfe0bd779ae6b20d650e9095da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 20:21:31 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01UChTFaCAyCiKDQV3HJeZYj --- src/components/images/Gallery/const.ts | 11 ++++ src/components/images/Gallery/index.tsx | 77 ++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/components/images/Gallery/const.ts b/src/components/images/Gallery/const.ts index 443b65ecb8..8b66fa1b50 100644 --- a/src/components/images/Gallery/const.ts +++ b/src/components/images/Gallery/const.ts @@ -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 diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index c87957503f..01cf121af6 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -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 | 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 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 ? ( - - + ) : null} {hasAlt || isCropped ? (