[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-flutter-scroll-to-index-jumpto-pixels":3},{"content":4,"lastModified":5},"\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>The first version threw inside \u003Ccode>initState\u003C\u002Fcode>. 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.\u003C\u002Fp>\n\n\u003Ch2>A controller is a handle, not a position\u003C\u002Fh2>\n\n\u003Cp>\u003Ccode>ScrollController\u003C\u002Fcode> keeps a list of the positions that have attached to it. That list is empty until a \u003Ccode>Scrollable\u003C\u002Fcode> actually gets built, and \u003Ccode>initState\u003C\u002Fcode> runs before any of that. So my first line of controller code was illegal.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-dart\">@override\nvoid initState() {\n  super.initState();\n  \u002F\u002F Nothing is attached yet, so this asserts.\n  _controller.jumpTo(0);  \u002F\u002F ScrollController not attached to any scroll views.\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The docs name the guard outright. When \u003Ccode>hasClients\u003C\u002Fcode> is false, \u003Ccode>position\u003C\u002Fcode>, \u003Ccode>offset\u003C\u002Fcode>, \u003Ccode>animateTo\u003C\u002Fcode> and \u003Ccode>jumpTo\u003C\u002Fcode> must not be called. If a row genuinely needs the controller on the first frame, wait a frame:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-dart\">WidgetsBinding.instance.addPostFrameCallback((_) {\n  if (!_controller.hasClients) return;\n  _controller.jumpTo(target);\n});\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>That message is an \u003Ccode>assert\u003C\u002Fcode>, so it does not survive into a release build. The getter behind it ends at \u003Ccode>_positions.single\u003C\u002Fcode>, so production gives you \u003Ccode>Bad state: No element\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>jumpTo measures pixels\u003C\u002Fh2>\n\n\u003Cp>The signatures give it away. \u003Ccode>jumpTo(double value)\u003C\u002Fcode>, \u003Ccode>animateTo(double offset, {duration, curve})\u003C\u002Fcode>. There is no index anywhere, because a scroll position is a pixel measurement and the list has no idea what a row is.\u003C\u002Fp>\n\n\u003Cp>Uniform rows let you do the arithmetic yourself, and telling the list about the uniformity makes it exact:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-dart\">ListView.builder(\n  controller: _controller,\n  itemCount: entries.length,\n  itemExtent: 48,\n  itemBuilder: (context, index) =&gt; LogRow(entry: entries[index]),\n)\n\n_controller.jumpTo(index * 48);\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>\u003Ccode>itemExtent\u003C\u002Fcode> pins each row to that height, and underneath it swaps a \u003Ccode>SliverList\u003C\u002Fcode> for a \u003Ccode>SliverFixedExtentList\u003C\u002Fcode>, which derives offsets by multiplication instead of laying out children it has never seen.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>The arithmetic is fine. The assumption under it is not.\u003C\u002Fp>\n\n\u003Cp>Timing is the other half of this. Even after a position attaches, the extents are not known yet, and the \u003Ccode>onAttach\u003C\u002Fcode> callback says so directly, pointing at \u003Ccode>hasContentDimensions\u003C\u002Fcode> for the moment \u003Ccode>maxScrollExtent\u003C\u002Fcode> becomes real.\u003C\u002Fp>\n\n\u003Cfigure>\n  \u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F577585\u002Fpexels-photo-577585.jpeg?auto=compress&cs=tinysrgb&w=1200\" alt=\"A dark code editor filling a monitor with panels of syntax-highlighted text, photographed through a pair of eyeglasses\" loading=\"lazy\" \u002F>\n\u003C\u002Ffigure>\n\n\u003Ch2>The row you want has never been built\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>Scrollable.ensureVisible\u003C\u002Fcode> to accept as a \u003Ccode>BuildContext\u003C\u002Fcode>. That is the whole point of a lazy list.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>After the second attempt I stopped hunting for an offset.\u003C\u002Fp>\n\n\u003Ch2>Move offset zero onto the row you want\u003C\u002Fh2>\n\n\u003Cp>\u003Ccode>CustomScrollView\u003C\u002Fcode> takes a \u003Ccode>center\u003C\u002Fcode> 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 \u003Ccode>center\u003C\u002Fcode> key sits at scroll offset zero, by construction.\u003C\u002Fp>\n\n\u003Cp>Which means you get to decide what zero means. Split the list at the row you want on screen and rebuild:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-dart\">final _anchorKey = UniqueKey();\nint _anchor = 0;\n\nCustomScrollView(\n  controller: _controller,\n  center: _anchorKey,\n  slivers: [\n    \u002F\u002F Rows above the anchor. These grow backwards into negative offsets.\n    SliverList.builder(\n      itemCount: _anchor,\n      itemBuilder: (context, i) =&gt; LogRow(entry: entries[_anchor - 1 - i]),\n    ),\n    \u002F\u002F The anchor row, and everything below it.\n    SliverList.builder(\n      key: _anchorKey,\n      itemCount: entries.length - _anchor,\n      itemBuilder: (context, i) =&gt; LogRow(entry: entries[_anchor + i]),\n    ),\n  ],\n)\n\nvoid jumpToRow(int index) {\n  setState(() =&gt; _anchor = index);\n  WidgetsBinding.instance.addPostFrameCallback((_) {\n    if (_controller.hasClients) _controller.jumpTo(0);\n  });\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>Two constraints belong to the scroll view. \u003Ccode>center\u003C\u002Fcode> cannot be combined with \u003Ccode>shrinkWrap: true\u003C\u002Fcode>, and \u003Ccode>anchor\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>The constraint that bit me lives on the controller. \u003Ccode>keepScrollOffset\u003C\u002Fcode> defaults to true, and the docs are explicit that a saved offset gets restored when the scrollable is recreated while \u003Ccode>initialScrollOffset\u003C\u002Fcode> is then ignored. If your jump view comes back from a tab switch sitting on an old offset, that setting did it.\u003C\u002Fp>\n\n\u003Ch2>The two packages that used to handle this\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>\u003Ccode>scrollable_positioned_list\u003C\u002Fcode> sits at 0.3.8, published in May 2023, and lives in \u003Ccode>google\u002Fflutter.widgets\u003C\u002Fcode>, archived in August 2024. It still pulls roughly 714,000 downloads a month. \u003Ccode>scroll_to_index\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>What I settled on\u003C\u002Fh2>\n\n\u003Cp>The viewer guards the controller with \u003Ccode>hasClients\u003C\u002Fcode> and uses the anchor rebuild for the jump box. The estimate-and-correct code is gone, and so is the shuffling.\u003C\u002Fp>\n\n\u003Cp>One guard clause does more work than the rest of it, so it lives in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> instead of being retyped:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-dart\">double? get _maxExtent {\n  if (!_controller.hasClients) return null;\n  final position = _controller.position;\n  if (!position.hasContentDimensions) return null;\n  return position.maxScrollExtent;\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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 \u003Ca href=\"\u002Fposts\u002Fflutter-listview-jank-scroll-performance-fixes\u002F\">bug report about a slideshow\u003C\u002Fa>.\u003C\u002Fp>\n","2026-10-06",1791775771999]