Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

120. A widget scrolls itself into view

Date: 2026-08-19

Status

Accepted. Completes scroll alongside ADR-0116, ADR-0117, ADR-0118 and ADR-0119.

Context

docs/core-widgets.md §1 gives scroll a scrollIntoView(widget) API. Three things in the catalog want it and none can be finished without it: selecting a tab the strip has scrolled past currently selects something the user cannot see, affix needed the same geometry from the other side, and §5’s tour “scrolls the target into view and waits for the frame” before it places a popover.

The obvious reading is a method on the viewport, and it is the expensive one. For a viewport to scroll an arbitrary descendant into view it must find it: a way to name the target, a lookup from that name to a rectangle, and an answer for what happens when the target is not built yet. None of that machinery exists, and all of it would exist only for this.

Decision

The child asks, and the viewport moves by a distance

ADR-0119 already tells a widget its own rectangle and the rectangle that clips it, which for anything inside a scroll is that viewport. The difference between those two rectangles is the answer. So the direction is inverted: the thing that wants to be seen — the one node that certainly knows where it is — computes the distance, and the viewport is asked to move by it.

ScrollController is the handle:

public void scrollBy(double dx, double dy);
public void reveal(LogicalRect self, LogicalRect clip);

reveal is scrollBy with the subtraction done, because every caller has the same two rectangles and doing the arithmetic at each call site is how two of them end up disagreeing about what “in view” means.

It scrolls the least it can. A row below the fold comes up to the bottom edge, not to the middle: §1 asks for the target to be in view, and a reveal that centred it would throw away everything the user was already looking at. When the target is larger than the viewport the near edge wins, which is what every browser does — the alternative shows a heading’s bottom and hides the heading.

A controller and not a wrapper — which is the second attempt

The first attempt was a Reveal widget wrapping whatever wanted to be seen. It reads better than a controller, it needed no new API on Scroll, and it was wrong: a wrapper is a box, and a box in a flex row changes how that row is sized. Putting one around each tab header broke two tab goldens and two motion tests immediately. The widget did exactly what it promised; the layout underneath it was no longer the same layout.

That is not a bug to be fixed in the wrapper. There is no box that is guaranteed transparent to flexbox — display: contents is the CSS answer and §8’s subset has no display — so any widget that inserts a node to observe geometry can change the geometry it was inserted to observe.

§1 words this as an API rather than as markup, and that turns out to be the load-bearing part of the wording. An API adds no node. A widget that wants to be revealed implements Located itself, which it can do without gaining a parent, and calls the controller from there. Tab does exactly this: it was already a Handles node, and it is now a Located one too.

The controller is created above the viewport

Not by the viewport’s own state, which is the tempting shortcut. Whoever needs to scroll a viewport is by definition somewhere else, and a controller created by ScrollState would have to be reachable downwards — which is the direction findAncestorState cannot look, since the scroll view a tab strip owns is the strip’s descendant rather than its ancestor.

So TabsState creates one, holds it for its lifetime, and hands it down through TabStrip and TabList into the Scroll. An application does the same with a field. A controller with no viewport attached is inert rather than an error, because a controller existing for a frame before the Scroll that answers to it is the normal order of construction and throwing there would make that order load-bearing.

BuildContext.findAncestorState is added and used by nothing in the end — the downward case is what the catalog needed — but it stays, because it is how an application-level scrollIntoView from inside a scroll view reaches the viewport, and that is the case §1’s wording is actually about.

A reveal is a request, not a constraint

TabsState holds one pending value, set when the selection changes and cleared the moment it has been acted on. A strip that pulled the selected tab into view on every frame would take the scrollbar away from the user for as long as anything was selected, which is always. Exactly one header per build carries the callback, so the router asks one node where it is rather than all of them.

Consequences

The three-way geometry story is closed: extents on an event for the clamp, extents once a frame for the thumb, a position once a frame for affix and this. Each arrived with a consumer that could not be built without it and each answers a question the others do not.

A reveal costs two frames — one to be measured, one to act — and lands without animation. §3.1 gives scroll “scrollIntoView / programmatic: overlay duration”, so a revealed row should glide rather than jump; the offset is state and nothing interpolates it. That is a transition on a value the cascade cannot see, which is the same shape TabPhase solved for one widget and the second consumer that would justify promoting it.

Nothing reveals horizontally and vertically with different urgency. A wide table asked to reveal a cell scrolls both axes at once, which is right, and scrolls the minimum on each independently, which occasionally moves a view further than a person would have.

The wrong turn is worth keeping in mind beyond this ADR: any widget that adds a node to observe layout can change the layout it observes. affix avoids it by being two nodes deliberately, with the outer one load-bearing for the hole anyway. A future widget that wants geometry without a node has to implement Located on something already there, which is a real constraint on what such a widget can be.