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 ? (