skip to content

A Flutter job feed jumps to the top on every reload and shifts when new postings are prepended; how do you keep the user's scroll position?

level: seniorimportance: should knowfreq 28%

answer

  1. do not unmount the scroll view
  2. offset is pixels, not an item
  3. new position starts at initialScrollOffset
  4. PageStorage needs a PageStorageKey
  5. CustomScrollView center for prepends

basics

~20 s

Keep the list mounted during reloads instead of swapping it for a spinner, because a new scroll position starts at initialScrollOffset. For prepended items, use a CustomScrollView whose center is the existing items' sliver, so new items grow upward without moving the view.

solid answer

~50 s

Two separate bugs hide behind 'the list jumps'. The first is a reload that swaps the `ListView` for a full-screen spinner: the `Scrollable` and its `ScrollPosition` are disposed, and the new one starts at the controller's `initialScrollOffset`, `0.0`, unless a `PageStorageKey` on the path let `PageStorage` save the offset. The fix is to keep the list on screen and show progress with `RefreshIndicator` or the footer. The second is that the offset is measured in **pixels, not items**: appending below is harmless, but inserting new postings at index 0, or rows above the viewport changing height, leaves the offset where it was while the content under it moves. For prepends I use a `CustomScrollView` with `center` set to the key of the sliver holding the existing postings and put new ones in a sliver before it; slivers before the center grow upward into negative offsets, so what the user is reading stays put.

code

dart · 33 lines
dart
// Inside a State: _newer holds postings that arrived above the original top,
// _older holds the first page and every load-more page.
static const _centerKey = ValueKey<String>('job-feed-center');

@override
Widget build(BuildContext context) {
  return CustomScrollView(
    controller: _controller,
    center: _centerKey,
    slivers: [
      // Grows upward: index 0 sits just above the center sliver.
      SliverList.builder(
        itemCount: _newer.length,
        itemBuilder: (context, i) => ListTile(title: Text(_newer[i].title)),
      ),
      SliverList.builder(
        key: _centerKey,
        itemCount: _older.length + (_hasMore ? 1 : 0),
        itemBuilder: (context, i) => i < _older.length
            ? ListTile(title: Text(_older[i].title))
            : const Padding(
                padding: EdgeInsets.all(16),
                child: Center(child: CircularProgressIndicator()),
              ),
      ),
    ],
  );
}

void _prependNewest(List<Job> newestFirst) {
  // Oldest of the new batch goes nearest the center.
  setState(() => _newer.addAll(newestFirst.reversed));
}

go deeper

for a junior

Remember not to replace the list with a spinner during a reload; keep the items on screen and let RefreshIndicator or the footer show progress.

for a middle

Explain that the offset is in pixels, that a new ScrollPosition starts at initialScrollOffset, and why inserting at index 0 moves the content under the user.

for a senior

Diagnose which jump you are facing, keep the Scrollable mounted, reserve row heights, and anchor prepends with a CustomScrollView center sliver.

for a principal

Decide how a live feed treats arrivals while the user reads: insert silently, anchor them above, or hold them behind a 'new postings' prompt, and apply it everywhere.

## Two different jumps Users describe both as 'the list jumped', but they have different causes and fixes: | Symptom | Cause | Fix | |---|---|---| | back at the top after every reload | the scroll view was unmounted and a fresh `ScrollPosition` created | keep the list mounted; show loading inside it | | content slides down while reading | rows were inserted above the viewport; the pixel offset did not change | prepend into a sliver before a `CustomScrollView` `center` | | small jumps while scrolling up | rows above the viewport change height as images load | give rows a fixed or reserved height | | back at the top after a navigation or tab switch | the scroll view was rebuilt and nothing saved the offset | a `PageStorageKey` so `PageStorage` restores it | ## Why a reload loses the position A **`ScrollPosition`** holds the current offset in `pixels`. It belongs to the `Scrollable` that a `ListView` builds, and it is created when that `Scrollable` is first built. A common build method reads: - if loading, return a `Center` with a `CircularProgressIndicator`; - otherwise, return the `ListView`. During a reload the `ListView` leaves the tree, its `Scrollable` is disposed, and when the data comes back a new one is created. The new position starts at the controller's `initialScrollOffset`, `0.0` by default. `ScrollController.keepScrollOffset` (true by default) asks the position to save and restore its offset through `PageStorage`, but in the Flutter 3.47 source `PageStorageBucket.writeState` saves nothing unless a `PageStorageKey` sits between the scroll view and the `PageStorage`, so without one there is nothing to restore. The robust fix is not to unmount the list at all: 1. show the old items while a refresh runs, with `RefreshIndicator` providing the spinner; 2. show the initial full-screen spinner only when there are no items yet; 3. apply new data to the same list widget, so the same `Scrollable` and `ScrollPosition` survive; 4. keep the scroll view's own key stable, because a new key makes a new element and a new position. ## The offset is pixels, not an item Flutter's scroll offset says how many logical pixels the viewport is from the start of the content. It does not remember which posting was on screen. Consequences: - **Appending below the viewport** (load-more) changes `maxScrollExtent` but not `pixels`, so nothing visible moves. - **Replacing the list in the background** while the user is mid-feed keeps the same pixel offset over different content. - **Inserting at index 0** pushes every existing row down by the inserted height, while the offset stays the same, so the posting being read slides out of view. - **Rows above the viewport growing**, for example images loading without a reserved size, have the same effect in smaller steps. ## Anchoring prepended items with a center sliver A `CustomScrollView` has a `center` parameter, the key of one of its slivers. That sliver starts at scroll offset zero (with the default `anchor` of `0.0`), slivers after it grow in the normal direction, and **slivers before it grow in the opposite direction**, into negative scroll offsets. So for a feed of job postings loaded 25 at a time: 1. put the postings from the first load and every load-more page in a `SliverList` whose key is the `center`; 2. put postings that arrive later at the top in a second `SliverList` placed before it; 3. in that earlier sliver, index `0` sits just above the center, so append new postings in oldest-first order. Adding rows to the earlier sliver extends the content upward while the offset, and so the visible posting, stays where it was. A 'new postings' chip can then scroll the user up when they choose. ## Reserving heights Rows that change height after first layout move everything below them. Give images a fixed aspect ratio or placeholder of the final size, or give the list a fixed row height where the design allows.

  • Why does a PageStorageKey matter if ScrollController.keepScrollOffset is already true?
    keepScrollOffset makes the position try to save and restore its offset through `PageStorage`, but the bucket identifies the entry by the `PageStorageKey`s on the path to the scroll view. With none, `writeState` stores nothing, so a recreated scroll view starts at `initialScrollOffset`. It helps for tabs and navigation; for a reload, keeping the list mounted is simpler and more reliable.
  • Does appending a load-more page ever move what the user sees?
    Not by itself: the new rows go below the viewport, `maxScrollExtent` grows and `pixels` stays. Movement comes from elsewhere, such as the list being rebuilt under a new key, rows above the viewport changing height, or de-duplication removing rows that were already on screen.

saying these in an interview costs you the question

  • Show a full-screen spinner instead of the ListView while a refresh runs.
  • Flutter keeps the same item in view when rows are inserted above it.
  • keepScrollOffset alone restores the offset after the scroll view is rebuilt.
  • Appending a load-more page shifts the rows already on screen.
  • Use reverse: true on the ListView to prepend items without a jump.