anchored_list
Jump to any index in a lazy list, instantly — and hold your place when new items arrive above it.
final controller = AnchoredListController();
AnchoredList.builder(
controller: controller,
itemCount: 1000000,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
);
controller.jumpToIndex(842013); // same cost as jumping to item 3

The problem
A lazy list cannot say where item 842,013 begins, because it has never built
the 842,012 items above it and does not know how tall they are. Flutter offers
no answer, and the two packages that did are both abandoned —
scrollable_positioned_list was archived by Google, and scroll_to_index was
last published in 2022. Between them they still serve over a million downloads
a month.
How this differs
The archived approach builds a second complete list anchored at the target and cross-fades to it. That is why jumping keeps two sets of children alive and why the transition is visible.
This uses the primitive Flutter already has for the job. A viewport can nominate a centre sliver, and content before it lays out at negative scroll offset. So the list splits in two at the anchor — items before it in one sliver, the anchor and everything after in another, marked as the centre. Offset zero is the anchor.
Jumping is then just re-splitting. Nothing above the anchor is built, nothing is measured, and nothing fades.
The readouts in the GIF above are live, and they are the whole argument. Every
call to itemBuilder is counted:
| rows mounted | itemBuilder calls |
|
|---|---|---|
| opened at row 0 | 12 | 12 |
| jumped 250,000 rows | 17 | +17 |
| jumped 367,432 more | 17 | +17 |
| jumped to the last row | 6 | +6 |
| back to row 3 | 15 | +15 |
Sixty-seven builder calls to cross a million-row list four times. The cost of a jump is the cost of one screenful, whatever the distance — and the test suite pins that, so it stays true.
Scrolling backwards past the anchor runs the viewport at a negative offset, which is ordinary framework behaviour rather than a special case:

Older messages arriving at the top
Prepend a page of history to a normal list and every index shifts by a page, so what the reader was looking at slides down and off the screen. The usual answers are to measure the inserted items and subtract their height, or to invert the whole list and think upside down forever.
Here the anchor is an index, so the correction is arithmetic on one integer, and the scroll offset is never touched — the pixels do not move at all, even mid-item.
setState(() => messages.insertAll(0, older));
controller.itemsInsertedAbove(older.length);

Both panels are showing the same message and receive the same five inserts three
times over. The left one is a ListView. There is an itemsRemovedAbove for
trimming history off the top, and items added below the anchor need no call at
all, because their indices do not change.
Reaching the scroll position
The list will use a ScrollController you supply, and hands back whichever one
it is using. That covers everything index-based movement does not — attaching a
Scrollbar, page-up/page-down, linking two lists, reading raw offsets.
final scroll = ScrollController();
Scrollbar(
controller: scroll,
child: AnchoredList.builder(scrollController: scroll, ...),
);
One thing to know: offsets are measured from the anchor, not the start of the
list. Zero means "the anchor is at the leading edge", and content above it sits
at negative offset — that is exactly what makes a jump constant time.
minScrollExtent is therefore negative whenever the anchor is not item 0.
Separators, and the rest of the ListView API
AnchoredList.separated takes a separatorBuilder on the same terms as
ListView.separated. AnchoredList(children: [...]) exists for short lists.
padding, physics, reverse, scrollDirection, cacheExtent,
semanticChildCount, dragStartBehavior, keyboardDismissBehavior,
scrollBehavior, clipBehavior, restorationId, findChildIndexCallback and
the three add* flags all behave as they do on ListView.
Two of those are worth calling out, because the split into two slivers could easily have broken them and silently did not:
paddingis divided between the slivers rather than applied to both, which would double the inset at the anchor.- semantic indexes are translated, so items above the anchor announce their real list index instead of counting backwards from it.
Where each item is
ValueListenableBuilder(
valueListenable: controller.itemPositions,
builder: (context, positions, _) {
final first = positions.firstWhere((p) => p.isVisible);
return Text('Showing item ${first.index}');
},
);
leadingEdge and trailingEdge are fractions of the viewport — 0 is the top,
1 the bottom — so a scrollbar label, a sticky section header, or edge-triggered
paging all fall out of it directly.
Alignment
controller.jumpToIndex(500, alignment: 0.5); // centred
await controller.animateToIndex(520); // smooth
animateToIndex scrolls normally when the target is already built. When it is
far outside the built range there is nothing to scroll through, so the list
re-anchors near the target and animates the last stretch. That reads as a fast
scroll rather than a cross-fade.
What this does not do
No shrinkWrap. Flutter asserts !shrinkWrap || center == null, so a
centre-anchored viewport can never size itself to its content. The parameter is
absent rather than present and broken. If you need shrink-wrapping, use
ListView(shrinkWrap: true) and give up constant-time jumps — that trade runs
the other way, and scrollable_positioned_list makes it, which is the one thing
its design does better.
Not a sliver. It owns its viewport, because owning the viewport is what
makes the centre trick work, so it cannot be nested in another
CustomScrollView.
Both follow from the same decision. If they matter more to you than jump cost, this is the wrong widget, and the README would rather say so than have you find out in a layout assertion. They are also, between them, the two most-requested features on the archived package's tracker — so if that is what brought you here, this is not the successor you want.
License
MIT © K M Shahriar Hossain
Libraries
- anchored_list
- Scroll to any index in a lazy list, instantly and without glitches.