Skip to content

feat: add Carousel component - #5123

Open
andriicallstack wants to merge 4 commits into
callstack:mainfrom
andriicallstack:feat/carousel
Open

andriicallstack wants to merge 4 commits into
callstack:mainfrom
andriicallstack:feat/carousel

Conversation

@andriicallstack

Copy link
Copy Markdown

Motivation

react-native-paper has no Carousel, and it is one of the Material 3 Expressive components still missing from the v6 modernization effort. This adds it, with the four layouts the spec defines — multi-browse, hero, uncontained and full-screen.

Two things about the component are counter-intuitive enough to be worth stating up front, because they shape the whole implementation:

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. This is why the corner radius lives on the mask rather than on the item box — it has to travel with the visible rectangle, not with the item — and why photos never reflow or squash as they shrink.

Only two layouts need the arrangement solver. Uncontained needs no solver, does not snap at all, and is what MDC recommends when item aspect ratios must be preserved — the common React Native case. Full-screen is a single item. Multi-browse and hero are the two that run the cost-minimising arrangement.

Related issue

None yet — to be created.

Implementation notes

Worth reading before the diff; the layout model is the bulk of the change.

Keylines. Items live on an unmasked axis where item i is centred at (i + 0.5) × itemSize. A keyline list maps a position on that axis onto a drawn centre and a masked size, so one interpolation produces both the translation and the mask. Because the axis is uniform, every snap target is a whole multiple of the item size — which is how the snap target ends up being the focal keyline offset rather than the container edge.

Near either end the keylines blend into two permuted states that put the focal range hard against the start or the end of the container, so the first and last items can become focal. The three states are permutations of the same sizes, so they always have equal keyline counts and interpolate pairwise; the focal index is blended along with them, which keeps the mapping continuous across the boundary (there is a test for exactly this).

Geometry comes from the MDC-Android and Compose implementations, since the token table has none: small item min 40dp / max 56dp, anchor 10dp, gone 1dp, targetSmall = clamp(large / 3, 40, 56), targetMedium = (large + small) / 2, hero large ≤ 2× its height, uncontained medium threshold 0.85, arrangement cost |targetLarge − large| × priority.

Motion is split across the two tiers deliberately. The mask is a pure function of scroll offset, so it is read directly from a shared value — springing it against the finger would make it lag during direct manipulation. The settle is where the spring belongs: native deceleration is switched off wherever the carousel snaps, so the scroll view stops when the finger lifts and a shared value sprung with withSpring(toRawSpring(theme.motion.spring.default.spatial)) drives the scroll position from the UI thread. A new touch cancels it, which is what makes it interruptible. Programmatic scrollToIndex settles on the same spring, and the spring honours reduce-motion. State layers, the focus ring, disabled opacity and the hover elevation are not scroll-driven, so they run as Reanimated CSS transitions.

Release velocity is measured from successive scroll offsets rather than read from the drag-end event, because the event's units and sign differ between platforms while the offsets do not.

Three fling behaviours, not one. All three live in resolveSettleOffset, a pure worklet, so they are unit-tested directly rather than inferred from scroll view props:

Layout Settle
multi-browse decay projection carries across as many items as the fling earned, then springs
hero, full-screen single-advance: at most one item from where the drag began, however hard it is thrown
uncontained no snap at all

Content-vs-mask API. Item content is specified to react to the mask, and both reference implementations expose this publicly (OnMaskChangedListener on Android, carouselItemDrawInfo.maskRect in Compose). renderItem receives the live mask as shared values:

<Carousel
  data={photos}
  height={200}
  itemWidth={220}
  renderItem={({ item, mask }) => (
    <CarouselItem mask={mask}>
      <Image source={item.source} style={{ width: '100%', height: '100%' }} />
      <CarouselItemContent mask={mask}>
        <Text variant="labelLarge">{item.title}</Text>
      </CarouselItemContent>
    </CarouselItem>
  )}
/>

CarouselItem is the full-size box that media goes in; CarouselItemContent pins to the mask's leading edge and fades as the item collapses, which is the behaviour the spec asks of an item's text. Reading the mask from a useAnimatedStyle costs no re-renders.

Tokens. md.comp.carousel-item.* is the component's entire spec surface — there is no md.comp.carousel.*, no plural and no .expressive. variant, so baseline and Expressive resolve to the same values. Component tokens are extracted to tokens.ts; container.shape reuses corner.extraLarge, state layers reuse md.sys.state, and the focus indicator reuses the system thickness and offset.

Two groups from the table are deliberately not mirrored, and a test fails the build if an unused token is reintroduced:

  • label-text.* — MDC draws an item's label itself, but in Paper the item's content, text included, is rendered by the caller, so a label colour has nothing to paint. Disabled dims the content as a whole instead.
  • dragged.* — a drag on an 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.

Test plan

yarn example ios (or android), then open Carousel from the example list. Every layout is on that screen, along with toggles for the outlined and disabled variants and Previous / Next buttons that drive the carousel through its ref.

Worth checking by hand:

  • Fling multi-browse hard — momentum should carry across several items and then spring into place, not stop at the next one.
  • Fling hero hard — it should advance exactly one item however hard you throw it.
  • Uncontained should not snap at all.
  • Grab the strip mid-settle — the spring should yield to your finger immediately.
  • Watch a photo shrink: it should crop, never squash, and its label should slide with the mask's leading edge and fade out.
  • Turn on Reduce Motion and confirm the settle jumps rather than springs.

Automated: 47 tests covering the arrangement solver, keyline placement (continuity across the shift boundaries, monotone shrink, exact container fill, focal alignment at both scroll ends), the three settle behaviours, the mask handed to renderItem, and the token table. yarn lint, yarn typecheck and yarn test are all clean; the docs site builds and generates the three component pages.

Screenshots

Multi-browse, mid-scroll — one large item, a medium and a small, with the leading item collapsing to a sliver. Note that each photo is cropped by its mask rather than resized:

Multi-browse Hero, start-aligned
Hero, centre-aligned Uncontained
Full-screen Outlined

The solver picking more large items as the container grows:

The settle spring, driven through the component's ref — note the slight overshoot before it comes to rest on the focal keyline:

Known gaps

  • The touch-drag settle has not been exercised on a device. The spring, the UI-thread scrollTo and the velocity worklet were verified on react-native-web, and the settle maths is unit-tested and platform-independent, but the handoff from a native drag to our spring — onScrollBeginDrag / onScrollEndDrag with decelerationRate={0} — only really runs on iOS and Android. This wants a pass on both before merge.
  • RTL is not handled for scroll direction; the layout is left-to-right and this is noted in the component docs.
  • The token values should be cross-checked against the latest published spec table.
  • No docs screenshots yetdocs/public/screenshots/ holds real per-platform device captures, which need a native build to produce.

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.
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.
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.
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant