Scroll captures
Long-form scrolling captures (one tall PNG that stitches the whole scrollable region) and animated GIFs of the scroll itself, driven through the renderer extension pipeline.
At a glance
| Kinds | render/scroll/long, render/scroll/gif |
| Schema version | n/a (image-only) |
| Modules | :data-scroll-core (pure JVM — planners, stitcher, GIF encoder, axis), :data-scroll-android (the Android AndroidComposeTestRule-bound scroll drivers used by :renderer-android) |
| Render mode | default |
| Cost | medium (extra renders per scroll step) |
| Token usage | Image-only — ~1.5 k tok per render/scroll/* PNG read; payload itself is a path. See token usage. |
| Transport | path (PNG / GIF) |
| Platforms | Android · Desktop · shared |
What it answers
- What does the entire scrollable region look like, end to end, not just the viewport at rest?
- What does the scroll motion look like over time (entry animations, sticky headers settling,
LazyColumnitem placement)? - Does a
nestedScrollcollapse / expand land in the right state at the end of a fling?
data/scroll/core ships the pure-JVM scroll primitives (ScrollAxis,
ScrollLongFramePlan / ScrollGifFramePlan planners,
ScrollSliceStitcher, ScrollGifEncoder, ScrollPreviewExtension).
data/scroll/android ships the AndroidComposeTestRule-bound
ScrollDriver (driveScrollByViewport, driveScrollBy,
driveScrollToStart, driveScrollToEnd, remainingScrollPx) — the
Android renderer composes both. The Compose Desktop renderer pulls
just data-scroll-core and drives the scrollable through
runComposeUiTest directly, sharing the pure-JVM planners and
stitcher with the Android path.
What it does NOT answer
- Scroll is renderer-side only — it produces image artifacts, not a JSON payload, so it has no
kindon the daemon’sinitialize.capabilities.dataProductslist. There is nodata-scroll-connector. It never round-trips throughdata/fetchordata/subscribe; instead the renderer drives it directly viaPreviewPipelineStep/ scenario-driver hooks. - It does not measure scroll performance — for that, instrument
compose/recompositionover the same scrolled frames.
Use cases
- Render a tall settings screen as one PNG for design review.
- GIF a
LazyColumnto verify item-key stability across data changes. - Capture the full scroll of a Wear
ScalingLazyColumnto confirm the curvature target.
Payload shape
Image-only artifacts. Produced via
:data-scroll-core
extensions. Output paths under
build/compose-previews/renders/<id>-long.png and
build/compose-previews/renders/<id>.gif.
Enabling
Annotate the preview with the matching multi-preview annotation (e.g.
@ScrollingPreview) — see
skills/compose-preview/references/capture-modes.md
for the multi-preview / scenario annotations the scroll extension
recognises.
How a LONG capture is verified
Every stride of the walk and every seam of the stitch is checked against the pixels, and what could not be checked is written next to the PNG.
The driver measures each stride. ScrollBy on a lazy list animates, and a
lazy list’s ScrollAxisRange.value is not a pixel position (LazyColumn and
Wear’s ScalingLazyColumn publish index × 500 + offset), so neither “has the
spring landed” nor “how far did the content move” can be read off the
semantics range. driveScrollByViewport instead snapshots the on-screen
positions of the scrollable’s descendant semantics nodes before each stride,
advances the paused clock frame by frame until they stop moving, measures the
median displacement of the nodes seen on both sides, and dispatches a
corrective ScrollBy (up to three) when the landing misses the planned stride
by more than a pixel while the scroller still has room. The measured travel is
the offset the stitcher receives, flagged as measured so the search window
around it is a few pixels rather than a third-to-triple of the hint.
The stitcher only aligns on signal. Rows are weighted by their horizontal
luminance stddev, so black background and the body of a button between its text
lines contribute nothing and a row through text or an icon decides the match.
A candidate shift is only eligible when its overlap is at least a tenth of the
viewport and its informative rows sum to enough signal to be a feature rather
than a stray edge; candidates within a small margin of the best score are a tie
and the one nearest the hint wins (identical chip bodies one pitch apart). This
is what a pinned Wear TimeText used to defeat: with the time drawn over the
head of every slice, a 12-row all-black overlap at the far end of the window
out-scored the true shift and the next slice was painted from its top row,
10:10 and all.
Chrome that appears at the foot of a slice is set aside. The last stride of
a Wear walk lands at the content end, and landing there reveals the
ScreenScaffold’s EdgeButton: the last slice’s bottom fifth is a bright
button where the previous slice had background. At the true shift every row
above it agrees exactly and every row of it disagrees violently, which
misalignment never looks like (it spreads disagreement across the overlap). A
candidate’s score therefore walks up from the bottom of the overlap past a
contiguous run of disagreeing rows, caps it at a third of the overlap, and
scores only the rows above; the run is reported as revealedRows on the seam.
RevealedBottomChromeSeamTest pins this on the real slice pair.
What is reported. Each seam gets a verdict — verified, low_signal (too
little varied content in the overlap to decide on) or mismatch (no shift made
the two slices agree) — and each stride a landed/not-landed outcome. The stitch
is always written, but anything unverified rides in <png>.warnings.json:
{
"unlandedScrollSteps": [{ "role": "…", "step": 2, "requestedPx": 307.2, "measuredPx": 225.0, "corrections": 0, "settled": true, "message": "…" }],
"unverifiedScrollSeams": [{ "role": "…", "seam": 1, "verdict": "low_signal", "hintPx": 307, "shiftPx": 372, "overlapRows": 12, "informativeRows": 0, "residualPerPixel": 0.3, "message": "…" }]
}
A clean capture writes neither array. A consumer that wants to refuse an
untrustworthy tall PNG fails on either being non-empty; the renderer’s
WearPinnedTimeTextLongScrollTest and LongScrollPreviewPixelTest do exactly
that.
To iterate on the matcher without a Robolectric round-trip, render with
COMPOSEAI_KEEP_SCROLL_SLICES=1 (the per-slice PNGs, the settled final frame
and the reported offsets stay in <id>_slices/ next to the output), then point
COMPOSEAI_STITCH_HARNESS_DIR at that directory and run
:data-scroll-core:test — ScrollSliceStitcherHarnessTest re-stitches every
slice set it finds and prints each seam’s verdict.
Companion products
- Recomposition —
compose/recompositionto attribute scroll cost to specific composables. - History diff —
history/diff/regionsagainst a long-PNG baseline to catch list-item layout regressions.