skip to content

In Flutter, how does a PageStorageKey let a list keep its scroll offset when the user leaves a tab and comes back?

level: middleimportance: nice to knowfreq 25%

answer

  1. each route owns a PageStorageBucket
  2. offset saved when a scroll ends
  3. restored when the position is created
  4. key chain identifies the slot
  5. keepScrollOffset defaults to true

basics

~20 s

When a scroll ends, the ScrollPosition writes its pixels into the route's PageStorageBucket, under an identifier built from the PageStorageKeys above it. When the list is rebuilt, the new position reads that value back. Distinct keys give each tab's list its own slot.

solid answer

~40 s

Each `ModalRoute` wraps its page in a `PageStorage` with a `PageStorageBucket`. With the controller's `keepScrollOffset` at its default `true`, a `ScrollPosition` calls `saveScrollOffset` when a scroll ends, writing `pixels` to the bucket, and `restoreScrollOffset` when a new position is created. The storage identifier is the chain of `PageStorageKey`s from the scroll view up to the nearest `PageStorage`. In the pinned 3.47 source an empty chain is neither written nor read, so a list with no `PageStorageKey` on itself or an ancestor restores nothing. Giving each tab's list a distinct key like `PageStorageKey<String>('thread-replies')` makes the offset survive the tab being disposed and rebuilt. It lasts as long as the route; surviving process death is state restoration's job.

code

dart · 26 lines
dart
import 'package:flutter/material.dart';

class ThreadTabsBody extends StatelessWidget {
  const ThreadTabsBody({super.key, required this.replies, required this.media});

  final List<String> replies;
  final List<String> media;

  @override
  Widget build(BuildContext context) {
    return TabBarView(
      children: [
        ListView.builder(
          key: const PageStorageKey<String>('thread-replies'),
          itemCount: replies.length,
          itemBuilder: (context, i) => ListTile(title: Text(replies[i])),
        ),
        ListView.builder(
          key: const PageStorageKey<String>('thread-media'),
          itemCount: media.length,
          itemBuilder: (context, i) => ListTile(title: Text(media[i])),
        ),
      ],
    );
  }
}

go deeper

for a junior

Remember that putting a PageStorageKey on a list in a tab lets it return to the same scroll position.

for a middle

Explain the route's bucket, the key chain identifier, and when offsets are saved and restored.

for a senior

Pick between PageStorage, keep-alive and state restoration, and catch colliding or unstable keys.

for a principal

Define which scroll positions the product promises to remember and at what level, from tab switch to relaunch.

## The problem it solves A forum thread screen has two tabs, *Replies* and *Media*, in a `TabBarView`. Switching tabs disposes the off-screen tab's list, and when the user comes back a brand-new `ListView` is built — starting at the top unless something remembers the offset. Keeping the whole tab alive is one answer (a keep-alive mixin); **`PageStorage`** is the lighter one: remember a number, rebuild the list, put the number back. ## The parts - **`PageStorage`** — an inherited widget holding a **`PageStorageBucket`**, a map from identifiers to arbitrary values. Every `ModalRoute` inserts one around its page, so each route has its own bucket that lives as long as the route. - **`PageStorageKey<T>`** — a `ValueKey` subclass. Placed on a widget, it contributes to the identifier under which descendants store state. - **`ScrollPosition`** — saves and restores its own `pixels` through the nearest `PageStorage`. - **`ScrollController.keepScrollOffset`** — defaults to `true`; set it to `false` to opt out. ## The save and restore cycle 1. The user scrolls the *Replies* list and lets go. When the scroll activity ends, `ScrollPosition.didEndScroll` calls `saveScrollOffset`, which calls `writeState` on the bucket with the current `pixels`. 2. The bucket builds an identifier from every `PageStorageKey` on the element chain from the list up to the `PageStorage`. 3. The user switches to *Media*; the *Replies* list is disposed. 4. Switching back builds a new list and a new `ScrollPosition`. Its constructor calls `restoreScrollOffset`, which reads the bucket with the same identifier and, if a value is found and no pixels are set yet, applies it. ## Why the key is not optional The framework docs call a `PageStorageKey` *recommended* to disambiguate scroll views. The pinned Flutter 3.47 source is stricter: `PageStorageBucket.writeState` and `readState` skip an identifier whose key chain is **empty**. So a list with no `PageStorageKey` on itself or on any ancestor below the route's `PageStorage` saves nothing and restores nothing. With keys, the identifier is the whole chain, so two lists keyed `'replies'` in different parents stay distinct, while two siblings with the same key value collide. Key values must be **stable across rebuilds**: a string or an id, never `UniqueKey()` or a freshly created object whose identity changes each time. ## What it is not | Mechanism | Keeps | Survives | |---|---|---| | `PageStorage` with `PageStorageKey` | a small value such as the scroll offset | widget disposal inside the same route | | keep-alive of list items or tab pages | the whole subtree and its `State` | scrolling or tab switches, at memory cost | | state restoration (`restorationId`) | serialized values | the OS killing and restoring the app | ## Pitfalls - **Same key in two places at once** — both lists read and overwrite one slot. - **Controller with `keepScrollOffset: false`** — nothing is saved, whatever the key. - **`initialScrollOffset` expected to win** — once an offset is saved, the restored value takes priority; `initialScrollOffset` is used only the first time. - **A pushed route** — a new route has a new bucket, so opening the same thread again from the list starts fresh; storing positions across navigations needs your own state. - **Lazy lists with changing content** — a restored offset points at whatever row is now there; if items were inserted above, the reader lands elsewhere.

  • Does a PageStorageKey keep the scroll offset after the app is killed and relaunched?
    No. The bucket belongs to the route and lives in memory. Surviving process death is state restoration: give the scroll view a `restorationId` inside a restoration scope, and the framework serializes the offset with the rest of the restorable state.
  • Two tabs use ListViews with PageStorageKey('list'); what goes wrong?
    If they sit under the same chain of keys, both compute the same identifier, so they share one slot: scrolling one overwrites the other's saved offset, and each restores the other's position. Give every scroll view a distinct, stable key value.

saying these in an interview costs you the question

  • Offsets are restored even when no PageStorageKey is present.
  • PageStorage persists the offset to disk across app restarts.
  • A UniqueKey works as well as a PageStorageKey.
  • initialScrollOffset always wins over a saved offset.
  • PageStorage keeps the whole tab's widget state alive.