Appearance
A virtualized feed
An activity feed of entries of no fixed height, growing at the end as things happen and at the start as older history is loaded. The screen holds three cells and no list of rows.
This recipe assumes you have read virtualization, which is the mechanism under everything here. It is deliberately not built on the LazyList component: that owns its own scroll offset, and this screen has to drive one, for the jump and for the anchoring below. LazyList is the better choice for a feed nothing ever scrolls from outside, and it brings the roles, the keyboard and the selection with it, which this page then puts on the rows by hand.
Scroll into the middle of the feed and try the buttons. Newer entries arrive at the end and nothing under the reader moves. Older entries arrive at the start, which moves everything: press the naive button to watch it happen, and the anchored one to watch it not.
The source
tsx
/** The entry the feed starts at, and how many it starts with. */
const FIRST_ENTRY = 1000;
const STARTING_ENTRIES = 120;
/** How many arrive at each end when the buttons are pressed. */
const NEWER = 3;
const OLDER = 10;
/**
* The height the window assumes for an entry it has not measured.
*
* The entries wrap, so they are not all this tall and no single number
* could be right. What it has to be is close: a good estimate means the
* scroll range is nearly right before anything has been measured, and a
* bad one makes the scrollbar jump as the reader travels.
*/
const ESTIMATE = 46;
const AUTHORS = ['Ravi', 'Ana', 'Mikael', 'Yuki', 'Priya', 'Tomas', 'Noor'];
const ACTIONS = [
'pushed two commits',
'opened a pull request against the layout engine',
'closed the ticket about sticky headers and moved the rest of the work into next week',
'renamed the release branch',
'left a review, with a note about the estimate the virtual window starts from and how it settles',
'restarted the build'
];
/** A deterministic spread, so an entry reads the same on every reload. */
function scramble(entry: number): number {
return Math.imul(entry + 1, 2654435761) >>> 0;
}
/**
* What one entry says.
*
* Keyed on the entry number rather than on the row index, which is the
* whole point of the exercise: an index means a different entry after
* older entries are loaded above it, and an entry number never does.
*/
function authorOf(entry: number): string {
return AUTHORS[scramble(entry) % AUTHORS.length];
}
function actionOf(entry: number): string {
return ACTIONS[(scramble(entry) >>> 5) % ACTIONS.length];
}There is no array of entries anywhere in this screen. The source is two cells, declared in the component below: count, and the number of the entry at index 0. Between them they say what index i means, and everything else is a function of the entry number: who did it, and what they did.
That is what the window wants. It renders an index, so a data source that can answer "what is at index i" costs nothing per entry that nobody is looking at. A real feed answers it out of a page of records it has fetched, and the shape is the same.
The estimate is the one number worth thinking about. The entries wrap, so no single height is right for all of them, and the window uses it for every entry it has not measured. Here 46 puts the scroll range within 3 px of the truth on the first frame: 5,517 px for 120 entries, of which eight have been measured, and 5,482 after a jump to the end has measured two windows more.
The feed
tsx
/**
* An append-only feed, with older entries loaded above it.
*
* Two cells are the whole data source. `count` is how many entries
* there are; `first` is the number of the entry at index 0. Together
* they say what index _i_ means: entry `first + i`. Newer entries land
* at the end, older ones at the start.
*
* That is also why they reach the list differently.
*
* **A newer entry only changes `count`.** Nothing an index means has
* moved, so the list follows the count, re-uses every mounted row, and
* leaves the scroll offset alone. The reader does not move.
*
* **An older entry changes what every index means**, so `first` is
* handed to the list as its `revision` as well. The mounted rows are
* rendered again in place against the new data, and their measurements
* are dropped. The scroll offset is untouched by that, which is exactly
* the problem: the entry the reader was looking at is ten indices
* further down than it was, so leaving the offset alone moves the feed
* under them. The anchored button below does the arithmetic that very
* nearly cancels that, and the naive one does not, so the difference is
* a press apart.
*
* This is a `LazyColumn` rather than the `LazyList` component, for one
* reason: the screen needs to drive the scroll offset, for the jump and
* for the anchoring, and a bound `scrollY` is how a scroll container is
* put where you want it. `LazyList` owns its own offset and gives the
* rows their roles, their keyboard and their selection instead, so it
* is the better choice for a list that never has to be moved from
* outside.
*/
export function Activity(_inputs: Inputs<{}>, _ctx: ComponentContext) {
const count = internalState(STARTING_ENTRIES);
const first = internalState(FIRST_ENTRY);
/** Where the list is scrolled to, in the application's hands. */
const at = internalState(0);
/**
* The window, handed over once as the list is built.
*
* It is read, never driven: `offsetOf` says where an index sits
* without mounting it, `range` says which indices exist, and
* `totalExtent` is how tall the whole feed currently believes it is.
* The runtime owns everything else about it.
*/
let view: UiVirtualWindow | null = null;
/**
* One entry. Called once per mounted row, and again for the mounted
* rows whenever `revision` changes.
*
* Nothing sets a height. The body wraps to the width of the list, so
* an entry is one line or three, and the window measures what it got.
*/
const entry = (index: number) => {
const number = first.value + index;
return (
<column
role="listitem"
posInSet={index + 1}
setSize={count}
gap={2}
paddingLeft={10}
paddingRight={10}
paddingTop={6}
paddingBottom={6}>
<text text={`#${number} ${authorOf(number)}`} fontSize={11} color="textMuted" />
<text text={actionOf(number)} fontSize={12} color="text" />
</column>
);
};
const list = LazyColumn(
{
width: percent(100),
height: 200,
count,
revision: first,
estimatedExtent: ESTIMATE,
scrollY: at,
modifiers: [scrollPosition({ onChange: offset => (at.value = offset.y) })],
windowRef: window => (view = window),
role: 'list',
label: 'Activity',
borderWidth: 1,
borderColor: 'border',
borderRadius: 8,
backgroundColor: 'surface'
},
entry
);
/** Newer entries: the count grows, and nothing else changes. */
const newer = () => {
count.value += NEWER;
};
/**
* Older entries, and the reader kept roughly where they were.
*
* The offset has to move because the content above the reader grew.
* What by is a question only the window can answer, so it is asked:
* the top of the window, how far past it the viewport starts, and
* where that same entry sits once it is ten indices further down.
*
* Roughly, not exactly. The ten entries that arrived have never been
* mounted, so nothing knows how tall they are and the offset moves by
* what the window assumes them to be. In the spec beside this file
* that leaves the reader 28 px from where they were, against 462 px
* for the naive button, on entries between 42 and 56 tall.
*/
const older = () => {
if (view === null) {
return;
}
const anchor = view.range.first;
const into = at.value - view.offsetOf(anchor);
load();
at.value = view.offsetOf(anchor + OLDER) + into;
};
/** The same load, with the offset left alone. */
const olderNaive = () => {
load();
};
const load = () => {
first.value -= OLDER;
count.value += OLDER;
};
/**
* The jump: past the end on purpose.
*
* The engine clamps a scroll offset to the content it has, and
* `scrollPosition` reports the offset the list actually ended up at,
* which is written straight back into the same cell. So asking for
* more than there is settles on the bottom without the screen having
* to work out where the bottom is.
*/
const newest = () => {
if (view !== null) {
at.value = view.totalExtent();
}
};
const caption = combineLatest([count, first, at]).pipe(
map(
([entries, start, offset]) =>
`${entries} entries, #${start} to #${start + entries - 1}, ${Math.round(offset).toLocaleString('en-US')} px down`
)
);
return (
<column gap={12} padding={16} width={percent(100)} height={percent(100)}>
{list}
<row gap={8} y="center">
<Control label="3 newer" onPress={newer} />
<Control label="10 older" onPress={older} />
<Control label="10 older, naive" onPress={olderNaive} />
<Control label="Jump to newest" onPress={newest} />
</row>
<text text={caption} fontSize={12} color="textMuted" />
</column>
);
}The rows carry their own semantics. LazyColumn is a scroll container, not a control, so role, posInSet and setSize are the screen's to set, and what they say is the whole feed: entry 41 of 130, not entry 3 of the 8 that happen to be mounted. See semantics for what those become.
setSize is bound to the count cell rather than read from it, so an entry that is mounted when the feed grows says the new length without being rendered again.
Nothing sets a height on an entry. The body wraps to the width of the list and the window measures whatever it got, which is the case the estimate exists for.
Newer entries change only the count
count grows, no index means anything different, and the list follows. The mounted rows are re-used, the scroll offset is untouched, and the reader does not move: the spec checks that the entry under the eye is still in exactly the same place, to the pixel.
Older entries change what every index means
Loading ten older entries shifts every index by ten. That is not something a count can express, so the number of the first entry is handed to the list as its revision as well, and the mounted rows are rendered again in place against the new data.
What revision does not do is move the scroll offset, and the offset is now pointing ten entries too far up the feed. Left alone, the feed jumps under the reader: 462 px, in the spec beside this page.
The fix is arithmetic, and the window is the thing that can do it. It was handed over once through windowRef, and it is read, never driven:
range.firstis the index at the top of the mounted window,offsetOf(index)is where an index sits in the content, mounted or not,- so the offset the reader should be at afterwards is where that same entry has moved to, plus how far past it they already were.
This gets close rather than exact: 28 px in the spec, against the 462 of leaving it alone. It cannot be exact, because the ten entries that arrived have never been mounted and nothing knows how tall they are. The offset moves by what the window assumes them to be, and the remainder settles as they are measured. Entries of a fixed height have no such gap.
The jump asks for more than there is
totalExtent() is how tall the window currently believes the feed to be, so writing it into the offset asks to scroll past the end. The engine clamps a scroll offset to the content on the next layout, and scrollPosition reports the offset the list actually ended up at, which goes straight back into the same cell. The screen therefore never has to work out where the bottom is: it asks for too much and reads back what it got. In the spec that settles on 5,282 px, which is the content less the 200 of the viewport, with the newest entry mounted.
That pair, a bound scrollY and scrollPosition writing back into it, is the general shape for owning a scroll offset, and it is how a container is put back where it was after a route change too. A modifier cannot do it: a modifier writes through the override cascade, and an override would shadow every wheel for the life of the node.
What this page has checked
The spec beside the example drives the real runtime with a fake canvas and asserts what is claimed above: that 120 entries mount eight rows and claim 5,517 px of scroll range with only those eight measured; that three more entries change what the mounted rows announce without adding any; that appending leaves the entry under the eye at exactly the same pixel; that a naive prepend moves it 462 px and an anchored one 28 px; and that the jump settles on the content height less the viewport, with the newest entry mounted.
Those pixel figures come from the test text measurer rather than from a browser, so the exact drift and the exact window size differ in a real one. The direction and the order of magnitude do not: the anchored load is out by a fraction of an entry and the naive one by the ten entries that arrived.
The live example above is Canvas2D unless you asked this site for the other renderer, and Chrome is the extent of what any of it has been opened in.
A feed like this is keyboard-reachable only in so far as it scrolls: the entries here are not focusable and there is no selection. LazyList is what adds those, and keyboard operability is what it has to satisfy.
Next
Virtualization is the window in full, the estimate, the corrections and what a frame costs. Overflow and scrolling is the scroll container the feed is one of.