Iris, from her phone: "some stuttering when flinging in particular. Harder to notice with my finger directly moving the scroll." Her fling phase was 103fps on a 120Hz screen at p50 6.3ms. Two of the four things found are corrections to the instrument, not the renderer. The swapchain acquire -- `get_current_texture`, which *blocks* until the compositor frees an image -- was inside the span the report called iris's CPU work, so a fling comfortably ahead of the display read as milliseconds of being slow. A frame is now three measured parts (`FrameParts`: build, acquire, submit), per phase as well as per run. And nothing could say a frame was never *produced*: `late` counts frames that cost too much, which a reader does not see, while a frame that never happens leaves the last one up for two refreshes, which is the stutter. `PhaseStats::missed` counts vsyncs nothing was drawn for. It closes on the emulator: 1548 frames + 452 missed over 33.0s at 60Hz is 1980 vsyncs. The other two are the frame loop. `Choreographer.postFrameCallback` schedules for the next vsync after the call, and iris asked at the *end* of the callback -- so any frame whose work ran past the boundary registered too late and got the vsync after, one frame over budget silently costing a second. It is asked for immediately after `tick_animations` now, on both backends. And the fling was advanced on `Instant::now()` rather than the vsync `do_frame` carries: frames are presented on an even cadence whatever clock computes them, so sampling the spline at "whenever the callback ran" moves the content unevenly with no frame late enough to appear in any report -- and a drag never had it, which is the asymmetry Iris described. `PointerClock` is `DeviceClock` and the view keeps one, anchored by whichever of a touch or a frame comes first, so a fling is advanced on the clock its velocity was measured on. `opt-level` for the Android release build goes from "s" to 3. The table in RUST.md picked "s" on bytes alone; over the same warm fling eight times iris's own per-frame work is p90 0.15ms/p99 0.42ms at "s" against p90 0.09ms/p99 0.26ms at 3, for 1.8 MB of arm64 APK. `app-rust/tests/fling_profile.rs` is the rig that established what a fling frame actually costs and is kept for next time (Iris: "please keep the profiling rig around for future use"): only one frame in six lays anything out, and the multi-millisecond spikes are all first-pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
16 KiB
Markdown
327 lines
16 KiB
Markdown
# Scrolling in iris
|
|
|
|
How anything in iris scrolls, as of 2026-09-08. This is the current
|
|
design, not a history — the git log has the account of
|
|
how it got here, and `docs/IRIS_TODO.md` has what is still open.
|
|
|
|
Read this before touching `iris/src/widget/position/scrollable.rs`,
|
|
`scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays
|
|
out a long list.
|
|
|
|
## The one rule
|
|
|
|
**Everything a scroll position is made of lives in one `ScrollController`,
|
|
and the widget that scrolls owns one.** The position, the pending delta,
|
|
the travel left, the pin, the `DragGesture` and the `Flinger` are all in
|
|
that struct (`scrollable.rs`); there is exactly one `Flinger` and one
|
|
`DragGesture` implementation in the crate's widgets. A widget with one
|
|
implements `Scrollable`, whose one required pair of methods hands the
|
|
controller back, and gets `scroll`, `fling`, `drag`, `amt`,
|
|
`is_scrolling`, `tick_fling` and the pin as default methods.
|
|
|
|
Two widgets have one, and they differ only in how they spend a delta:
|
|
|
|
- **`ScrollArea`** (`scroll_area.rs`) — a fixed child, measured whole and
|
|
then slid about as a lump, which is what makes a scroll tick an O(1)
|
|
move of one subtree. `.scrollable(axis, pin)` wraps anything in one.
|
|
- **`LazySpan`** (`lazy_span.rs`) — lays its own rows out from an anchor,
|
|
so it cannot be a lump and is not wrapped in anything. Its own
|
|
`.scrollable()` registers the same two senses against the controller it
|
|
already has.
|
|
|
|
Do not give a widget its own fling, its own scroll amount, or a
|
|
`RequestRedraw` handle. And do not add a scrolling method to the `Widget`
|
|
trait: the three that used to be there (`scrolls_itself`, `apply_scroll`,
|
|
`scroll_offset`) existed only so a `Scroll` could drive a `LazySpan` it
|
|
had no business wrapping, and they are gone (Iris, 2026-09-08: "I don't
|
|
like adding methods to widget, it seems like we can structure things
|
|
better instead").
|
|
|
|
## One convention for a delta
|
|
|
|
**Positive scrolls the reader up or left; negative down or right.** The
|
|
content's pixels therefore move the positive way along the axis for a
|
|
positive delta — the finger's direction — and that is `Scroll::scroll`'s
|
|
sign, `Scroll::fling`'s, and `Widget::apply_scroll`'s, from the gesture
|
|
all the way down to a row's anchor.
|
|
|
|
**It is a screen direction, not a logical one** (Iris, 2026-09-08:
|
|
"positive should always scroll up / left, and negative down / right ...
|
|
that way it always works as the user would expect"). The earlier wording
|
|
— "positive brings *earlier* content into view" — is true only of a span
|
|
laid out forwards: a `Dir::UP` list's earlier content is *below*, so the
|
|
same delta panned it the opposite way from every other scrollable in
|
|
iris. `LazySpan::flip_delta` is the conversion into the walk's own
|
|
direction-relative space, the exact counterpart of `flip_pos` for
|
|
positions, and its `scroll` (private) is the only thing that speaks that
|
|
space.
|
|
|
|
There used to be two public conventions under the same name, and every
|
|
call site had to know which widget it was talking to. If you add a third
|
|
scrolling thing, it takes this one. Two tests pin it, and neither is
|
|
redundant: `a_negative_delta_moves_toward_the_end` follows the sign
|
|
across the whole handoff, and `a_delta_moves_both_directions_the_same_
|
|
way_on_screen` checks the two `dir`s against **where rows were drawn** —
|
|
an assertion written in the walk's own space passes with the flip
|
|
deleted, because it checks the bookkeeping against itself.
|
|
|
|
## The contract between a controller and its owner
|
|
|
|
Two calls, both inside the owner's `draw`, because a `draw` is the only
|
|
place that knows where the content ends:
|
|
|
|
1. **`take_delta()`** — everything a wheel, a drag or a fling asked for
|
|
since the last layout, in one number, already clamped to the travel
|
|
that layout reported. Clipping it stops a fling.
|
|
2. **`set_travel(Travel)`** at the end, plus whichever of **`moved_by`**
|
|
(movement) or **`set_amt`** (an absolute position) fits how that owner
|
|
knows where it ended up.
|
|
|
|
`Travel` is `{ back, fwd }` in the same screen-space units as a delta:
|
|
`back` bounds a positive one, `fwd` a negative one, and `f32::INFINITY`
|
|
means "the end is not in sight". That last is not a placeholder — a lazy
|
|
layout genuinely cannot say how far its content runs without walking
|
|
there, and `clamp` takes the answer with no branch of its own.
|
|
|
|
**Why the delta is banked rather than applied where it arrives.** A wheel
|
|
event, a drag frame and a fling tick all land between draws, and none of
|
|
them can know whether there is content to move into. Applying them at the
|
|
layout that follows is also what keeps layout a pure function of the state
|
|
(Iris, 2026-09-08). The visible consequence, and the thing that catches a
|
|
test out: **`amt` does not move until the next draw.**
|
|
|
|
**Which clock a fling is ticked on.** The vsync the frame callback
|
|
carries, not `Instant::now()` -- on Android `do_frame`'s
|
|
`frame_time_nanos`, converted through the view's one `DeviceClock`
|
|
(`sense.rs`), which also dates every touch sample, so a fling is advanced
|
|
on the clock its own velocity was measured on. Frames are presented on an
|
|
even cadence whatever clock they are computed on, so sampling the spline
|
|
at "whenever the callback got to run" moves the content unevenly between
|
|
frames that are shown evenly -- a shimmer that no frame-time percentile
|
|
can see, since no frame was late. Found 2026-09-09; docs/RUST.md's
|
|
"The fling stutter" has the rest.
|
|
|
|
### Why a remainder was not enough
|
|
|
|
The `apply_scroll(&mut delta)` this replaced left the part it could not
|
|
take in the caller's variable, and that was meant to be the whole story.
|
|
It is not, because **a lazy layout usually cannot say where its content
|
|
ends until it has walked there.** With the wall out of view it takes the
|
|
delta in full, and the walk that follows gives part of it back. So the
|
|
remainder is exact only when the wall was already visible, and a parent
|
|
adding remainders up would over-count by every overshoot and never
|
|
correct. Now the owner reports what it *did* (`moved_by`, from the one
|
|
place its anchor moves) as well as what it *can* do, and
|
|
`amt_counts_only_what_the_child_could_take` is the test.
|
|
|
|
## What `amt` means
|
|
|
|
The same direction for both owners, and a different origin:
|
|
|
|
- `ScrollArea`: distance from the start of the content, clamped into the
|
|
scroll range. An absolute position.
|
|
- `LazySpan`: **movement, not position.** Paging rows in above moves the
|
|
origin and the span cannot say by how much, never having measured them.
|
|
|
|
A scrollbar needs a real content length before it can use either, and a
|
|
lazy span has none. Do not invent one.
|
|
|
|
## `ScrollArea::draw` — measure, then place
|
|
|
|
1. `take_delta`, and move to where it asks.
|
|
2. Draw the child in a box as long as **last frame's** length, to measure
|
|
it. This is free in the common case: the same region as last frame
|
|
means `draw_inner` returns immediately.
|
|
3. Apply the pin and clamp against the length just measured.
|
|
4. Draw the child again, at that length and position.
|
|
|
|
Only the second draw decides anything, and a frame on which the content
|
|
did change pays one real extra draw — a frame on which it was being
|
|
redrawn anyway. Placing against the hint and letting the next frame fix it
|
|
is what hung the composer's text half a line outside its box on Iris's
|
|
phone: **layout is a pure function of the state, not of how many frames
|
|
have been drawn**, and there may be no next frame.
|
|
|
|
The pin only re-pins on a frame with **no delta of its own**: the pin
|
|
means "stay flush with the end as the content grows", and a reader who has
|
|
just scrolled away has said otherwise.
|
|
|
|
## `LazySpan`
|
|
|
|
`iris/src/widget/position/lazy_span.rs`. A virtualised sequence of
|
|
variable-height rows, laid out from an anchor. It is what `Span` is, done
|
|
lazily, and it drives its own controller: the walk is the only thing that
|
|
can say how far it may go, so nothing above it is in a position to.
|
|
|
|
### Why it is not a `Span` inside a `ScrollArea`
|
|
|
|
Measured 2026-09-08, and worth not re-deriving:
|
|
|
|
- A `Span` is skipped entirely in the steady state, but **when it is
|
|
redrawn it costs two draws per child** (21 draws for 10 children):
|
|
phase 1 offers each child the ambient region to learn its length,
|
|
phase 2 offers it its real share. So any mutation of a `Span` redraws
|
|
all of it — 24 draws for 11 children after one prepend.
|
|
- A `ScrollArea`'s efficiency and virtualisation pull opposite ways: a
|
|
scroll tick offers a same-size moved region, `draw_inner` takes the
|
|
`mov` path, and the child's `draw` never runs. A virtualising child
|
|
inside one would never update which rows it shows. That is why a
|
|
`LazySpan` owns its controller instead of being wrapped in one.
|
|
- A lazy child cannot report a content length, so an area's clamp,
|
|
end-pin and any future scrollbar would have nothing to work against.
|
|
Walls are *discovered* by the walk instead.
|
|
|
|
### Direction and pin are separate questions
|
|
|
|
`LazySpan::new(dir, pin)`, and `ScrollArea::new(inner, axis, pin)`.
|
|
|
|
- **`dir`** means what it means in `Span`: which end of the box item 0
|
|
sits at, and which way the sequence grows.
|
|
- **`pin`** is which end the view clings to as rows arrive.
|
|
|
|
A transcript is `Dir::DOWN` (oldest message is item 0, at the top) with
|
|
`Pin::End` (the view sits at the bottom). Conflating the two would stand
|
|
it on its head.
|
|
|
|
**`Pin` says it either way round**, because there are two questions and
|
|
they are not the same one (Iris, 2026-09-08). `Start`/`End` are
|
|
content-relative — the first row or the newest one, wherever the layout
|
|
puts it — and `Neg`/`Pos` are axis-absolute: the top/left edge and the
|
|
bottom/right one, whichever end of the content is there. They coincide for
|
|
everything except a reversed `LazySpan`, where they are exact opposites,
|
|
which is the whole reason both exist. The one question a scrollable acts
|
|
on is `pinned_to_end`, and `dir` is what resolves a `Pin` into it.
|
|
|
|
### Two coordinate spaces, two conversion points
|
|
|
|
The walk works entirely in **direction-relative** pixels from the leading
|
|
edge — which for `Sign::Neg` is the bottom or the right. `Edge`,
|
|
`Placement`, `RowExtent`'s `lead`/`trail` and every local are in that
|
|
space, so the layout is written once for both directions. Exactly two
|
|
functions know which way round the box is:
|
|
|
|
- **`abs_region`** flips the box for `Sign::Neg`.
|
|
- **`flip_pos`** converts the screen-space positions the public helpers
|
|
speak in (`note_tap`, `key_at`, `extent`, all fed by pointer events).
|
|
|
|
Skip the second and a reversed span hit-tests at the mirror of where it
|
|
drew — which looks like a working list until you tap one.
|
|
`a_dir_up_span_grows_upward_from_item_zero` guards this, and it asserts on
|
|
where rows were **actually drawn** (`UiRenderState::active`) rather than
|
|
on `extents`, because an `extents`-only assertion passes with the flip
|
|
deleted: it checks the bookkeeping against itself.
|
|
|
|
### The row-height cache stays in the container
|
|
|
|
`heights`, keyed by `RowKey`. Two reasons it cannot move into the
|
|
framework:
|
|
|
|
1. **`ActiveData::size` dies exactly when it is needed.** The moment
|
|
`LazySpan` culls a row it stops offering it a region, `draw_inner`'s
|
|
old-children diff calls `remove_rec`, and the `ActiveData` — with its
|
|
`size` — is freed. The framework's copy is gone for precisely the rows
|
|
the walk has to pass through without drawing.
|
|
2. **A widget may one day render in two places at once** (Iris,
|
|
2026-09-08), so anything keyed by `WidgetId` alone that describes where
|
|
or how big a widget was drawn will be wrong then. Where and how big
|
|
belongs to the owner that placed it.
|
|
|
|
Virtualisation *means* traversing rows without drawing them, and a size
|
|
you can only get by drawing is no use for deciding not to draw.
|
|
|
|
### Overscroll, and why it happens at all
|
|
|
|
**Because the span cannot see the wall until it has walked to it.** With
|
|
rows loaded past an edge it reports `INFINITY` of travel that way, takes
|
|
the whole delta, and the walk that follows discovers the content ran out
|
|
200px ago. Nothing else could be reported: the rows past the edge have
|
|
never been measured, and measuring them is exactly the work
|
|
virtualisation exists to skip. The other source is the content or the
|
|
viewport changing under a settled anchor — a row that grew, a page
|
|
dropped, the keyboard opening — where nothing scrolled at all.
|
|
|
|
So `overscroll_gap` measures the gap from the ends the walk already
|
|
placed, and `draw` moves the anchor by it and walks a **second time
|
|
inside the same frame**. `moved_by` counts that correction along with the
|
|
move that caused it, which is why `amt` stays equal to what is on screen
|
|
rather than drifting by every overshoot.
|
|
|
|
Layout is a pure function of the state, not of how many frames have been
|
|
drawn (Iris, 2026-09-08). A correction that lands next frame is a frame
|
|
drawn wrong, and there may be no next frame — a fling that stopped is not
|
|
asking for one.
|
|
|
|
## The transcript's wiring
|
|
|
|
`app-rust/src/ui/mod.rs`, `build_tree`.
|
|
|
|
The transcript registers the wheel **by hand rather than calling
|
|
`LazySpan::scrollable()`**, and this is not an oversight. That helper also
|
|
registers a finger drag driving the span's own `DragGesture`, and the
|
|
transcript already has an arbiter — `Selection`, which must decide between
|
|
panning and selecting text and so cannot let a second `DragGesture` see
|
|
the same frames. `DragGesture`'s doc states the rule: one gesture, one
|
|
arbiter, each frame delivered exactly once. The wheel handler registered
|
|
here is identical to the helper's; only the drag differs.
|
|
|
|
`Selection` is given the span by `set_scroll_area` after it exists (rows
|
|
need a `Selection`, and the span needs the rows), and hands it committed
|
|
pans and releases through `Scrollable::scroll`/`fling`. There is no
|
|
wrapper widget: `TranscriptScreen::list` is the layout (`extent`,
|
|
`key_at`, `jump_to_end`) *and* the position (`amt`, `fling`,
|
|
`is_scrolling`).
|
|
|
|
A `Selection` with no scroll area still selects and still reports taps but
|
|
cannot pan; there is a `debug_assert` in `drag` naming that.
|
|
|
|
## Measurements worth not re-taking
|
|
|
|
- A settled scroll tick of a `LazySpan` with 31 rows on screen:
|
|
**1 real draw and 31 move-slot writes**, no primitive rewrites and no
|
|
text reshaped. An idle frame is `(0, 0, 0, 0)` — `draw_inner` does not
|
|
even enter the widget. This is the number any "store the edges and only
|
|
recompute what changed" optimisation would have to beat, and it is why
|
|
the walk was left alone.
|
|
- `Span`, redrawn: two draws per child (see above).
|
|
- The one design that would collapse those 31 moves into a single delta
|
|
write is moving the content as a unit, which needs a content length —
|
|
which a lazy layout cannot supply.
|
|
|
|
## Tests that pin the behaviour
|
|
|
|
In `lazy_span.rs`, all of these fail if the corresponding piece is undone:
|
|
|
|
- `a_negative_delta_moves_toward_the_end` — the sign, end to end.
|
|
- `a_delta_moves_both_directions_the_same_way_on_screen` — the sign is a
|
|
screen direction, checked against where rows were *drawn*.
|
|
- `amt_counts_only_what_the_child_could_take` — why the owner reports what
|
|
it did rather than the caller adding up what it asked for.
|
|
- `a_fling_stops_at_the_first_row`,
|
|
`scrolling_past_the_start_lands_on_it_in_the_same_frame` — the walls,
|
|
with no settling frame drawn on purpose.
|
|
- `a_dir_up_span_grows_upward_from_item_zero`,
|
|
`a_reversed_span_hit_tests_in_screen_space` — the position conversions
|
|
(`flip_pos`), as `a_delta_moves_both_directions_the_same_way_on_screen`
|
|
is the delta one (`flip_delta`).
|
|
- `a_registered_fling_is_driven_by_tick_animations_and_then_unregisters` —
|
|
a fling that nothing registers never moves, whatever its velocity.
|
|
|
|
In `app-rust/tests/` (layer 1, no window or GPU):
|
|
|
|
- `top_edge.rs`'s `scrolling_past_the_first_row_settles_on_it` /
|
|
`scrolling_past_the_last_row_settles_on_it` — both ends, no settling
|
|
frame.
|
|
- `phone_screen.rs`'s `a_recorded_flick_releases_with_a_velocity_and_
|
|
flings_the_list` — the velocity against
|
|
`benches/velocity_reference.py`'s number, and the fling's travel against
|
|
`benches/fling_spline_reference.py`'s.
|
|
- `phone_screen.rs`'s `a_long_press_and_drag_selects_text` — what caught
|
|
two `DragGesture`s fighting over the transcript.
|
|
- `catch_a_fling.rs`, `gesture_cancel.rs`, `fence_fling.rs` — press-catches
|
|
a coasting area, cancels, and a code fence panning sideways
|
|
independently of the transcript.
|
|
|
|
`docs/RUST.md`'s "Three test layers" says which layer answers what. Test
|
|
at the cheapest one that can answer the question; the emulator is for JNI,
|
|
the IME, insets and one verification run, not for iterating on layout.
|