Flutter Scroll to Index: jumpTo Wants Pixels, Not a Row Number
I added a "jump to entry" box to an audit log viewer last month. Paste an entry id and the list is supposed to scroll to that index. It took two tries, and neither failure was about the jump itself.
The first version threw inside initState. The second ran fine and parked me a screen away from the row I asked for. Both came from one bad assumption about how Flutter measures a scroll position, which is that an offset and a row number are the same kind of thing.
A controller is a handle, not a position
ScrollController keeps a list of the positions that have attached to it. That list is empty until a Scrollable actually gets built, and initState runs before any of that. So my first line of controller code was illegal.
@override
void initState() {
super.initState();
// Nothing is attached yet, so this asserts.
_controller.jumpTo(0); // ScrollController not attached to any scroll views.
}
The docs name the guard outright. When hasClients is false, position, offset, animateTo and jumpTo must not be called. If a row genuinely needs the controller on the first frame, wait a frame:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_controller.hasClients) return;
_controller.jumpTo(target);
});
That message is an assert, so it does not survive into a release build. The getter behind it ends at _positions.single, so production gives you Bad state: No element instead. Same bug, less helpful sentence. The next line covers one controller attached to two scrollables, which is how a tablet layout with a list and a side panel sharing a controller fails.
jumpTo measures pixels
The signatures give it away. jumpTo(double value), animateTo(double offset, {duration, curve}). There is no index anywhere, because a scroll position is a pixel measurement and the list has no idea what a row is.
Uniform rows let you do the arithmetic yourself, and telling the list about the uniformity makes it exact:
ListView.builder(
controller: _controller,
itemCount: entries.length,
itemExtent: 48,
itemBuilder: (context, index) => LogRow(entry: entries[index]),
)
_controller.jumpTo(index * 48);
itemExtent pins each row to that height, and underneath it swaps a SliverList for a SliverFixedExtentList, which derives offsets by multiplication instead of laying out children it has never seen.
None of it holds once one row wraps to two lines. A stack trace expanded inline. A device name longer than anything you tested with. From that row down, every multiplication is wrong and the error grows with the distance you jump.
The arithmetic is fine. The assumption under it is not.
Timing is the other half of this. Even after a position attaches, the extents are not known yet, and the onAttach callback says so directly, pointing at hasContentDimensions for the moment maxScrollExtent becomes real.
The row you want has never been built
The row I was jumping to might sit 400,000 items down. It has never been built, so there is no element, no render object, and nothing for Scrollable.ensureVisible to accept as a BuildContext. That is the whole point of a lazy list.
Estimating and correcting is the workaround everyone reaches for, and in production it looks like this: the list arrives near the target and then shuffles, because the correction lands after paint. Users read the wrong rows first.
After the second attempt I stopped hunting for an offset.
Move offset zero onto the row you want
CustomScrollView takes a center key, and it does something I had only seen used for chat feeds that grow upwards. Slivers listed before the center sliver are laid out in the opposite direction from the scroll direction, at negative offsets. Slivers after it grow normally. The sliver carrying the center key sits at scroll offset zero, by construction.
Which means you get to decide what zero means. Split the list at the row you want on screen and rebuild:
final _anchorKey = UniqueKey();
int _anchor = 0;
CustomScrollView(
controller: _controller,
center: _anchorKey,
slivers: [
// Rows above the anchor. These grow backwards into negative offsets.
SliverList.builder(
itemCount: _anchor,
itemBuilder: (context, i) => LogRow(entry: entries[_anchor - 1 - i]),
),
// The anchor row, and everything below it.
SliverList.builder(
key: _anchorKey,
itemCount: entries.length - _anchor,
itemBuilder: (context, i) => LogRow(entry: entries[_anchor + i]),
),
],
)
void jumpToRow(int index) {
setState(() => _anchor = index);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_controller.hasClients) _controller.jumpTo(0);
});
}
Nothing above the target gets measured or built, so a jump to row 400,000 costs what a jump to row 3 costs. The trade is a rebuild instead of an animation. You cannot animate across a distance nobody computed.
Two constraints belong to the scroll view. center cannot be combined with shrinkWrap: true, and anchor defaults to 0.0, which puts the leading edge of the center sliver at the leading edge of the viewport. Raising it toward 1.0 moves the anchor row down the screen when you want context above it.
The constraint that bit me lives on the controller. keepScrollOffset defaults to true, and the docs are explicit that a saved offset gets restored when the scrollable is recreated while initialScrollOffset is then ignored. If your jump view comes back from a tab switch sitting on an old offset, that setting did it.
The two packages that used to handle this
You are not the first person to want this. The community shipped packages for it years ago, and the catch is where those packages are now.
scrollable_positioned_list sits at 0.3.8, published in May 2023, and lives in google/flutter.widgets, archived in August 2024. It still pulls roughly 714,000 downloads a month. scroll_to_index last shipped 3.0.1 in May 2022 and pulls around 470,000. Close to 1.2 million downloads a month through code with no maintainer.
What I settled on
The viewer guards the controller with hasClients and uses the anchor rebuild for the jump box. The estimate-and-correct code is gone, and so is the shuffling.
One guard clause does more work than the rest of it, so it lives in Snippet Ark instead of being retyped:
double? get _maxExtent {
if (!_controller.hasClients) return null;
final position = _controller.position;
if (!position.hasContentDimensions) return null;
return position.maxScrollExtent;
}
Every scroll bug I have shipped in Flutter came from reading a scroll metric before it was ready. That helper turns the unreadiness into a value instead of an exception. The flat-list performance problems on the same screen are a separate story, and I wrote them up after a tester filed a bug report about a slideshow.