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/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/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..ada42fc122 --- /dev/null +++ b/example/src/Examples/CarouselExample.tsx @@ -0,0 +1,267 @@ +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'; + +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; + ref?: React.RefObject; +}; + +const PhotoCarousel = ({ + layout, + alignment, + itemWidth = 220, + height = 200, + outlined, + disabled, + onIndexChange, + ref, +}: 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 carouselRef = React.useRef(null); + 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} + + {/* Programmatic moves settle on the same spring a fling does. */} + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + 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/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 new file mode 100644 index 0000000000..ae5038e3eb --- /dev/null +++ b/src/components/Carousel/Carousel.tsx @@ -0,0 +1,440 @@ +import * as React from 'react'; +import { + StyleSheet, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type StyleProp, + type ViewStyle, +} from 'react-native'; + +import Animated, { + cancelAnimation, + ReduceMotion, + runOnJS, + runOnUI, + scrollTo, + useAnimatedReaction, + useAnimatedRef, + useAnimatedScrollHandler, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +import CarouselItemShell from './CarouselItemShell'; +import { createStrategy } from './strategy'; +import type { + CarouselAlignment, + CarouselLayout, + CarouselRenderItemInfo, + CarouselSnap, +} from './types'; +import { + getDefaultCarouselColors, + getSnapScrollProps, + resolveSettleOffset, + visibleSlotCount, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +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 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); + + 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 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) => { + 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)); + 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, springTo, scrollRef, reportIndex] + ); + + 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 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..0624d0cb1a --- /dev/null +++ b/src/components/Carousel/CarouselItemShell.tsx @@ -0,0 +1,316 @@ +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; +}; + +/** + * 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, +}: 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 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 }), + [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..4a23da0426 --- /dev/null +++ b/src/components/Carousel/tokens.ts @@ -0,0 +1,84 @@ +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; + +/** + * 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', + outlineColor: 'outlineVariant', + focusIndicatorColor: 'secondary', + hoverStateLayerColor: 'onSurface', + focusStateLayerColor: 'onSurface', + pressedStateLayerColor: '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, +} as const satisfies Record; + +const dimensions = { + outlineWidth: 1, + disabledContainerOpacity: 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..c870592040 --- /dev/null +++ b/src/components/Carousel/utils.ts @@ -0,0 +1,195 @@ +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; +}; + +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], + }; +} + +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 = { + decelerationRate: 'normal' | number; +}; + +/** + * Hands the settle to the platform or to us. + * + * 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 { + 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 new file mode 100644 index 0000000000..9e509dbc57 --- /dev/null +++ b/src/components/__tests__/Carousel.test.tsx @@ -0,0 +1,493 @@ +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'; +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, + resolveSettleOffset, +} 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 no items until it has been measured', async () => { + await renderCarousel(); + // 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')).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'), 'momentumScrollEnd', { + nativeEvent: { contentOffset: { x: itemSize * 2, y: 0 } }, + }); + expect(onIndexChange).toHaveBeenCalledWith(2); + }); +}); + +describe('getSnapScrollProps', () => { + 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('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('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 + ); + }); +}); + +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); + }); + + 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); + } + }); +}); 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';