Scroll restoration
The user scrolls halfway down a list of sixty devices, opens one, comes back — and lands at the top of an empty list with a spinner, then at the top of the full list. Two things went wrong there, and only one of them is about data.
The data half is solved by the cache. Coming back to a list whose query
is still cached is instant: the first build after navigating back already
has the rows — a QuerySuccess on the first frame, no spinner and no empty
frame. That is what makes restoring a scroll position possible at all: a
saved offset into a list that is still loading has nothing to point at, and
Flutter clamps it to zero.
If the data is stale, it is refetched behind the rows, and structural sharing keeps the instances of the rows that did not change, so the list does not jump or rebuild what is the same.
The position half is Flutter's. What you need to do depends on how the list went away.
Pushed routes: nothing to do
Navigator.push keeps the route below alive, its widgets and its scroll
position included. Popping back to the list needs nothing from you or from
the cache. (The list's query still has a reader the whole time, so it is not
even collected.)
Tabs, page views, switched bodies: PageStorageKey
A TabBarView, a PageView, or a screen that swaps its body builds the list
again from scratch when it comes back. Give the scrollable a
PageStorageKey, and Flutter's PageStorage keeps its offset while it is
gone and puts it back when it is built again — with its rows, since they
come from the cache:
class DeviceList extends StatelessWidget {
const DeviceList({super.key, required this.kind});
final String kind;
Widget build(BuildContext context) {
final devices = context.query(devicesOfKind(kind));
final rows = devices.dataOrNull;
if (rows == null) {
return const Center(child: CircularProgressIndicator());
}
return ListView.builder(
// Where PageStorage files this list's offset.
key: PageStorageKey<String>('devices-$kind'),
itemCount: rows.length,
itemBuilder: (context, index) => DeviceTile(rows[index]),
);
}
}
class DeviceTabs extends StatelessWidget {
const DeviceTabs({super.key});
Widget build(BuildContext context) => DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
bottom: const TabBar(
tabs: <Widget>[Tab(text: 'Lights'), Tab(text: 'Shutters')],
),
),
body: const TabBarView(
children: <Widget>[
DeviceList(kind: 'light'),
DeviceList(kind: 'shutter'),
],
),
),
);
}
Two details matter:
- The spinner has no key. While the rows are loading there is no list to
restore; the
ListViewwith the key is built only once there is data. The first time, that is after the fetch; every time after, it is the first frame. - One key per list. Each tab's list files its offset under its own key
(
devices-light,devices-shutter), so switching tabs does not hand one list the other's position.
AutomaticKeepAliveClientMixin is the other way to keep a tab's position: it
keeps the whole tab alive while it is off screen. That costs the widgets'
memory, and the tab's queries keep a reader — so they keep polling and
refetching on focus. With a PageStorageKey, the tab is gone, its queries
have no reader, and it is rebuilt from the cache when it returns.
How long the rows are there
The cache keeps a query without readers for its gcTime — five minutes by
default — before collecting it; see
garbage collection. Come back within that
and the rows are there on the first frame. Come back later and the list
loads from scratch: the spinner shows first, and the saved offset is applied
when the keyed list is built with the new rows — a jump the user sees. An
infinite query is back to its first page then, so an offset further down is
clamped to what that page holds.
For a list the user returns to after longer breaks, raise the gcTime on
that query. An infinite query keeps every page it had
loaded — up to maxPages — so a long scrolled list comes back to its full
length, not to its first page.
Across app restarts: state restoration
When the operating system ends a backgrounded app and the user returns to
it, Flutter's state restoration can bring the scroll position back: a
restorationScopeId on the app, a restorationId on the scrollable. The
data does not come back with it — the cache lives in memory — so the list
loads, and the scrollable takes its restored offset when it is built — so,
as with a PageStorageKey, build it only once the rows are there. An
infinite query starts again from its first page. There is no persistence of
the cache in 1.0.
Try it: in the screen below, press Load more a couple of times, then press
Go to about. The list is unmounted — its query has no reader — and Back
to list rebuilds it with every page on the first frame and no new request.
This demo's list has no PageStorageKey, so it comes back at the top: the
rows are the cache's half, the position is the part this page adds.
The web has the browser's own scroll restoration, which works when the data
is cached and the first render has it; TanStack's guide says as much. In
Flutter the position is PageStorage's or state restoration's, and the
cache supplies the same thing — the rows on the first build. See
differences from TanStack Query.