From 0446f67e0b1bd58c4fa48e881d62042e245d633e Mon Sep 17 00:00:00 2001 From: andriicallstack Date: Tue, 15 Sep 2026 09:06:48 +0200 Subject: [PATCH 1/4] feat: add Carousel component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Material 3 Expressive Carousel with all four layouts — multi-browse, hero (start- and centre-aligned), uncontained and full-screen. Items are never resized. Every item is measured at the large size for the whole of its life; the apparent size change is an interpolated mask rectangle plus a translation, with items stacked in descending order so a collapsing item passes under the one before it. The corner radius sits on the mask rather than on the item box, so it travels with the visible rectangle. Layout runs on an unmasked axis where every item occupies one item size, and a keyline list maps a position on that axis onto a drawn centre and a masked size. Near either end the keylines blend into permuted states that put the focal range hard against the start or the end, so the first and last items can be focal. Multi-browse and hero resolve their arrangements through a cost-minimising solver; uncontained and full-screen need none. Each layout gets its own fling behaviour: single-advance for hero and full-screen, decay-plus-spring across several items for multi-browse, and no snap at all for uncontained. Where it snaps, the target is the focal keyline offset — a whole number of items on the unmasked axis — not the container edge. renderItem receives the item's live mask as shared values, the equivalent of OnMaskChangedListener and carouselItemDrawInfo.maskRect in the reference implementations. CarouselItemContent pins to the mask's leading edge and fades as the item collapses. Scroll-driven mask interpolation runs on the UI thread through Reanimated shared values; the state layer, focus ring, disabled opacity and hover elevation are not scroll-driven and run as CSS transitions. --- docs/component-docs.config.ts | 11 + docs/src/data/themeColors.ts | 10 + example/src/ExampleList.tsx | 2 + example/src/Examples/CarouselExample.tsx | 242 +++++++++ src/components/Carousel/Carousel.tsx | 343 ++++++++++++ src/components/Carousel/CarouselItem.tsx | 115 ++++ src/components/Carousel/CarouselItemShell.tsx | 314 +++++++++++ src/components/Carousel/index.ts | 16 + src/components/Carousel/strategy.ts | 495 ++++++++++++++++++ src/components/Carousel/tokens.ts | 76 +++ src/components/Carousel/types.ts | 68 +++ src/components/Carousel/utils.ts | 157 ++++++ src/components/__tests__/Carousel.test.tsx | 408 +++++++++++++++ src/index.tsx | 16 + 14 files changed, 2273 insertions(+) create mode 100644 example/src/Examples/CarouselExample.tsx create mode 100644 src/components/Carousel/Carousel.tsx create mode 100644 src/components/Carousel/CarouselItem.tsx create mode 100644 src/components/Carousel/CarouselItemShell.tsx create mode 100644 src/components/Carousel/index.ts create mode 100644 src/components/Carousel/strategy.ts create mode 100644 src/components/Carousel/tokens.ts create mode 100644 src/components/Carousel/types.ts create mode 100644 src/components/Carousel/utils.ts create mode 100644 src/components/__tests__/Carousel.test.tsx diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 4196e8e7bd..e744d59b80 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -60,6 +60,17 @@ const pages = { CardCover: 'Card/CardCover', CardTitle: 'Card/CardTitle', }, + Carousel: { + Carousel: 'Carousel/Carousel', + CarouselItem: { + source: 'Carousel/CarouselItem', + component: 'CarouselItem', + }, + CarouselItemContent: { + source: 'Carousel/CarouselItem', + component: 'CarouselItemContent', + }, + }, Checkbox: { Checkbox: 'Checkbox/Checkbox', CheckboxItem: 'Checkbox/CheckboxItem', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 118114da27..0b4bcdd68e 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -101,6 +101,16 @@ export const themeColors = { borderColor: 'theme.colors.outline', }, }, + Carousel: { + '-': { + backgroundColor: 'theme.colors.surfaceContainerHigh', + focusIndicatorColor: 'theme.colors.secondary', + stateLayerColor: 'theme.colors.onSurface', + }, + outlined: { + borderColor: 'theme.colors.outlineVariant', + }, + }, Dialog: { '-': { backgroundColor: 'theme.colors.elevation.level3', diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 8a2646d798..b79c848928 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -13,6 +13,7 @@ import BottomNavigationBarExample from './Examples/BottomNavigationBarExample'; import BottomNavigationExample from './Examples/BottomNavigationExample'; import ButtonExample from './Examples/ButtonExample'; import CardExample from './Examples/CardExample'; +import CarouselExample from './Examples/CarouselExample'; import CheckboxExample from './Examples/CheckboxExample'; import CheckboxItemExample from './Examples/CheckboxItemExample'; import ChipExample from './Examples/ChipExample'; @@ -58,6 +59,7 @@ export const mainExamples = { BottomNavigation: BottomNavigationExample, Button: ButtonExample, Card: CardExample, + Carousel: CarouselExample, Checkbox: CheckboxExample, CheckboxItem: CheckboxItemExample, Chip: ChipExample, diff --git a/example/src/Examples/CarouselExample.tsx b/example/src/Examples/CarouselExample.tsx new file mode 100644 index 0000000000..2920486936 --- /dev/null +++ b/example/src/Examples/CarouselExample.tsx @@ -0,0 +1,242 @@ +import * as React from 'react'; +import { Image, StyleSheet, View } from 'react-native'; + +import { + Carousel, + CarouselItem, + CarouselItemContent, + Switch, + Text, + useTheme, + type CarouselLayout, +} from 'react-native-paper'; + +import ScreenWrapper from '../ScreenWrapper'; + +const photos = [ + { + id: 'beach', + title: 'Beach', + source: require('../../assets/images/beach.jpg'), + }, + { + id: 'bridge', + title: 'Bridge', + source: require('../../assets/images/bridge.jpg'), + }, + { + id: 'city', + title: 'City', + source: require('../../assets/images/city.jpg'), + }, + { + id: 'forest', + title: 'Forest', + source: require('../../assets/images/forest.jpg'), + }, + { + id: 'chameleon', + title: 'Chameleon', + source: require('../../assets/images/chameleon.jpg'), + }, + { + id: 'strawberries', + title: 'Strawberries', + source: require('../../assets/images/strawberries.jpg'), + }, +]; + +const Section = ({ + title, + caption, + children, +}: { + title: string; + caption?: string; + children: React.ReactNode; +}) => ( + + {title} + {caption ? ( + + {caption} + + ) : null} + {children} + +); + +type PhotoCarouselProps = { + layout: CarouselLayout; + alignment?: 'start' | 'center'; + itemWidth?: number; + height?: number; + outlined?: boolean; + disabled?: boolean; + onIndexChange?: (index: number) => void; +}; + +const PhotoCarousel = ({ + layout, + alignment, + itemWidth = 220, + height = 200, + outlined, + disabled, + onIndexChange, +}: PhotoCarouselProps) => { + const theme = useTheme(); + + return ( + photo.id} + aria-label="Photos" + renderItem={({ item, mask }) => ( + + + {/* Pinned to the mask's leading edge, so it slides with the visible + rectangle and fades out as the item collapses. */} + + + {item.title} + + + + )} + /> + ); +}; + +const CarouselExample = () => { + const [focused, setFocused] = React.useState(0); + const [outlined, setOutlined] = React.useState(false); + const [disabled, setDisabled] = React.useState(false); + + return ( + + + Items are never resized. What changes as an item moves through the + keylines is its mask rectangle and a translation — every item stays + measured at the large size throughout, which is why the photos never + reflow. + + +
+ +
+ + Focused item: {photos[focused]?.title} + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + Outlined + + + + Disabled + + +
+ ); +}; + +CarouselExample.title = 'Carousel'; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 16, + gap: 8, + }, + section: { + gap: 4, + paddingTop: 16, + }, + caption: { + opacity: 0.7, + paddingHorizontal: 16, + }, + carousel: { + paddingHorizontal: 16, + }, + image: { + width: '100%', + height: '100%', + }, + label: { + padding: 16, + }, + labelPill: { + paddingHorizontal: 12, + paddingVertical: 6, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 8, + }, +}); + +export default CarouselExample; diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx new file mode 100644 index 0000000000..5114e720bd --- /dev/null +++ b/src/components/Carousel/Carousel.tsx @@ -0,0 +1,343 @@ +import * as React from 'react'; +import { + StyleSheet, + View, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type ScrollView, + type StyleProp, + type ViewStyle, +} from 'react-native'; + +import Animated, { + runOnJS, + useAnimatedReaction, + useAnimatedScrollHandler, + useSharedValue, +} from 'react-native-reanimated'; + +import CarouselItemShell from './CarouselItemShell'; +import { createStrategy } from './strategy'; +import type { + CarouselAlignment, + CarouselLayout, + CarouselRenderItemInfo, + CarouselSnap, +} from './types'; +import { + getDefaultCarouselColors, + getSnapScrollProps, + visibleSlotCount, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../theme/types'; + +/** Items kept mounted either side of the on-screen ones. */ +const OVERSCAN = 2; + +export type CarouselHandle = { + /** Scrolls until `index` sits on the first focal keyline. */ + scrollToIndex: (index: number, animated?: boolean) => void; +}; + +export type Props = { + /** + * Items to render. + */ + data: readonly ItemT[]; + /** + * Renders one item. Receives the item's live mask, which the content is + * expected to react to — see `CarouselItem` for the default behaviour. + */ + renderItem: (info: CarouselRenderItemInfo) => React.ReactNode; + /** + * Height of the carousel. Every item is laid out at this height. + */ + height: number; + /** + * Arrangement of the items. Defaults to `multi-browse`. + * + * Reach for `uncontained` when item aspect ratios have to be preserved: it + * hands items the width you asked for and does not snap. + */ + layout?: CarouselLayout; + /** + * Where a `hero` layout puts its large item. `center` falls back to `start` + * below three items. Ignored by the other layouts. + */ + alignment?: CarouselAlignment; + /** + * Overrides the layout's fling behaviour. + */ + snap?: CarouselSnap; + /** + * Width to lay large items out at, for `multi-browse` and `uncontained`. + * Defaults to a square item. `hero` derives its own width from the container + * and `full-screen` always fills it. + */ + itemWidth?: number; + /** + * Gap between items. + */ + itemSpacing?: number; + /** + * Draws each item with a one-pixel outline. + */ + outlined?: boolean; + /** + * Disables scrolling and renders the disabled visual state. + */ + disabled?: boolean; + /** + * Item to rest on when the carousel is first laid out. + */ + initialIndex?: number; + /** + * Called with the focal item's index once scrolling settles. + */ + onIndexChange?: (index: number) => void; + /** + * Called when an item is pressed. Items are only hoverable, focusable and + * pressable when this is set. + */ + onItemPress?: (item: ItemT, index: number) => void; + keyExtractor?: (item: ItemT, index: number) => string; + style?: StyleProp; + contentContainerStyle?: StyleProp; + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; + ref?: React.Ref; + /** + * Accessibility label for the carousel. + */ + 'aria-label'?: string; +}; + +/** + * Carousels show a scrolling strip of items — images, most often — that grow + * and shrink as they pass through the container. + * + * Items are never resized. Every item is measured at the large size for the + * whole of its life; the size change you see is an interpolated mask rectangle + * plus a translation, with items stacked in descending order so a collapsing + * item passes under the one before it. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Image } from 'react-native'; + * import { Carousel, CarouselItem } from 'react-native-paper'; + * + * const photos = [{ id: '1', uri: '…' }, { id: '2', uri: '…' }]; + * + * const MyComponent = () => ( + * photo.id} + * renderItem={({ item, mask }) => ( + * + * + * + * )} + * /> + * ); + * + * export default MyComponent; + * ``` + * + * ## Theming + * Customize by overriding these `theme.colors` roles: + * - `surfaceContainerHigh`: item container, behind the content + * - `outlineVariant`: the `outlined` item border + * - `onSurface`: hover, focus and press state layers + * - `secondary`: focus indicator + * + * The item corner radius comes from `theme.shapes.corner.extraLarge`. + * + * Note: layout is left-to-right. Right-to-left locales are not handled yet. + */ +const Carousel = ({ + data, + renderItem, + height, + layout = 'multi-browse', + alignment = 'start', + snap, + itemWidth, + itemSpacing = 0, + outlined = false, + disabled = false, + initialIndex = 0, + onIndexChange, + onItemPress, + keyExtractor, + style, + contentContainerStyle, + testID, + theme: themeOverrides, + ref, + 'aria-label': ariaLabel, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const scrollRef = React.useRef(null); + const scrollX = useSharedValue(0); + + const [containerWidth, setContainerWidth] = React.useState(0); + const [windowStart, setWindowStart] = React.useState(initialIndex); + + const itemCount = data.length; + const strategy = React.useMemo( + () => + createStrategy({ + layout, + alignment, + containerSize: containerWidth, + containerHeight: height, + preferredItemSize: itemWidth ?? height, + itemCount, + snap, + }), + [layout, alignment, containerWidth, height, itemWidth, itemCount, snap] + ); + + const colors = React.useMemo(() => getDefaultCarouselColors(theme), [theme]); + + const scrollHandler = useAnimatedScrollHandler((event) => { + scrollX.value = event.contentOffset.x; + }); + + const itemSize = strategy?.itemSize ?? 0; + + useAnimatedReaction( + () => (itemSize > 0 ? Math.floor(scrollX.value / itemSize) : 0), + (current, previous) => { + if (current !== previous) { + runOnJS(setWindowStart)(current); + } + }, + [itemSize] + ); + + const scrollToIndex = React.useCallback( + (index: number, animated = true) => { + if (!strategy) return; + const clamped = Math.min(Math.max(index, 0), Math.max(itemCount - 1, 0)); + scrollRef.current?.scrollTo({ + x: Math.min(clamped * strategy.itemSize, strategy.maxScroll), + animated, + }); + }, + [strategy, itemCount] + ); + + React.useImperativeHandle(ref, () => ({ scrollToIndex }), [scrollToIndex]); + + // Jump to the requested item as soon as the container has been measured. + const appliedInitialIndex = React.useRef(false); + React.useEffect(() => { + if (!strategy || appliedInitialIndex.current || initialIndex <= 0) return; + appliedInitialIndex.current = true; + scrollToIndex(initialIndex, false); + }, [strategy, initialIndex, scrollToIndex]); + + const lastReportedIndex = React.useRef(initialIndex); + const handleScrollSettled = ( + event: NativeSyntheticEvent + ) => { + if (!strategy || !onIndexChange) return; + const index = Math.min( + Math.max( + Math.round(event.nativeEvent.contentOffset.x / strategy.itemSize), + 0 + ), + Math.max(itemCount - 1, 0) + ); + if (index !== lastReportedIndex.current) { + lastReportedIndex.current = index; + onIndexChange(index); + } + }; + + const handleLayout = (event: LayoutChangeEvent) => { + setContainerWidth(event.nativeEvent.layout.width); + }; + + const snapProps = React.useMemo( + () => (strategy ? getSnapScrollProps(strategy) : {}), + [strategy] + ); + + const visible = React.useMemo(() => { + if (!strategy) return { from: 0, to: 0 }; + const slots = visibleSlotCount(strategy); + return { + from: Math.max(windowStart - OVERSCAN, 0), + to: Math.min(windowStart + slots + OVERSCAN, itemCount), + }; + }, [strategy, windowStart, itemCount]); + + return ( + + {strategy ? ( + + {data.slice(visible.from, visible.to).map((item, offset) => { + const index = visible.from + offset; + return ( + + ); + })} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, +}); + +export default Carousel; diff --git a/src/components/Carousel/CarouselItem.tsx b/src/components/Carousel/CarouselItem.tsx new file mode 100644 index 0000000000..bff99719d1 --- /dev/null +++ b/src/components/Carousel/CarouselItem.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; + +import Animated, { + interpolate, + useAnimatedStyle, + type AnimatedStyle, +} from 'react-native-reanimated'; + +import type { CarouselItemMask } from './types'; +import { useLocale } from '../../core/locale'; + +/** Below this much of its full width, an item's content has faded out entirely. */ +const FADE_OUT_EXPANSION = 0.5; + +export type Props = { + /** + * The item's live mask, from `renderItem`. + */ + mask: CarouselItemMask; + children?: React.ReactNode; + style?: StyleProp>; + testID?: string; +}; + +/** + * An item's full-size box. + * + * Content placed here is laid out at the item's unmasked size and clipped by + * the mask, which is what keeps media from reflowing as the item moves through + * the keylines. Content that should follow the mask instead — a label, most + * often — goes in a `CarouselItemContent` inside this box. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Image } from 'react-native'; + * import { Carousel, CarouselItem, CarouselItemContent, Text } from 'react-native-paper'; + * + * const MyComponent = () => ( + * ( + * + * + * + * {item.title} + * + * + * )} + * /> + * ); + * + * export default MyComponent; + * ``` + */ +export const CarouselItem = ({ mask, children, style, testID }: Props) => ( + + {children} + +); + +/** + * Content that tracks the mask rather than the item. + * + * It pins to the mask's leading edge and fades out as the item collapses, + * which is the behaviour the spec asks of an item's text. Both reference + * implementations expose the live mask for exactly this — `OnMaskChangedListener` + * on Android, `carouselItemDrawInfo.maskRect` in Compose. + */ +export const CarouselItemContent = ({ + mask, + children, + style, + testID, +}: Props) => { + const { direction } = useLocale(); + const { width } = mask; + + const animatedStyle = useAnimatedStyle(() => { + const { left, right } = mask.rect.value; + return { + transform: [{ translateX: direction === 'rtl' ? right - width : left }], + opacity: interpolate( + mask.expansion.value, + [FADE_OUT_EXPANSION, 1], + [0, 1], + 'clamp' + ), + }; + }, [direction, width]); + + return ( + + {children} + + ); +}; + +const styles = StyleSheet.create({ + content: { + position: 'absolute', + left: 0, + bottom: 0, + }, +}); + +export default CarouselItem; diff --git a/src/components/Carousel/CarouselItemShell.tsx b/src/components/Carousel/CarouselItemShell.tsx new file mode 100644 index 0000000000..066eb356d1 --- /dev/null +++ b/src/components/Carousel/CarouselItemShell.tsx @@ -0,0 +1,314 @@ +import * as React from 'react'; +import { Platform, Pressable, StyleSheet, View } from 'react-native'; +import type { ColorValue, ViewStyle } from 'react-native'; + +import Animated, { + cubicBezier, + useAnimatedStyle, + useDerivedValue, + type AnimatedStyle, + type SharedValue, +} from 'react-native-reanimated'; + +import type { CarouselStrategy } from './strategy'; +import { CarouselTokens } from './tokens'; +import type { CarouselMaskRect, CarouselRenderItemInfo } from './types'; +import { resolveItemPlacement, type CarouselColors } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { tokens } from '../../theme/tokens'; +import type { InternalTheme } from '../../theme/types'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import Surface from '../Surface'; + +const { opacity: stateOpacity, focusIndicator } = tokens.md.sys.state; +const FOCUS_INSET = focusIndicator.thickness + focusIndicator.outerOffset; + +export type CarouselItemShellProps = { + item: ItemT; + index: number; + itemCount: number; + renderItem: (info: CarouselRenderItemInfo) => React.ReactNode; + strategy: CarouselStrategy; + scrollX: SharedValue; + height: number; + spacing: number; + colors: CarouselColors; + outlined: boolean; + disabled: boolean; + onPress?: (item: ItemT, index: number) => void; + theme: InternalTheme; + testID?: string; +}; + +/** + * One item's box, mask and interaction states. + * + * The box is always the full unmasked item size; what changes as the item + * moves through the keylines is the clipping wrapper inside it and a + * translation. The corner radius lives on that wrapper — the mask — rather + * than on the box, so the rounded corners travel with the visible rectangle + * instead of with the item. + */ +function CarouselItemShell({ + item, + index, + itemCount, + renderItem, + strategy, + scrollX, + height, + spacing, + colors, + outlined, + disabled, + onPress, + theme: themeOverride, + testID, +}: CarouselItemShellProps) { + const theme = useInternalTheme(themeOverride); + const [hovered, setHovered] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + const [focused, setFocused] = React.useState(false); + + const { itemSize } = strategy; + const contentWidth = Math.max(itemSize - spacing, 0); + const borderRadius = theme.shapes.corner[CarouselTokens.containerShape]; + + const placement = useDerivedValue( + () => resolveItemPlacement(strategy, scrollX.value, index), + [strategy, index] + ); + + // Item-local mask, measured from the visible item box rather than from the + // spacing-inflated slot, which is what `renderItem` wants to align against. + const maskRect = useDerivedValue(() => { + const left = (itemSize - placement.value.size) / 2; + const width = Math.max(placement.value.size - spacing, 0); + return { left, top: 0, right: left + width, bottom: height }; + }, [itemSize, spacing, height]); + + const expansion = useDerivedValue(() => { + if (contentWidth <= 0) { + return 0; + } + const width = maskRect.value.right - maskRect.value.left; + return Math.min(Math.max(width / contentWidth, 0), 1); + }, [contentWidth]); + + const boxStyle = useAnimatedStyle( + () => ({ + // The box is drawn in container coordinates but lives inside the scrolling + // content, so the scroll offset is added back in. + transform: [ + { translateX: scrollX.value + placement.value.center - itemSize / 2 }, + ], + }), + [itemSize] + ); + + const maskStyle = useAnimatedStyle(() => { + const { left, right } = maskRect.value; + return { + width: right - left, + transform: [{ translateX: left + spacing / 2 }], + }; + }, [spacing]); + + const contentStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: -maskRect.value.left }], + })); + + const focusRingStyle = useAnimatedStyle(() => { + const { left, right } = maskRect.value; + return { + width: right - left + FOCUS_INSET * 2, + transform: [{ translateX: left + spacing / 2 - FOCUS_INSET }], + }; + }, [spacing]); + + const transition = useTransition(theme); + + const interactive = onPress !== undefined && !disabled; + const stateLayerOpacity = !interactive + ? 0 + : pressed + ? stateOpacity.pressed + : focused + ? stateOpacity.focused + : hovered + ? stateOpacity.hovered + : 0; + const stateLayerColor: ColorValue = pressed + ? colors.pressedStateLayerColor + : focused + ? colors.focusStateLayerColor + : colors.hoverStateLayerColor; + const focusRingOpacity = { opacity: focused && interactive ? 1 : 0 }; + + const mask = React.useMemo( + () => ({ rect: maskRect, expansion, width: contentWidth, height }), + [maskRect, expansion, contentWidth, height] + ); + + const content = ( + + {renderItem({ item, index, mask })} + + ); + + return ( + + + + {content} + {interactive ? ( + onPress(item, index)} + onPressIn={() => setPressed(true)} + onPressOut={() => setPressed(false)} + onHoverIn={() => setHovered(true)} + onHoverOut={() => setHovered(false)} + onFocus={(event) => { + if (!isKeyboardFocusEvent(event)) return; + setFocused(true); + }} + onBlur={() => setFocused(false)} + role="button" + aria-disabled={disabled} + /> + ) : null} + + + + + + + ); +} + +/** + * Fades, colour changes and the elevation rise are not scroll-driven, so they + * run as CSS transitions rather than through the imperative tier. + */ +function useTransition(theme: InternalTheme): AnimatedStyle { + return React.useMemo( + () => ({ + transitionProperty: ['opacity', 'backgroundColor'], + transitionDuration: theme.motion.duration.short3 * theme.animation.scale, + transitionTimingFunction: cubicBezier(...theme.motion.easing.standard), + }), + [ + theme.motion.duration.short3, + theme.motion.easing.standard, + theme.animation.scale, + ] + ); +} + +const styles = StyleSheet.create({ + box: { + position: 'absolute', + left: 0, + top: 0, + }, + mask: { + position: 'absolute', + left: 0, + top: 0, + }, + clip: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + overflow: 'hidden', + }, + content: { + position: 'absolute', + left: 0, + top: 0, + }, + ignoreTouches: { + pointerEvents: 'none', + }, + stateLayer: { + pointerEvents: 'none', + }, + focusRing: { + position: 'absolute', + left: 0, + pointerEvents: 'none', + }, +}); + +// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + +export default CarouselItemShell; diff --git a/src/components/Carousel/index.ts b/src/components/Carousel/index.ts new file mode 100644 index 0000000000..b39f63fae9 --- /dev/null +++ b/src/components/Carousel/index.ts @@ -0,0 +1,16 @@ +export { default } from './Carousel'; +export type { Props, CarouselHandle } from './Carousel'; +export { + CarouselItem, + CarouselItemContent, + type Props as CarouselItemProps, +} from './CarouselItem'; +export { CarouselGeometry, CarouselTokens } from './tokens'; +export type { + CarouselAlignment, + CarouselItemMask, + CarouselLayout, + CarouselMaskRect, + CarouselRenderItemInfo, + CarouselSnap, +} from './types'; diff --git a/src/components/Carousel/strategy.ts b/src/components/Carousel/strategy.ts new file mode 100644 index 0000000000..43b6727612 --- /dev/null +++ b/src/components/Carousel/strategy.ts @@ -0,0 +1,495 @@ +import { CarouselGeometry } from './tokens'; +import type { CarouselAlignment, CarouselLayout, CarouselSnap } from './types'; + +const { + smallSizeMin: SMALL_MIN, + smallSizeMax: SMALL_MAX, + anchorSize: ANCHOR, + goneSize: GONE, + heroLargeMaxAspectRatio: HERO_MAX_ASPECT, + heroCentreAlignedMinItemCount: HERO_CENTRE_MIN_ITEMS, + heroLargeCountMax: HERO_LARGE_COUNT_MAX, + uncontainedMediumThreshold: UNCONTAINED_MEDIUM_THRESHOLD, +} = CarouselGeometry; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +/** + * One keyline state: the masked size an item takes at each stop across the + * container, and where that stop is drawn. + * + * Both arrays include the two off-container keylines at either end — an anchor + * and a gone keyline — so an item shrinks to a sliver at the edge rather than + * popping out of existence. + */ +export type KeylineState = { + /** Masked size at each keyline. */ + sizes: number[]; + /** Centre of each keyline, in container coordinates. */ + centers: number[]; + /** Index of the first focal (large) keyline. */ + focalStart: number; + /** Index of the last focal (large) keyline. */ + focalEnd: number; +}; + +/** + * Everything the render pass needs to place an item at a given scroll offset. + * + * Items are laid out on an *unmasked* axis where every item occupies + * `itemSize`, and the keylines map a position on that axis onto a drawn centre + * and a masked size. `startState` and `endState` are the same keylines + * permuted so the focal range sits hard against the start or the end of the + * container; the carousel blends into them over the first and last + * `startShift` / `endShift` of scroll so the first and last items can be + * focal. + */ +export type CarouselStrategy = { + /** The unmasked item size — what every item is measured at. */ + itemSize: number; + focalCount: number; + startState: KeylineState; + defaultState: KeylineState; + endState: KeylineState; + startShift: number; + endShift: number; + maxScroll: number; + contentSize: number; + snap: CarouselSnap; +}; + +/** + * Lays keylines out across the container. + * + * `sizes` are the in-container keylines and must sum to `containerSize`; the + * anchor and gone keylines are added outside either edge. + */ +function buildState( + sizes: number[], + focalStart: number, + focalEnd: number, + containerSize: number +): KeylineState { + const allSizes = [GONE, ANCHOR, ...sizes, ANCHOR, GONE]; + + const centers: number[] = [-(ANCHOR + GONE / 2), -ANCHOR / 2]; + let edge = 0; + for (const size of sizes) { + centers.push(edge + size / 2); + edge += size; + } + centers.push(containerSize + ANCHOR / 2); + centers.push(containerSize + ANCHOR + GONE / 2); + + return { + sizes: allSizes, + centers, + focalStart: focalStart + 2, + focalEnd: focalEnd + 2, + }; +} + +/** + * Builds the default, start-shifted and end-shifted keyline states from one + * in-container arrangement. + * + * The shifted states are permutations of the same sizes, so the three states + * always have the same keyline count and can be interpolated pairwise. Items + * move outward from the focal range in both directions, which is why the + * leading keylines are reversed when they are moved behind it. + */ +function buildStates( + sizes: number[], + focalStart: number, + focalEnd: number, + containerSize: number, + itemSize: number, + itemCount: number, + snap: CarouselSnap +): CarouselStrategy { + const before = sizes.slice(0, focalStart); + const focal = sizes.slice(focalStart, focalEnd + 1); + const after = sizes.slice(focalEnd + 1); + const outward = [...after, ...before.slice().reverse()]; + + const defaultState = buildState(sizes, focalStart, focalEnd, containerSize); + const startState = buildState( + [...focal, ...outward], + 0, + focal.length - 1, + containerSize + ); + const endState = buildState( + [...outward.slice().reverse(), ...focal], + outward.length, + outward.length + focal.length - 1, + containerSize + ); + + const focalCount = focal.length; + // Scroll is measured on the unmasked axis, so shifting the focal range by one + // keyline is worth exactly one item of scroll. + const startShift = + (defaultState.focalStart - startState.focalStart) * itemSize; + const endShift = (endState.focalStart - defaultState.focalStart) * itemSize; + const maxScroll = Math.max((itemCount - focalCount) * itemSize, 0); + + return { + itemSize, + focalCount, + startState, + defaultState, + endState, + startShift, + endShift, + maxScroll, + contentSize: maxScroll + containerSize, + snap, + }; +} + +type Targets = { + targetSmall: number; + targetMedium: number; + targetLarge: number; +}; + +type Arrangement = { + smallCount: number; + smallSize: number; + mediumCount: number; + mediumSize: number; + largeCount: number; + largeSize: number; + cost: number; +}; + +/** + * Fits one candidate arrangement to the container exactly. + * + * Small items flex first, inside their 40–56dp band; the large items then + * absorb whatever is left. Because a medium item is defined as the mean of a + * large and a small one, that second step has a closed form. + */ +function fitArrangement( + available: number, + { targetSmall, targetMedium, targetLarge }: Targets, + smallCount: number, + mediumCount: number, + largeCount: number, + priority: number +): Arrangement | null { + if (largeCount < 1) { + return null; + } + + const naturalSpace = + largeCount * targetLarge + + mediumCount * targetMedium + + smallCount * targetSmall; + const delta = available - naturalSpace; + + const smallSize = + smallCount > 0 + ? clamp(targetSmall + delta / smallCount, SMALL_MIN, SMALL_MAX) + : 0; + + const largeSize = + (available - smallSize * (smallCount + mediumCount / 2)) / + (largeCount + mediumCount / 2); + const mediumSize = mediumCount > 0 ? (largeSize + smallSize) / 2 : 0; + + if (largeSize <= 0) { + return null; + } + if (smallCount > 0 && largeSize < smallSize) { + return null; + } + if (mediumCount > 0 && mediumSize < smallSize) { + return null; + } + + return { + smallCount, + smallSize, + mediumCount, + mediumSize, + largeCount, + largeSize, + cost: Math.abs(targetLarge - largeSize) * priority, + }; +} + +/** + * Picks the arrangement whose large items land closest to the requested size, + * weighted by how far down the preference order the candidate sits. + */ +function findLowestCostArrangement( + available: number, + targets: Targets, + smallCounts: number[], + mediumCounts: number[], + largeCounts: number[], + itemCount: number +): Arrangement | null { + let best: Arrangement | null = null; + let priority = 1; + + for (const smallCount of smallCounts) { + for (const mediumCount of mediumCounts) { + for (const largeCount of largeCounts) { + const candidate = + smallCount + mediumCount + largeCount > itemCount + ? null + : fitArrangement( + available, + targets, + smallCount, + mediumCount, + largeCount, + priority + ); + priority += 1; + if (candidate && (best === null || candidate.cost < best.cost)) { + best = candidate; + } + } + } + } + + return best; +} + +function toSizes(arrangement: Arrangement): number[] { + return [ + ...new Array(arrangement.largeCount).fill(arrangement.largeSize), + ...new Array(arrangement.mediumCount).fill(arrangement.mediumSize), + ...new Array(arrangement.smallCount).fill(arrangement.smallSize), + ]; +} + +function descendingRange(from: number, to: number): number[] { + const values: number[] = []; + for (let value = from; value >= to; value--) { + values.push(value); + } + return values; +} + +function multiBrowseSizes( + available: number, + preferredItemSize: number, + itemCount: number +): { sizes: number[]; focalEnd: number } { + const targetLarge = Math.min(preferredItemSize, available); + const targetSmall = clamp(targetLarge / 3, SMALL_MIN, SMALL_MAX); + const targets: Targets = { + targetLarge, + targetSmall, + targetMedium: (targetLarge + targetSmall) / 2, + }; + + // Multi-browse always keeps a small item — it is what tells the reader the + // strip continues — so the only reason to drop it is a container too narrow + // to hold one. Letting the solver choose 0 smalls would win on large-size + // cost and quietly turn the layout into a plain pager. + const smallCounts = available < SMALL_MIN * 2 ? [0] : [1]; + const mediumCounts = [1, 0]; + const largeCounts = descendingRange( + Math.max(1, Math.floor(available / targetLarge)), + 1 + ); + + const arrangement = + findLowestCostArrangement( + available, + targets, + smallCounts, + mediumCounts, + largeCounts, + itemCount + ) ?? + // Nothing fit within the item count — fall back to filling the container + // with as many large items as there are items to put in it. + ({ + smallCount: 0, + smallSize: 0, + mediumCount: 0, + mediumSize: 0, + largeCount: Math.max(1, Math.min(itemCount, largeCounts[0])), + largeSize: available / Math.max(1, Math.min(itemCount, largeCounts[0])), + cost: 0, + } satisfies Arrangement); + + return { sizes: toSizes(arrangement), focalEnd: arrangement.largeCount - 1 }; +} + +function heroSizes( + available: number, + containerHeight: number, + itemCount: number, + alignment: CarouselAlignment +): { sizes: number[]; focalStart: number; focalEnd: number } { + const centred = alignment === 'center' && itemCount >= HERO_CENTRE_MIN_ITEMS; + const smallCount = Math.min(centred ? 2 : 1, Math.max(itemCount - 1, 0)); + + // A hero's small peeks saturate their band on any realistic container, but + // resolve them through the same large/3 target the other layouts use. + const provisionalLarge = Math.max( + available - smallCount * SMALL_MAX, + SMALL_MAX + ); + const smallSize = + smallCount > 0 ? clamp(provisionalLarge / 3, SMALL_MIN, SMALL_MAX) : 0; + + const spaceForLarge = available - smallCount * smallSize; + const maxLargeWidth = HERO_MAX_ASPECT * containerHeight; + const largeCount = clamp( + Math.ceil(spaceForLarge / Math.max(maxLargeWidth, 1)), + 1, + Math.min(HERO_LARGE_COUNT_MAX, Math.max(itemCount - smallCount, 1)) + ); + const largeSize = spaceForLarge / largeCount; + + const large = new Array(largeCount).fill(largeSize); + + if (smallCount === 2) { + return { + sizes: [smallSize, ...large, smallSize], + focalStart: 1, + focalEnd: largeCount, + }; + } + + return { + sizes: [...large, ...new Array(smallCount).fill(smallSize)], + focalStart: 0, + focalEnd: largeCount - 1, + }; +} + +function uncontainedSizes( + available: number, + preferredItemSize: number, + itemCount: number +): { sizes: number[]; focalEnd: number } { + const requested = Math.min(preferredItemSize, available); + const largeCount = Math.max( + 1, + Math.min(Math.floor(available / requested), itemCount) + ); + + let largeSize = requested; + let mediumSize = available - largeCount * largeSize; + + if (mediumSize / largeSize > UNCONTAINED_MEDIUM_THRESHOLD) { + // The leftover strip is close enough to a full item that it stops reading + // as a peek. Cap it and let the large items take up the slack — this is the + // one case where uncontained does not hand items their requested size. + mediumSize = largeSize * UNCONTAINED_MEDIUM_THRESHOLD; + largeSize = (available - mediumSize) / largeCount; + } + + if (mediumSize <= 0 || itemCount <= largeCount) { + // No cut-off item, so the large items have to fill the container on their own. + largeSize = available / largeCount; + return { + sizes: new Array(largeCount).fill(largeSize), + focalEnd: largeCount - 1, + }; + } + + return { + sizes: [...new Array(largeCount).fill(largeSize), mediumSize], + focalEnd: largeCount - 1, + }; +} + +export type StrategyOptions = { + layout: CarouselLayout; + alignment: CarouselAlignment; + /** Container width available to the carousel. */ + containerSize: number; + containerHeight: number; + /** Item size the caller asked for. Ignored by `full-screen`. */ + preferredItemSize: number; + itemCount: number; + snap?: CarouselSnap; +}; + +const DEFAULT_SNAP: Record = { + // Multi-browse lets momentum decay across several items before it settles. + 'multi-browse': 'multi', + // Hero and full-screen advance by exactly one item per fling. + hero: 'single', + 'full-screen': 'single', + // Uncontained does not snap at all; that is what preserves aspect ratios. + uncontained: 'none', +}; + +/** + * Resolves a layout into the keyline states the carousel scrolls through. + * + * Returns `null` until the container has been measured. + */ +export function createStrategy({ + layout, + alignment, + containerSize, + containerHeight, + preferredItemSize, + itemCount, + snap, +}: StrategyOptions): CarouselStrategy | null { + if (containerSize <= 0 || itemCount <= 0) { + return null; + } + + const resolvedSnap = snap ?? DEFAULT_SNAP[layout]; + + if (layout === 'full-screen') { + return buildStates( + [containerSize], + 0, + 0, + containerSize, + containerSize, + itemCount, + resolvedSnap + ); + } + + if (layout === 'hero') { + const { sizes, focalStart, focalEnd } = heroSizes( + containerSize, + containerHeight, + itemCount, + alignment + ); + return buildStates( + sizes, + focalStart, + focalEnd, + containerSize, + sizes[focalStart], + itemCount, + resolvedSnap + ); + } + + const { sizes, focalEnd } = + layout === 'uncontained' + ? uncontainedSizes(containerSize, preferredItemSize, itemCount) + : multiBrowseSizes(containerSize, preferredItemSize, itemCount); + + return buildStates( + sizes, + 0, + focalEnd, + containerSize, + sizes[0], + itemCount, + resolvedSnap + ); +} diff --git a/src/components/Carousel/tokens.ts b/src/components/Carousel/tokens.ts new file mode 100644 index 0000000000..220a653428 --- /dev/null +++ b/src/components/Carousel/tokens.ts @@ -0,0 +1,76 @@ +import type { Elevation } from '../../theme/types'; +import type { ColorRole } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +/** + * `md.comp.carousel-item.*` — the carousel's entire spec surface. + * + * There is no `md.comp.carousel.*` group, no plural and no `.expressive.` + * variant, so baseline and Expressive resolve to the same values. The table is + * colour / shape / state only: it carries no geometry at all, which is why + * every size lives in `CarouselGeometry` below and is sourced from the + * MDC-Android and Compose implementations instead. + */ +const shape = { + containerShape: 'extraLarge', +} as const satisfies Record; + +const colors = { + containerColor: 'surfaceContainerHigh', + labelTextColor: 'onSurface', + outlineColor: 'outlineVariant', + focusIndicatorColor: 'secondary', + hoverStateLayerColor: 'onSurface', + focusStateLayerColor: 'onSurface', + pressedStateLayerColor: 'onSurface', + draggedStateLayerColor: 'onSurface', + disabledContainerColor: 'onSurface', + disabledLabelTextColor: 'onSurface', +} as const satisfies Record; + +const elevations = { + containerElevation: 0, + hoverContainerElevation: 1, + focusContainerElevation: 0, + pressedContainerElevation: 0, + draggedContainerElevation: 0, +} as const satisfies Record; + +const dimensions = { + outlineWidth: 1, + disabledContainerOpacity: 0.38, + disabledLabelTextOpacity: 0.38, +} as const; + +export const CarouselTokens = { + ...shape, + ...colors, + ...elevations, + ...dimensions, +}; + +/** + * Geometry the token table does not define. Values come from the MDC-Android + * and Compose carousel implementations. + */ +export const CarouselGeometry = { + /** Smallest an item may be laid out at while still counted as a small item. */ + smallSizeMin: 40, + /** Largest a small item may grow to before it reads as a medium item. */ + smallSizeMax: 56, + /** Size an item shrinks to at the container edge, just before it goes. */ + anchorSize: 10, + /** Size of the off-screen keyline an item disappears into. */ + goneSize: 1, + /** A hero's large item is at most this many times its own height. */ + heroLargeMaxAspectRatio: 2, + /** Centre-aligned hero falls back to start-aligned below this many items. */ + heroCentreAlignedMinItemCount: 3, + /** Hero shows at most this many large items, however wide the container is. */ + heroLargeCountMax: 2, + /** + * Above this fraction of the large size, an uncontained carousel's trailing + * cut-off item reads as a second full item rather than a peek. + */ + uncontainedMediumThreshold: 0.85, +} as const; diff --git a/src/components/Carousel/types.ts b/src/components/Carousel/types.ts new file mode 100644 index 0000000000..24e5526a10 --- /dev/null +++ b/src/components/Carousel/types.ts @@ -0,0 +1,68 @@ +import type { SharedValue } from 'react-native-reanimated'; + +/** + * Which arrangement the carousel lays its items out in. + * + * - `multi-browse` shows one or two large items next to a medium and a small + * one, so several items are browsable at a glance. + * - `hero` puts the emphasis on a single large item with small items peeking + * at one or both sides. + * - `uncontained` keeps items at the size they were asked for and lets the + * last one run past the container edge. It is the layout to reach for when + * item aspect ratios have to be preserved. + * - `full-screen` shows one item filling the container. + */ +export type CarouselLayout = + | 'multi-browse' + | 'hero' + | 'uncontained' + | 'full-screen'; + +/** Where the focal (large) item sits. Only `hero` reads this. */ +export type CarouselAlignment = 'start' | 'center'; + +/** + * How a fling settles. + * + * - `single` advances by exactly one item per fling. + * - `multi` lets momentum decay across several items before springing to the + * nearest focal keyline. + * - `none` does not snap at all. + */ +export type CarouselSnap = 'single' | 'multi' | 'none'; + +/** + * The visible rectangle of an item, in item-local coordinates. + * + * Items are never resized — every item is measured at the large size for the + * whole of its life — so this rectangle, not the item's box, is what changes + * as the item moves through the keylines. + */ +export type CarouselMaskRect = { + left: number; + top: number; + right: number; + bottom: number; +}; + +/** + * Live mask state handed to `renderItem`. `rect` and `expansion` are shared + * values written on the UI thread, so reading them from a `useAnimatedStyle` + * costs no re-renders. + */ +export type CarouselItemMask = { + /** The item's visible rectangle, in item-local coordinates. */ + rect: SharedValue; + /** `0` when the item is fully collapsed, `1` when it is at full size. */ + expansion: SharedValue; + /** Width every item is measured at, mask or no mask. */ + width: number; + /** Height every item is measured at. */ + height: number; +}; + +export type CarouselRenderItemInfo = { + item: ItemT; + index: number; + mask: CarouselItemMask; +}; diff --git a/src/components/Carousel/utils.ts b/src/components/Carousel/utils.ts new file mode 100644 index 0000000000..e6dad5f8de --- /dev/null +++ b/src/components/Carousel/utils.ts @@ -0,0 +1,157 @@ +import type { ColorValue } from 'react-native'; + +import type { CarouselStrategy, KeylineState } from './strategy'; +import { CarouselTokens } from './tokens'; +import type { InternalTheme } from '../../theme/types'; + +export type CarouselColors = { + containerColor: ColorValue; + outlineColor: ColorValue; + focusIndicatorColor: ColorValue; + hoverStateLayerColor: ColorValue; + focusStateLayerColor: ColorValue; + pressedStateLayerColor: ColorValue; + draggedStateLayerColor: ColorValue; +}; + +export function getDefaultCarouselColors(theme: InternalTheme): CarouselColors { + const t = CarouselTokens; + const c = theme.colors; + return { + containerColor: c[t.containerColor], + outlineColor: c[t.outlineColor], + focusIndicatorColor: c[t.focusIndicatorColor], + hoverStateLayerColor: c[t.hoverStateLayerColor], + focusStateLayerColor: c[t.focusStateLayerColor], + pressedStateLayerColor: c[t.pressedStateLayerColor], + draggedStateLayerColor: c[t.draggedStateLayerColor], + }; +} + +export type Placement = { + /** Where the item's centre is drawn, in container coordinates. */ + center: number; + /** The item's masked size. */ + size: number; +}; + +/** + * Reads a keyline state at a fractional keyline index. + * + * Indices outside the state clamp to its outermost keyline, which is the gone + * keyline — items that far out are off-screen anyway. + */ +function sampleKeylines(state: KeylineState, index: number): Placement { + 'worklet'; + const count = state.sizes.length; + const clamped = Math.min(Math.max(index, 0), count - 1); + const lower = Math.min(Math.floor(clamped), count - 2); + const t = clamped - lower; + + return { + center: + state.centers[lower] + + (state.centers[lower + 1] - state.centers[lower]) * t, + size: + state.sizes[lower] + (state.sizes[lower + 1] - state.sizes[lower]) * t, + }; +} + +/** + * Maps an item onto the keylines at the current scroll offset. + * + * Items live on an unmasked axis where item `i` is centred at + * `(i + 0.5) * itemSize`. Subtracting the scroll offset gives the position the + * keylines are read at, so scrolling to `i * itemSize` always puts item `i` on + * the first focal keyline — which is why the snap offsets are multiples of the + * item size rather than of the container width. + * + * Near either end the keylines blend into the shifted states, and the focal + * index is blended along with them so the mapping stays continuous. + */ +export function resolveItemPlacement( + strategy: CarouselStrategy, + scroll: number, + index: number +): Placement { + 'worklet'; + const { + itemSize, + startState, + defaultState, + endState, + startShift, + endShift, + maxScroll, + } = strategy; + + let from = defaultState; + let to = defaultState; + let t = 0; + + if (startShift > 0 && scroll < startShift) { + from = startState; + to = defaultState; + t = Math.min(Math.max(scroll / startShift, 0), 1); + } else if (endShift > 0 && scroll > maxScroll - endShift) { + from = defaultState; + to = endState; + t = Math.min(Math.max((scroll - (maxScroll - endShift)) / endShift, 0), 1); + } + + const focalStart = from.focalStart + (to.focalStart - from.focalStart) * t; + const position = (index + 0.5) * itemSize - scroll; + const keylineIndex = position / itemSize - 0.5 + focalStart; + + const a = sampleKeylines(from, keylineIndex); + const b = sampleKeylines(to, keylineIndex); + + return { + center: a.center + (b.center - a.center) * t, + size: a.size + (b.size - a.size) * t, + }; +} + +/** + * How many keylines a strategy has inside and around the container — the + * number of items that can be on screen at once, used to size the render + * window. + */ +export function visibleSlotCount(strategy: CarouselStrategy): number { + return strategy.defaultState.sizes.length; +} + +export type SnapScrollProps = { + snapToInterval?: number; + disableIntervalMomentum?: boolean; + decelerationRate: 'fast' | 'normal'; +}; + +/** + * Translates a strategy's fling behaviour into scroll view props. + * + * The snap target is the focal keyline offset rather than the container edge. + * On the unmasked axis that offset is a whole number of items, which is why a + * plain interval is enough to express it. + */ +export function getSnapScrollProps( + strategy: CarouselStrategy +): SnapScrollProps { + switch (strategy.snap) { + case 'single': + return { + snapToInterval: strategy.itemSize, + disableIntervalMomentum: true, + decelerationRate: 'fast', + }; + case 'multi': + // Momentum decays across several items, then settles on the nearest + // focal keyline. + return { + snapToInterval: strategy.itemSize, + decelerationRate: 'normal', + }; + default: + return { decelerationRate: 'normal' }; + } +} diff --git a/src/components/__tests__/Carousel.test.tsx b/src/components/__tests__/Carousel.test.tsx new file mode 100644 index 0000000000..e87fbe8e49 --- /dev/null +++ b/src/components/__tests__/Carousel.test.tsx @@ -0,0 +1,408 @@ +import { Text, View } from 'react-native'; + +import { describe, expect, it, jest } from '@jest/globals'; + +import { fireEvent, render, screen, userEvent } from '../../test-utils'; +import Carousel from '../Carousel/Carousel'; +import { CarouselItem, CarouselItemContent } from '../Carousel/CarouselItem'; +import { createStrategy, type CarouselStrategy } from '../Carousel/strategy'; +import { CarouselGeometry, CarouselTokens } from '../Carousel/tokens'; +import type { + CarouselItemMask, + CarouselLayout, + CarouselRenderItemInfo, +} from '../Carousel/types'; +import { getSnapScrollProps, resolveItemPlacement } from '../Carousel/utils'; + +const CONTAINER = 360; +const HEIGHT = 200; + +const strategyFor = ( + layout: CarouselLayout, + overrides: Partial[0]> = {} +): CarouselStrategy => { + const strategy = createStrategy({ + layout, + alignment: 'start', + containerSize: CONTAINER, + containerHeight: HEIGHT, + preferredItemSize: 220, + itemCount: 10, + ...overrides, + }); + if (!strategy) { + throw new Error(`no strategy for ${layout}`); + } + return strategy; +}; + +const inContainerSizes = (strategy: CarouselStrategy) => + // Drop the gone and anchor keylines at either end. + strategy.defaultState.sizes.slice(2, -2); + +describe('createStrategy', () => { + it('returns null until the container has been measured', () => { + const options = { + layout: 'multi-browse' as const, + alignment: 'start' as const, + containerHeight: HEIGHT, + preferredItemSize: 220, + }; + expect( + createStrategy({ ...options, containerSize: 0, itemCount: 10 }) + ).toBeNull(); + expect( + createStrategy({ ...options, containerSize: CONTAINER, itemCount: 0 }) + ).toBeNull(); + }); + + it.each([ + 'multi-browse', + 'hero', + 'uncontained', + 'full-screen', + ])('fills the container exactly for %s', (layout) => { + const sizes = inContainerSizes(strategyFor(layout)); + const total = sizes.reduce((sum, size) => sum + size, 0); + expect(total).toBeCloseTo(CONTAINER, 5); + }); + + it('keeps small items inside the 40–56dp band', () => { + const strategy = strategyFor('multi-browse'); + const sizes = inContainerSizes(strategy); + const smallest = sizes[sizes.length - 1]; + expect(smallest).toBeGreaterThanOrEqual(CarouselGeometry.smallSizeMin); + expect(smallest).toBeLessThanOrEqual(CarouselGeometry.smallSizeMax); + }); + + it('sizes the multi-browse medium item as the mean of large and small', () => { + const sizes = inContainerSizes(strategyFor('multi-browse')); + const [large, medium, small] = sizes; + expect(sizes).toHaveLength(3); + expect(medium).toBeCloseTo((large + small) / 2, 5); + }); + + it('lands multi-browse large items near the requested size', () => { + const strategy = strategyFor('multi-browse', { preferredItemSize: 220 }); + expect(strategy.itemSize).toBeGreaterThan(180); + expect(strategy.itemSize).toBeLessThan(260); + }); + + it('gives hero a single focal item with a peek beside it', () => { + const strategy = strategyFor('hero'); + expect(strategy.focalCount).toBe(1); + expect(inContainerSizes(strategy)).toHaveLength(2); + }); + + it('keeps a hero large item within twice its own height', () => { + const strategy = strategyFor('hero'); + expect(strategy.itemSize).toBeLessThanOrEqual( + CarouselGeometry.heroLargeMaxAspectRatio * HEIGHT + ); + }); + + it('centre-aligns hero with a peek on either side', () => { + const sizes = inContainerSizes( + strategyFor('hero', { alignment: 'center', itemCount: 5 }) + ); + expect(sizes).toHaveLength(3); + expect(sizes[0]).toBeCloseTo(sizes[2], 5); + expect(sizes[1]).toBeGreaterThan(sizes[0]); + }); + + it('falls back to start-aligned hero below three items', () => { + const sizes = inContainerSizes( + strategyFor('hero', { alignment: 'center', itemCount: 2 }) + ); + expect(sizes).toHaveLength(2); + expect(sizes[0]).toBeGreaterThan(sizes[1]); + }); + + it('hands uncontained items the width they asked for', () => { + const strategy = strategyFor('uncontained', { preferredItemSize: 160 }); + expect(strategy.itemSize).toBeCloseTo(160, 5); + }); + + it('caps an uncontained cut-off item below the medium threshold', () => { + // A 190dp item in a 360dp container leaves a 170dp strip — 89% of a full + // item, close enough that it would stop reading as a peek. + const strategy = strategyFor('uncontained', { preferredItemSize: 190 }); + const sizes = inContainerSizes(strategy); + expect(sizes).toHaveLength(2); + expect(sizes[1] / sizes[0]).toBeLessThanOrEqual( + CarouselGeometry.uncontainedMediumThreshold + 1e-6 + ); + }); + + it('gives full-screen one item filling the container', () => { + const strategy = strategyFor('full-screen'); + expect(strategy.itemSize).toBe(CONTAINER); + expect(strategy.focalCount).toBe(1); + }); + + it('picks a distinct fling behaviour per layout', () => { + expect(strategyFor('multi-browse').snap).toBe('multi'); + expect(strategyFor('hero').snap).toBe('single'); + expect(strategyFor('full-screen').snap).toBe('single'); + expect(strategyFor('uncontained').snap).toBe('none'); + }); + + it('honours an explicit snap override', () => { + expect(strategyFor('uncontained', { snap: 'single' }).snap).toBe('single'); + }); + + it('leaves room for the focal items at the end of the scroll', () => { + const strategy = strategyFor('multi-browse', { itemCount: 10 }); + expect(strategy.maxScroll).toBeCloseTo( + (10 - strategy.focalCount) * strategy.itemSize, + 5 + ); + expect(strategy.contentSize).toBeCloseTo(strategy.maxScroll + CONTAINER, 5); + }); + + it('does not scroll when every item is already focal', () => { + expect(strategyFor('full-screen', { itemCount: 1 }).maxScroll).toBe(0); + }); +}); + +describe('resolveItemPlacement', () => { + it('puts the first item on the focal keyline at rest', () => { + const strategy = strategyFor('multi-browse'); + const placement = resolveItemPlacement(strategy, 0, 0); + expect(placement.size).toBeCloseTo(strategy.itemSize, 5); + expect(placement.center).toBeCloseTo(strategy.itemSize / 2, 5); + }); + + it('puts the last item on the focal keyline at the end of the scroll', () => { + const strategy = strategyFor('multi-browse', { itemCount: 10 }); + const placement = resolveItemPlacement(strategy, strategy.maxScroll, 9); + expect(placement.size).toBeCloseTo(strategy.itemSize, 5); + }); + + it('snaps on whole items, so every snap target is a focal keyline', () => { + const strategy = strategyFor('multi-browse', { itemCount: 10 }); + for (let index = 0; index < 8; index++) { + const placement = resolveItemPlacement( + strategy, + index * strategy.itemSize, + index + ); + expect(placement.size).toBeCloseTo(strategy.itemSize, 5); + } + }); + + it('shrinks an item monotonically as it leaves the focal range', () => { + const strategy = strategyFor('multi-browse', { itemCount: 10 }); + const sizes = [0, 0.25, 0.5, 0.75, 1].map( + (step) => + resolveItemPlacement(strategy, (3 + step) * strategy.itemSize, 3).size + ); + for (let i = 1; i < sizes.length; i++) { + expect(sizes[i]).toBeLessThanOrEqual(sizes[i - 1] + 1e-6); + } + expect(sizes[sizes.length - 1]).toBeLessThan(sizes[0]); + }); + + it('collapses an off-screen item to the gone keyline', () => { + const strategy = strategyFor('multi-browse', { itemCount: 20 }); + const placement = resolveItemPlacement(strategy, 10 * strategy.itemSize, 0); + expect(placement.size).toBeCloseTo(CarouselGeometry.goneSize, 5); + }); + + it('stays continuous across the start and end shift boundaries', () => { + const strategy = strategyFor('hero', { + alignment: 'center', + itemCount: 10, + }); + const epsilon = 0.001; + for (const boundary of [ + strategy.startShift, + strategy.maxScroll - strategy.endShift, + ]) { + for (let index = 0; index < 4; index++) { + const before = resolveItemPlacement( + strategy, + boundary - epsilon, + index + ); + const after = resolveItemPlacement(strategy, boundary + epsilon, index); + expect(after.size).toBeCloseTo(before.size, 2); + expect(after.center).toBeCloseTo(before.center, 2); + } + } + }); + + it('never resizes the item box, only the mask', () => { + const strategy = strategyFor('multi-browse', { itemCount: 10 }); + const sizes = new Set(); + for (let scroll = 0; scroll < strategy.maxScroll; scroll += 17) { + sizes.add(resolveItemPlacement(strategy, scroll, 4).size); + } + // The mask varies while the box the item is laid out at does not. + expect(sizes.size).toBeGreaterThan(1); + expect(strategy.itemSize).toBeCloseTo(inContainerSizes(strategy)[0], 5); + }); +}); + +describe('Carousel', () => { + const data = ['a', 'b', 'c', 'd', 'e', 'f']; + + const layout = (width: number) => ({ + nativeEvent: { layout: { width, height: HEIGHT, x: 0, y: 0 } }, + }); + + const renderCarousel = async (props = {}) => { + await render( + ( + + + {item} + + + )} + {...props} + /> + ); + }; + + const measure = async (width = CONTAINER) => { + await fireEvent(screen.getByTestId('carousel'), 'layout', layout(width)); + }; + + it('renders nothing until it has been measured', async () => { + await renderCarousel(); + expect(screen.queryByTestId('carousel-scroll-view')).toBeNull(); + }); + + it('renders items once measured', async () => { + await renderCarousel(); + await measure(); + expect(screen.getByTestId('carousel-scroll-view')).toBeTruthy(); + expect(screen.getByText('a')).toBeTruthy(); + }); + + it('hands renderItem a live mask rectangle', async () => { + let captured: CarouselItemMask | undefined; + await render( + ) => { + captured ??= mask; + return ; + }} + /> + ); + await measure(); + + expect(captured?.height).toBe(HEIGHT); + expect(captured?.width).toBeGreaterThan(0); + expect(captured?.rect.value).toEqual( + expect.objectContaining({ top: 0, bottom: HEIGHT }) + ); + // At rest the first item is focal, so its mask covers the whole item. + expect(captured?.rect.value.left).toBeCloseTo(0, 5); + expect(captured?.expansion.value).toBeCloseTo(1, 5); + }); + + it('does not make items interactive without onItemPress', async () => { + await renderCarousel(); + await measure(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('calls onItemPress when an item is pressed', async () => { + const onItemPress = jest.fn(); + await renderCarousel({ onItemPress }); + await measure(); + + const user = userEvent.setup(); + const items = screen.getAllByRole('button'); + expect(items.length).toBeGreaterThan(0); + await user.press(items[0]); + expect(onItemPress).toHaveBeenCalledWith('a', 0); + }); + + it('does not respond to presses when disabled', async () => { + const onItemPress = jest.fn(); + await renderCarousel({ onItemPress, disabled: true }); + await measure(); + expect(screen.queryByRole('button')).toBeNull(); + expect(onItemPress).not.toHaveBeenCalled(); + }); + + it('only mounts the items that can be on screen', async () => { + await renderCarousel({ + data: Array.from({ length: 50 }, (_, i) => `${i}`), + }); + await measure(); + expect(screen.getByText('0')).toBeTruthy(); + expect(screen.queryByText('49')).toBeNull(); + }); + + it('reports the focal index once scrolling settles', async () => { + const onIndexChange = jest.fn(); + await renderCarousel({ onIndexChange }); + await measure(); + + const { itemSize } = strategyFor('multi-browse', { + itemCount: data.length, + }); + await fireEvent( + screen.getByTestId('carousel-scroll-view'), + 'momentumScrollEnd', + { nativeEvent: { contentOffset: { x: itemSize * 2, y: 0 } } } + ); + expect(onIndexChange).toHaveBeenCalledWith(2); + }); +}); + +describe('getSnapScrollProps', () => { + it('advances one item per fling for hero and full-screen', () => { + for (const layout of ['hero', 'full-screen'] as const) { + const strategy = strategyFor(layout); + const props = getSnapScrollProps(strategy); + expect(props.snapToInterval).toBeCloseTo(strategy.itemSize, 5); + expect(props.disableIntervalMomentum).toBe(true); + expect(props.decelerationRate).toBe('fast'); + } + }); + + it('lets momentum decay across items for multi-browse', () => { + const strategy = strategyFor('multi-browse'); + const props = getSnapScrollProps(strategy); + expect(props.snapToInterval).toBeCloseTo(strategy.itemSize, 5); + expect(props.disableIntervalMomentum).toBeUndefined(); + expect(props.decelerationRate).toBe('normal'); + }); + + it('does not snap at all for uncontained', () => { + const props = getSnapScrollProps(strategyFor('uncontained')); + expect(props.snapToInterval).toBeUndefined(); + expect(props.disableIntervalMomentum).toBeUndefined(); + }); +}); + +describe('CarouselTokens', () => { + it('takes its corner radius from the shape scale', () => { + expect(CarouselTokens.containerShape).toBe('extraLarge'); + }); + + it('is the only non-zero elevation in the set', () => { + expect(CarouselTokens.hoverContainerElevation).toBe(1); + expect(CarouselTokens.containerElevation).toBe(0); + expect(CarouselTokens.focusContainerElevation).toBe(0); + expect(CarouselTokens.pressedContainerElevation).toBe(0); + }); + + it('disables at the spec opacity', () => { + expect(CarouselTokens.disabledContainerOpacity).toBe(0.38); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index f46d8e22d8..7884806d34 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -27,6 +27,11 @@ export { default as Banner } from './components/Banner'; export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation'; export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; +export { + default as Carousel, + CarouselItem, + CarouselItemContent, +} from './components/Carousel'; export { default as Checkbox } from './components/Checkbox'; export { default as Chip } from './components/Chip/Chip'; export { default as DataTable } from './components/DataTable/DataTable'; @@ -75,6 +80,17 @@ export type { Props as CardActionsProps } from './components/Card/CardActions'; export type { Props as CardContentProps } from './components/Card/CardContent'; export type { Props as CardCoverProps } from './components/Card/CardCover'; export type { Props as CardTitleProps } from './components/Card/CardTitle'; +export type { + CarouselAlignment, + CarouselHandle, + CarouselItemMask, + CarouselItemProps, + CarouselLayout, + CarouselMaskRect, + CarouselRenderItemInfo, + CarouselSnap, + Props as CarouselProps, +} from './components/Carousel'; export type { Props as CheckboxProps } from './components/Checkbox/Checkbox'; export type { Props as CheckboxItemProps } from './components/Checkbox/CheckboxItem'; export type { Props as ChipProps } from './components/Chip/Chip'; From 2bc8cc666280c4056dc856bffc37d9a89797068b Mon Sep 17 00:00:00 2001 From: andriicallstack Date: Tue, 15 Sep 2026 09:25:03 +0200 Subject: [PATCH 2/4] refactor(carousel): drop tokens the component cannot consume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven of the tokens mirrored from `md.comp.carousel-item.*` had no consumer, which read as if the component themed things it does not. The `label-text.*` group goes: MDC draws an item's label itself, but in Paper the item's content — text included — is rendered by the caller, so there is nothing for a label colour to paint, and disabled dims the content as a whole rather than per-run. The `dragged.*` group goes too: a drag on an item scrolls the strip, and React Native hands the responder to the scroll view, which ends the item's press, so no dragged state is reachable. The focus and pressed elevations stay and are now read: elevation resolves from the interaction state rather than testing hover alone, so the fact that hover is the only state that lifts an item is expressed in the tokens instead of in a branch. A test walks the token table and fails on any entry the component never reads, so this cannot drift back. --- src/components/Carousel/CarouselItemShell.tsx | 15 +++++++++----- src/components/Carousel/tokens.ts | 20 +++++++++++++------ src/components/Carousel/utils.ts | 2 -- src/components/__tests__/Carousel.test.tsx | 16 +++++++++++++++ 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/components/Carousel/CarouselItemShell.tsx b/src/components/Carousel/CarouselItemShell.tsx index 066eb356d1..d9a907aa70 100644 --- a/src/components/Carousel/CarouselItemShell.tsx +++ b/src/components/Carousel/CarouselItemShell.tsx @@ -144,6 +144,15 @@ function CarouselItemShell({ ? colors.focusStateLayerColor : colors.hoverStateLayerColor; const focusRingOpacity = { opacity: focused && interactive ? 1 : 0 }; + const elevation = !interactive + ? CarouselTokens.containerElevation + : pressed + ? CarouselTokens.pressedContainerElevation + : focused + ? CarouselTokens.focusContainerElevation + : hovered + ? CarouselTokens.hoverContainerElevation + : CarouselTokens.containerElevation; const mask = React.useMemo( () => ({ rect: maskRect, expansion, width: contentWidth, height }), @@ -178,11 +187,7 @@ function CarouselItemShell({ ]} > ; +/** + * Two groups from the spec table have no consumer on this side and are + * deliberately not mirrored here: + * + * - `label-text.*`. MDC draws an item's label itself; in Paper the item's + * content — text included — is rendered by the caller, so there is nothing + * for a label colour to paint. `Text` already resolves to `onSurface`, the + * value the token carries, and the disabled state dims the content as a + * whole through `disabledContainerOpacity` rather than per-run. + * - `dragged.*`. A drag on a carousel item scrolls the strip, and React Native + * hands the responder to the scroll view, which ends the item's press. There + * is no interaction left for a dragged state to describe. + */ const colors = { containerColor: 'surfaceContainerHigh', - labelTextColor: 'onSurface', outlineColor: 'outlineVariant', focusIndicatorColor: 'secondary', hoverStateLayerColor: 'onSurface', focusStateLayerColor: 'onSurface', pressedStateLayerColor: 'onSurface', - draggedStateLayerColor: 'onSurface', - disabledContainerColor: 'onSurface', - disabledLabelTextColor: 'onSurface', } as const satisfies Record; +/** Hover is the only state that lifts an item off the surface. */ const elevations = { containerElevation: 0, hoverContainerElevation: 1, focusContainerElevation: 0, pressedContainerElevation: 0, - draggedContainerElevation: 0, } as const satisfies Record; const dimensions = { outlineWidth: 1, disabledContainerOpacity: 0.38, - disabledLabelTextOpacity: 0.38, } as const; export const CarouselTokens = { diff --git a/src/components/Carousel/utils.ts b/src/components/Carousel/utils.ts index e6dad5f8de..7c6042eed2 100644 --- a/src/components/Carousel/utils.ts +++ b/src/components/Carousel/utils.ts @@ -11,7 +11,6 @@ export type CarouselColors = { hoverStateLayerColor: ColorValue; focusStateLayerColor: ColorValue; pressedStateLayerColor: ColorValue; - draggedStateLayerColor: ColorValue; }; export function getDefaultCarouselColors(theme: InternalTheme): CarouselColors { @@ -24,7 +23,6 @@ export function getDefaultCarouselColors(theme: InternalTheme): CarouselColors { hoverStateLayerColor: c[t.hoverStateLayerColor], focusStateLayerColor: c[t.focusStateLayerColor], pressedStateLayerColor: c[t.pressedStateLayerColor], - draggedStateLayerColor: c[t.draggedStateLayerColor], }; } diff --git a/src/components/__tests__/Carousel.test.tsx b/src/components/__tests__/Carousel.test.tsx index e87fbe8e49..71947875d2 100644 --- a/src/components/__tests__/Carousel.test.tsx +++ b/src/components/__tests__/Carousel.test.tsx @@ -1,6 +1,8 @@ import { Text, View } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { fireEvent, render, screen, userEvent } from '../../test-utils'; import Carousel from '../Carousel/Carousel'; @@ -405,4 +407,18 @@ describe('CarouselTokens', () => { it('disables at the spec opacity', () => { expect(CarouselTokens.disabledContainerOpacity).toBe(0.38); }); + + it('declares no token the component does not consume', () => { + const source = [ + readFileSync( + join(__dirname, '../Carousel/CarouselItemShell.tsx'), + 'utf8' + ), + readFileSync(join(__dirname, '../Carousel/utils.ts'), 'utf8'), + ].join('\n'); + + for (const name of Object.keys(CarouselTokens)) { + expect(source).toContain(name); + } + }); }); From 999732c00cf8cf75b4d713cfd1f2c15e6cece079 Mon Sep 17 00:00:00 2001 From: andriicallstack Date: Tue, 15 Sep 2026 10:33:13 +0200 Subject: [PATCH 3/4] feat(carousel): settle flings on the M3 spring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settle was the scroll view's own deceleration curve: `snapToInterval` with `disableIntervalMomentum` for single-advance and plain momentum for multi-item. That behaved roughly right but was the platform's motion, not the spec's, and it left the carousel with no imperative tier at all. Native deceleration is now switched off wherever the carousel snaps, so the scroll view stops when the finger lifts and we own the settle: a shared value sprung with `withSpring(toRawSpring(motion.spring.default .spatial))`, written into the scroll view from the UI thread. A new touch cancels it, which is what makes the settle interruptible. Programmatic `scrollToIndex` moves settle on the same spring, and the spring honours reduce-motion. An uncontained carousel does not snap, so it keeps native momentum. Release velocity is measured from successive scroll offsets rather than read from the drag-end event: the event's units and sign vary between platforms, whereas the offsets do not. The decay projection and the snap target move into `resolveSettleOffset`, a pure worklet, so the three fling behaviours are unit-tested directly instead of inferred from scroll view props — single-advance clamps to one item from where the drag began however hard it is thrown, multi rides the projection across as many items as the fling earned, and both land on a focal keyline and never past either end. The example grows Previous / Next buttons that drive the carousel through its ref, showing the programmatic path uses the same spring. --- example/src/Examples/CarouselExample.tsx | 27 +++- src/components/Carousel/Carousel.tsx | 163 +++++++++++++++++---- src/components/Carousel/utils.ts | 86 ++++++++--- src/components/__tests__/Carousel.test.tsx | 105 ++++++++++--- 4 files changed, 308 insertions(+), 73 deletions(-) diff --git a/example/src/Examples/CarouselExample.tsx b/example/src/Examples/CarouselExample.tsx index 2920486936..ada42fc122 100644 --- a/example/src/Examples/CarouselExample.tsx +++ b/example/src/Examples/CarouselExample.tsx @@ -2,12 +2,14 @@ import * as React from 'react'; import { Image, StyleSheet, View } from 'react-native'; import { + Button, Carousel, CarouselItem, CarouselItemContent, Switch, Text, useTheme, + type CarouselHandle, type CarouselLayout, } from 'react-native-paper'; @@ -74,6 +76,7 @@ type PhotoCarouselProps = { outlined?: boolean; disabled?: boolean; onIndexChange?: (index: number) => void; + ref?: React.RefObject; }; const PhotoCarousel = ({ @@ -84,11 +87,13 @@ const PhotoCarousel = ({ outlined, disabled, onIndexChange, + ref, }: PhotoCarouselProps) => { const theme = useTheme(); return ( { + const carouselRef = React.useRef(null); const [focused, setFocused] = React.useState(0); const [outlined, setOutlined] = React.useState(false); const [disabled, setDisabled] = React.useState(false); @@ -147,11 +153,30 @@ const CarouselExample = () => { title="Multi-browse" caption="One or two large items, then a medium and a small one. Momentum decays across several items before it settles." > - + Focused item: {photos[focused]?.title} + {/* Programmatic moves settle on the same spring a fling does. */} + + + +
({ 'aria-label': ariaLabel, }: Props) => { const theme = useInternalTheme(themeOverrides); - const scrollRef = React.useRef(null); + const reduceMotion = useReduceMotion(); + const scrollRef = useAnimatedRef(); const scrollX = useSharedValue(0); + // Settle state. `settle` is the spring's output, written into the scroll view + // on the UI thread while `settling` is set; a new touch clears both, which is + // what makes the settle interruptible. + const settle = useSharedValue(0); + const settling = useSharedValue(false); + const dragStart = useSharedValue(0); + // Velocity is tracked here rather than read from the drag-end event: the + // event's units and sign differ between platforms, whereas successive offsets + // do not. + const velocity = useSharedValue(0); + const lastOffset = useSharedValue(0); + const lastTimestamp = useSharedValue(0); + const [containerWidth, setContainerWidth] = React.useState(0); const [windowStart, setWindowStart] = React.useState(initialIndex); @@ -208,12 +230,106 @@ const Carousel = ({ const colors = React.useMemo(() => getDefaultCarouselColors(theme), [theme]); - const scrollHandler = useAnimatedScrollHandler((event) => { - scrollX.value = event.contentOffset.x; - }); - const itemSize = strategy?.itemSize ?? 0; + const lastReportedIndex = React.useRef(initialIndex); + const reportIndex = React.useCallback( + (index: number) => { + if (!onIndexChange) return; + const clamped = Math.min(Math.max(index, 0), Math.max(itemCount - 1, 0)); + if (clamped === lastReportedIndex.current) return; + lastReportedIndex.current = clamped; + onIndexChange(clamped); + }, + [onIndexChange, itemCount] + ); + + // The settle is scroll-driven and interruptible, so it belongs in the + // imperative tier: a shared value sprung on the M3 spatial spring rather than + // the scroll view's own deceleration curve. + const springConfig = React.useMemo( + () => ({ + ...toRawSpring(theme.motion.spring.default.spatial), + reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, + }), + [theme.motion.spring.default.spatial, reduceMotion] + ); + + const springTo = React.useCallback( + (target: number) => { + 'worklet'; + settling.value = true; + settle.value = scrollX.value; + settle.value = withSpring(target, springConfig, (finished) => { + if (finished) { + settling.value = false; + if (itemSize > 0) { + runOnJS(reportIndex)(Math.round(target / itemSize)); + } + } + }); + }, + [springConfig, itemSize, reportIndex, settle, settling, scrollX] + ); + + // Drive the scroll view from the spring while it runs. + useAnimatedReaction( + () => (settling.value ? settle.value : null), + (offset) => { + if (offset !== null) { + scrollTo(scrollRef, offset, 0, false); + } + } + ); + + const scrollHandler = useAnimatedScrollHandler( + { + onScroll: (event) => { + const offset = event.contentOffset.x; + const now = performance.now(); + const elapsed = now - lastTimestamp.value; + // Ignore stale gaps; a resumed scroll would otherwise read as a fling. + if (elapsed > 0 && elapsed < 100) { + const sample = ((offset - lastOffset.value) / elapsed) * 1000; + velocity.value = velocity.value * 0.7 + sample * 0.3; + } + lastOffset.value = offset; + lastTimestamp.value = now; + scrollX.value = offset; + }, + onBeginDrag: (event) => { + // A new touch wins over an in-flight settle. + cancelAnimation(settle); + settling.value = false; + velocity.value = 0; + dragStart.value = event.contentOffset.x; + }, + onEndDrag: (event) => { + if (!strategy) return; + const target = resolveSettleOffset( + strategy, + event.contentOffset.x, + dragStart.value, + velocity.value + ); + if (target !== null) { + springTo(target); + } + }, + }, + [strategy, springTo] + ); + + // Only an uncontained carousel still decelerates natively; everywhere else + // the settle spring reports the index from its own completion. + const handleMomentumEnd = ( + event: NativeSyntheticEvent + ) => { + if (itemSize > 0) { + reportIndex(Math.round(event.nativeEvent.contentOffset.x / itemSize)); + } + }; + useAnimatedReaction( () => (itemSize > 0 ? Math.floor(scrollX.value / itemSize) : 0), (current, previous) => { @@ -228,12 +344,16 @@ const Carousel = ({ (index: number, animated = true) => { if (!strategy) return; const clamped = Math.min(Math.max(index, 0), Math.max(itemCount - 1, 0)); - scrollRef.current?.scrollTo({ - x: Math.min(clamped * strategy.itemSize, strategy.maxScroll), - animated, - }); + const target = Math.min(clamped * strategy.itemSize, strategy.maxScroll); + if (animated) { + // Programmatic moves settle on the same spring as a fling. + runOnUI(springTo)(target); + } else { + scrollRef.current?.scrollTo({ x: target, animated: false }); + reportIndex(clamped); + } }, - [strategy, itemCount] + [strategy, itemCount, springTo, scrollRef, reportIndex] ); React.useImperativeHandle(ref, () => ({ scrollToIndex }), [scrollToIndex]); @@ -246,24 +366,6 @@ const Carousel = ({ scrollToIndex(initialIndex, false); }, [strategy, initialIndex, scrollToIndex]); - const lastReportedIndex = React.useRef(initialIndex); - const handleScrollSettled = ( - event: NativeSyntheticEvent - ) => { - if (!strategy || !onIndexChange) return; - const index = Math.min( - Math.max( - Math.round(event.nativeEvent.contentOffset.x / strategy.itemSize), - 0 - ), - Math.max(itemCount - 1, 0) - ); - if (index !== lastReportedIndex.current) { - lastReportedIndex.current = index; - onIndexChange(index); - } - }; - const handleLayout = (event: LayoutChangeEvent) => { setContainerWidth(event.nativeEvent.layout.width); }; @@ -295,9 +397,8 @@ const Carousel = ({ showsHorizontalScrollIndicator={false} scrollEnabled={!disabled} onScroll={scrollHandler} + onMomentumScrollEnd={handleMomentumEnd} scrollEventThrottle={16} - onMomentumScrollEnd={handleScrollSettled} - onScrollEndDrag={handleScrollSettled} aria-label={ariaLabel} testID={testID ? `${testID}-scroll-view` : undefined} contentContainerStyle={[ diff --git a/src/components/Carousel/utils.ts b/src/components/Carousel/utils.ts index 7c6042eed2..c870592040 100644 --- a/src/components/Carousel/utils.ts +++ b/src/components/Carousel/utils.ts @@ -120,36 +120,76 @@ export function visibleSlotCount(strategy: CarouselStrategy): number { } export type SnapScrollProps = { - snapToInterval?: number; - disableIntervalMomentum?: boolean; - decelerationRate: 'fast' | 'normal'; + decelerationRate: 'normal' | number; }; /** - * Translates a strategy's fling behaviour into scroll view props. + * Hands the settle to the platform or to us. * - * The snap target is the focal keyline offset rather than the container edge. - * On the unmasked axis that offset is a whole number of items, which is why a - * plain interval is enough to express it. + * Where the carousel snaps, native deceleration is switched off entirely: the + * scroll view stops dead when the finger lifts and the decay-plus-spring in + * `resolveSettleOffset` takes over, so the settle runs on the M3 spring rather + * than on the platform's own curve. An uncontained carousel does not snap, so + * it keeps native momentum. */ export function getSnapScrollProps( strategy: CarouselStrategy ): SnapScrollProps { - switch (strategy.snap) { - case 'single': - return { - snapToInterval: strategy.itemSize, - disableIntervalMomentum: true, - decelerationRate: 'fast', - }; - case 'multi': - // Momentum decays across several items, then settles on the nearest - // focal keyline. - return { - snapToInterval: strategy.itemSize, - decelerationRate: 'normal', - }; - default: - return { decelerationRate: 'normal' }; + return { decelerationRate: strategy.snap === 'none' ? 'normal' : 0 }; +} + +/** + * How far a fling coasts, in seconds of its release velocity. + * + * This is the standard projection for a scroll view decelerating at 0.998 per + * millisecond: `v * rate / (1 - rate)`, which works out at roughly half a + * second of travel. + */ +const DECAY_PROJECTION = 0.5; + +/** A fling shorter than this fraction of an item is treated as a hold. */ +const SINGLE_ADVANCE_THRESHOLD = 0.15; + +/** + * Where a fling should come to rest. + * + * The three layouts settle differently, and the difference is entirely in this + * function — `single` advances by at most one item from wherever the drag + * started, `multi` lets the decay projection carry across as many items as the + * fling earned, and `none` does not settle at all. + * + * The target is always a focal keyline offset, never a container edge: on the + * unmasked axis those offsets are whole multiples of the item size. + * + * `velocity` is in points per second, positive towards the end of the list. + */ +export function resolveSettleOffset( + strategy: CarouselStrategy, + offset: number, + dragStartOffset: number, + velocity: number +): number | null { + 'worklet'; + const { snap, itemSize, maxScroll } = strategy; + if (snap === 'none' || itemSize <= 0) { + return null; } + + const projected = offset + velocity * DECAY_PROJECTION; + + if (snap === 'multi') { + const index = Math.round(projected / itemSize); + return Math.min(Math.max(index * itemSize, 0), maxScroll); + } + + const startIndex = Math.round(dragStartOffset / itemSize); + const travelled = (projected - dragStartOffset) / itemSize; + const step = + travelled > SINGLE_ADVANCE_THRESHOLD + ? 1 + : travelled < -SINGLE_ADVANCE_THRESHOLD + ? -1 + : 0; + + return Math.min(Math.max((startIndex + step) * itemSize, 0), maxScroll); } diff --git a/src/components/__tests__/Carousel.test.tsx b/src/components/__tests__/Carousel.test.tsx index 71947875d2..acd4e61a24 100644 --- a/src/components/__tests__/Carousel.test.tsx +++ b/src/components/__tests__/Carousel.test.tsx @@ -14,7 +14,11 @@ import type { CarouselLayout, CarouselRenderItemInfo, } from '../Carousel/types'; -import { getSnapScrollProps, resolveItemPlacement } from '../Carousel/utils'; +import { + getSnapScrollProps, + resolveItemPlacement, + resolveSettleOffset, +} from '../Carousel/utils'; const CONTAINER = 360; const HEIGHT = 200; @@ -367,28 +371,93 @@ describe('Carousel', () => { }); describe('getSnapScrollProps', () => { - it('advances one item per fling for hero and full-screen', () => { - for (const layout of ['hero', 'full-screen'] as const) { - const strategy = strategyFor(layout); - const props = getSnapScrollProps(strategy); - expect(props.snapToInterval).toBeCloseTo(strategy.itemSize, 5); - expect(props.disableIntervalMomentum).toBe(true); - expect(props.decelerationRate).toBe('fast'); + it('switches native deceleration off wherever the carousel snaps', () => { + for (const layout of ['hero', 'full-screen', 'multi-browse'] as const) { + expect(getSnapScrollProps(strategyFor(layout)).decelerationRate).toBe(0); } }); - it('lets momentum decay across items for multi-browse', () => { - const strategy = strategyFor('multi-browse'); - const props = getSnapScrollProps(strategy); - expect(props.snapToInterval).toBeCloseTo(strategy.itemSize, 5); - expect(props.disableIntervalMomentum).toBeUndefined(); - expect(props.decelerationRate).toBe('normal'); + it('leaves native momentum alone for uncontained', () => { + expect( + getSnapScrollProps(strategyFor('uncontained')).decelerationRate + ).toBe('normal'); + }); +}); + +describe('resolveSettleOffset', () => { + const at = (strategy: CarouselStrategy, index: number) => + index * strategy.itemSize; + + it('does not settle an uncontained carousel at all', () => { + const strategy = strategyFor('uncontained'); + expect( + resolveSettleOffset(strategy, at(strategy, 2) + 30, at(strategy, 2), 2000) + ).toBeNull(); + }); + + it('carries a multi-browse fling across several items', () => { + const strategy = strategyFor('multi-browse', { itemCount: 20 }); + // Half a second of travel at four items per second is two items. + const target = resolveSettleOffset(strategy, 0, 0, strategy.itemSize * 4); + expect(target).toBeCloseTo(at(strategy, 2), 5); + }); + + it('advances multi-browse backwards on a reverse fling', () => { + const strategy = strategyFor('multi-browse', { itemCount: 20 }); + const from = at(strategy, 6); + const target = resolveSettleOffset( + strategy, + from, + from, + -strategy.itemSize * 4 + ); + expect(target).toBeCloseTo(at(strategy, 4), 5); }); - it('does not snap at all for uncontained', () => { - const props = getSnapScrollProps(strategyFor('uncontained')); - expect(props.snapToInterval).toBeUndefined(); - expect(props.disableIntervalMomentum).toBeUndefined(); + it('advances hero by exactly one item however hard it is flung', () => { + const strategy = strategyFor('hero', { itemCount: 20 }); + const from = at(strategy, 3); + for (const velocity of [strategy.itemSize, strategy.itemSize * 50]) { + expect(resolveSettleOffset(strategy, from, from, velocity)).toBeCloseTo( + at(strategy, 4), + 5 + ); + } + expect( + resolveSettleOffset(strategy, from, from, -strategy.itemSize * 50) + ).toBeCloseTo(at(strategy, 2), 5); + }); + + it('returns a held hero item to where the drag started', () => { + const strategy = strategyFor('hero', { itemCount: 20 }); + const from = at(strategy, 3); + const target = resolveSettleOffset(strategy, from + 4, from, 0); + expect(target).toBeCloseTo(from, 5); + }); + + it('settles on focal keyline offsets, never between them', () => { + const strategy = strategyFor('multi-browse', { itemCount: 20 }); + for (let velocity = -4000; velocity <= 4000; velocity += 250) { + const target = resolveSettleOffset( + strategy, + at(strategy, 5), + at(strategy, 5), + velocity + ); + if (target === null) { + throw new Error('a snapping carousel must settle somewhere'); + } + const index = target / strategy.itemSize; + expect(Math.abs(index - Math.round(index))).toBeLessThan(1e-9); + } + }); + + it('never settles past either end of the list', () => { + const strategy = strategyFor('multi-browse', { itemCount: 8 }); + expect(resolveSettleOffset(strategy, 0, 0, -50000)).toBe(0); + expect(resolveSettleOffset(strategy, strategy.maxScroll, 0, 50000)).toBe( + strategy.maxScroll + ); }); }); From bfc2d3278a94c65fea4555d7bb4a3f491bcf0fd7 Mon Sep 17 00:00:00 2001 From: andriicallstack Date: Tue, 15 Sep 2026 11:13:59 +0200 Subject: [PATCH 4/4] refactor(carousel): collapse to a single testID `main` removed derived testIDs across the library, so the carousel should not arrive with new ones. `${testID}-scroll-view` and `${testID}-item-N` are gone; `testID` now lands on the scroll view, which is the element a caller actually addresses. That meant the scroll view could no longer be conditional on having been measured, since it carries the testID and the layout callback. It now always renders and the items wait for the arrangement instead, which also removes a layout jump on first paint. --- example/node_modules | 1 + node_modules | 1 + src/components/Carousel/Carousel.tsx | 40 +++++++++---------- src/components/Carousel/CarouselItemShell.tsx | 3 -- src/components/__tests__/Carousel.test.tsx | 16 ++++---- 5 files changed, 28 insertions(+), 33 deletions(-) create mode 120000 example/node_modules create mode 120000 node_modules diff --git a/example/node_modules b/example/node_modules new file mode 120000 index 0000000000..7a0956aab3 --- /dev/null +++ b/example/node_modules @@ -0,0 +1 @@ +/Users/andrii.doroshenko/react-native-paper/example/node_modules \ No newline at end of file diff --git a/node_modules b/node_modules new file mode 120000 index 0000000000..7552565393 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/andrii.doroshenko/react-native-paper/node_modules \ No newline at end of file diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx index 19b9098bfe..ae5038e3eb 100644 --- a/src/components/Carousel/Carousel.tsx +++ b/src/components/Carousel/Carousel.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { StyleSheet, - View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, @@ -385,28 +384,26 @@ const Carousel = ({ }, [strategy, windowStart, itemCount]); return ( - {strategy ? ( - + <> {data.slice(visible.from, visible.to).map((item, offset) => { const index = visible.from + offset; return ( @@ -425,13 +422,12 @@ const Carousel = ({ disabled={disabled} onPress={onItemPress} theme={theme} - testID={testID ? `${testID}-item-${index}` : undefined} /> ); })} - + ) : null} - + ); }; diff --git a/src/components/Carousel/CarouselItemShell.tsx b/src/components/Carousel/CarouselItemShell.tsx index d9a907aa70..0624d0cb1a 100644 --- a/src/components/Carousel/CarouselItemShell.tsx +++ b/src/components/Carousel/CarouselItemShell.tsx @@ -37,7 +37,6 @@ export type CarouselItemShellProps = { disabled: boolean; onPress?: (item: ItemT, index: number) => void; theme: InternalTheme; - testID?: string; }; /** @@ -63,7 +62,6 @@ function CarouselItemShell({ disabled, onPress, theme: themeOverride, - testID, }: CarouselItemShellProps) { const theme = useInternalTheme(themeOverride); const [hovered, setHovered] = React.useState(false); @@ -178,7 +176,6 @@ function CarouselItemShell({ return ( { await fireEvent(screen.getByTestId('carousel'), 'layout', layout(width)); }; - it('renders nothing until it has been measured', async () => { + it('renders no items until it has been measured', async () => { await renderCarousel(); - expect(screen.queryByTestId('carousel-scroll-view')).toBeNull(); + // The scroll view is always present; the arrangement needs a width first. + expect(screen.getByTestId('carousel')).toBeTruthy(); + expect(screen.queryByText('a')).toBeNull(); }); it('renders items once measured', async () => { await renderCarousel(); await measure(); - expect(screen.getByTestId('carousel-scroll-view')).toBeTruthy(); + expect(screen.getByTestId('carousel')).toBeTruthy(); expect(screen.getByText('a')).toBeTruthy(); }); @@ -361,11 +363,9 @@ describe('Carousel', () => { const { itemSize } = strategyFor('multi-browse', { itemCount: data.length, }); - await fireEvent( - screen.getByTestId('carousel-scroll-view'), - 'momentumScrollEnd', - { nativeEvent: { contentOffset: { x: itemSize * 2, y: 0 } } } - ); + await fireEvent(screen.getByTestId('carousel'), 'momentumScrollEnd', { + nativeEvent: { contentOffset: { x: itemSize * 2, y: 0 } }, + }); expect(onIndexChange).toHaveBeenCalledWith(2); }); });