Slivers & Scrolling
Lazy building is the whole point
Section titled “Lazy building is the whole point”A scrollable list might contain ten thousand items, but the screen shows twenty. Building all ten thousand would be catastrophic. The key idea of Flutter scrolling is lazy building: only the items near the visible area are built, and they are recycled as you scroll.
This is why the constructor you choose matters enormously:
// ❌ Builds ALL children up front — fine for a few, fatal for thousands.ListView(children: [ for (final item in items) Tile(item) ])
// ✅ Builds children on demand as they scroll into view.ListView.builder( itemCount: items.length, itemBuilder: (context, i) => Tile(items[i]),)Proven: ListView.builder really only builds what’s near the viewport
Section titled “Proven: ListView.builder really only builds what’s near the viewport”Not a claim to take on faith — a build counter proves it directly. A 300-tall viewport with 50-tall items should need roughly 6 visible plus a small buffer, out of a list of 1000:
import 'package:flutter/material.dart';import 'package:flutter/rendering.dart' show ScrollCacheExtent;
int builtCount = 0;final Set<int> builtIndices = {};
class LazyList extends StatelessWidget { const LazyList({super.key, this.scrollCacheExtent}); // ScrollCacheExtent (sealed: .pixels(n) / .viewport(multiplier)) — the // current API. The older `cacheExtent: double` parameter is deprecated, // see the callout below. final ScrollCacheExtent? scrollCacheExtent;
@override Widget build(BuildContext context) { return SizedBox( height: 300, child: ListView.builder( scrollCacheExtent: scrollCacheExtent, itemCount: 1000, itemExtent: 50, itemBuilder: (context, i) { builtCount++; builtIndices.add(i); return SizedBox(height: 50, child: Text('item $i')); }, ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/lazy_list.dart';
void main() { testWidgets('a 1000-item ListView.builder only builds items near a 300px viewport', (tester) async { builtCount = 0; builtIndices.clear();
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: LazyList())));
expect(builtCount, lessThan(20), reason: 'nowhere near all 1000 items were built'); expect(builtIndices.contains(0), isTrue, reason: 'the first visible item was built'); expect(builtIndices.contains(999), isFalse, reason: 'the far-off-screen last item was never built'); });}00:00 +0: a 1000-item ListView.builder only builds items near a 300px viewport00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ run on this Mac)
The real, measured count on this run was 11 of 1000 — the 6 items that actually fit the 300px viewport at 50px each, plus Flutter’s default cache buffer of pixels built just outside the visible edge (so scrolling a little doesn’t show a blank frame while new items build). builtIndices never contains 999 — the far end of the list was genuinely never touched. GridView.builder gives the identical laziness guarantee for grids.
Proven: scrollCacheExtent directly controls how far ahead Flutter pre-builds
Section titled “Proven: scrollCacheExtent directly controls how far ahead Flutter pre-builds”The cache is the buffer zone (in logical pixels, or a viewport-relative multiplier) built just outside the visible viewport on each side — provable by giving it a much larger explicit value and watching the built count grow correspondingly:
import 'package:flutter/material.dart';import 'package:flutter/rendering.dart' show ScrollCacheExtent;import 'package:flutter_test/flutter_test.dart';import 'package:my_app/lazy_list.dart';
void main() { testWidgets('a larger scrollCacheExtent builds proportionally more items ahead of the viewport', (tester) async { builtCount = 0; builtIndices.clear(); await tester.pumpWidget(const MaterialApp(home: Scaffold(body: LazyList()))); final defaultCount = builtCount;
builtCount = 0; builtIndices.clear(); await tester.pumpWidget( const MaterialApp(home: Scaffold(body: LazyList(scrollCacheExtent: ScrollCacheExtent.pixels(1000)))), ); final largeCount = builtCount;
expect(largeCount, greaterThan(defaultCount), reason: 'a 1000px cache buffer builds more items than the default'); expect(largeCount, lessThan(200), reason: 'still nowhere near the full 1000-item list — this is "how far ahead", not "build everything"'); });}00:00 +0: a larger scrollCacheExtent builds proportionally more items ahead of the viewport00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ run on this Mac)
Measured directly on this run: the default cache built 11 items; ScrollCacheExtent.pixels(1000) built 26 (300px visible + 1000px cache = 1300px of 50px items = 26). A larger cache extent means smoother fast-scrolling (more is ready ahead of time) at the cost of more memory and up-front build work — tune it, don’t guess at it.
The viewport and slivers
Section titled “The viewport and slivers”Under a scrollable is a viewport — the window you see — and inside it, slivers. A sliver is a scrollable region that knows how to lay itself out lazily against the viewport. ListView is a convenience that wraps a single sliver; when you need more than one scroll effect in one scroll view, you compose slivers yourself.
flowchart TB scroll["CustomScrollView"] --> vp["Viewport (visible window)"] vp --> s1["SliverAppBar (collapses on scroll)"] vp --> s2["SliverList (builds only visible tiles)"] vp --> offscreen["off-screen tiles: not built yet"]
Proven: CustomScrollView composes multiple sliver types — and each SliverList/SliverGrid stays independently lazy
Section titled “Proven: CustomScrollView composes multiple sliver types — and each SliverList/SliverGrid stays independently lazy”import 'package:flutter/material.dart';
int sliverBuiltCount = 0;
class CustomScrollDemo extends StatelessWidget { const CustomScrollDemo({super.key}); @override Widget build(BuildContext context) { return CustomScrollView( slivers: [ const SliverAppBar(floating: true, expandedHeight: 100, title: Text('Demo')), // SliverToBoxAdapter: drop a normal (non-sliver-aware) widget into a // sliver list — a one-off section header, banner, etc. const SliverToBoxAdapter(child: SizedBox(height: 40, child: Text('section header'))), SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisExtent: 50, ), delegate: SliverChildBuilderDelegate( (context, i) => SizedBox(height: 50, child: Text('grid $i')), childCount: 4, ), ), SliverList( delegate: SliverChildBuilderDelegate( (context, i) { sliverBuiltCount++; return SizedBox(height: 50, child: Text('row $i')); }, childCount: 1000, // same laziness claim, in sliver form ), ), ], ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/custom_scroll_demo.dart';
void main() { testWidgets('CustomScrollView composes SliverAppBar + SliverToBoxAdapter + SliverGrid + SliverList without error, staying lazy', (tester) async { sliverBuiltCount = 0; await tester.pumpWidget(const MaterialApp(home: Scaffold(body: CustomScrollDemo())));
expect(tester.takeException(), isNull); expect(sliverBuiltCount, lessThan(30), reason: 'the 1000-item SliverList.builder-style delegate stays lazy inside a CustomScrollView too'); });}00:00 +0: CustomScrollView composes SliverAppBar + SliverToBoxAdapter + SliverGrid + SliverList without error, staying lazy00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ run on this Mac)
Measured on this run: 13 of the 1000 SliverList rows were built — laziness is a property of the sliver, not of ListView specifically, which is why composing several slivers into one CustomScrollView still scrolls as one unit while each sliver independently manages its own lazy range. The mental model: a normal widget occupies a fixed box; a sliver occupies a scrollable extent and negotiates with the viewport about how much of it is visible. SliverAppBar, SliverList, SliverGrid, and SliverToBoxAdapter (to drop a regular widget into a sliver list) are the pieces you compose.
Proven: ScrollController + addPostFrameCallback — the real pattern for scrolling after first layout
Section titled “Proven: ScrollController + addPostFrameCallback — the real pattern for scrolling after first layout”A ScrollController cannot be told to scroll before the scrollable it’s attached to has actually laid itself out — its position doesn’t exist until after the first frame. addPostFrameCallback from initState is the standard way to run scroll logic exactly once, right after that first layout completes:
import 'package:flutter/material.dart';
class JumpToEndList extends StatefulWidget { const JumpToEndList({super.key}); @override JumpToEndListState createState() => JumpToEndListState();}
class JumpToEndListState extends State<JumpToEndList> { final controller = ScrollController();
@override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { controller.jumpTo(controller.position.maxScrollExtent); }); }
@override void dispose() { controller.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return SizedBox( height: 300, child: ListView.builder( controller: controller, itemCount: 50, itemExtent: 50, itemBuilder: (context, i) => SizedBox(height: 50, child: Text('item $i')), ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/scroll_controller_demo.dart';
void main() { testWidgets('addPostFrameCallback jumps to the bottom right after first layout', (tester) async { final key = GlobalKey<JumpToEndListState>(); await tester.pumpWidget(MaterialApp(home: Scaffold(body: JumpToEndList(key: key)))); await tester.pump(); // let the scheduled post-frame callback actually run
final controller = key.currentState!.controller; expect(controller.offset, controller.position.maxScrollExtent); expect(controller.offset, 2200.0, reason: '50 items * 50px = 2500 content height, minus the 300px viewport'); });}00:00 +0: addPostFrameCallback jumps to the bottom right after first layout00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ run on this Mac)
WidgetsBinding.instance.addPostFrameCallback is the general-purpose “run this once the current frame has actually finished” hook — the same tool used any time code needs to react to a completed layout (reading a final size/position, driving an animation’s first frame, or here, jumping a controller before the user ever sees the pre-jump position).
Proven: AutomaticKeepAliveClientMixin survives scrolling off-screen; plain State does not
Section titled “Proven: AutomaticKeepAliveClientMixin survives scrolling off-screen; plain State does not”By default, a list item’s State is disposed once it scrolls out of the built range (proven above — that’s exactly what makes laziness work). AutomaticKeepAliveClientMixin opts a specific item out of that: the framework keeps its Element/State alive in a special bucket even while it’s not part of the visible+cached range, and reattaches the same one when it scrolls back into view.
import 'package:flutter/material.dart';
int nextKeepAliveId = 0;int nextPlainId = 0;
/// Stamps an id once, in initState — proves whether the SAME State survives/// a round trip off-screen and back, exactly like the State-persistence/// proofs elsewhere in this course.class KeepAliveItem extends StatefulWidget { const KeepAliveItem({super.key}); @override KeepAliveItemState createState() => KeepAliveItemState();}
class KeepAliveItemState extends State<KeepAliveItem> with AutomaticKeepAliveClientMixin { late final int id;
@override void initState() { super.initState(); id = nextKeepAliveId++; }
@override bool get wantKeepAlive => true;
@override Widget build(BuildContext context) { super.build(context); // required by AutomaticKeepAliveClientMixin return SizedBox(height: 50, child: Text('keepalive $id')); }}
/// No mixin — the ordinary, default behavior.class PlainItem extends StatefulWidget { const PlainItem({super.key}); @override PlainItemState createState() => PlainItemState();}
class PlainItemState extends State<PlainItem> { late final int id;
@override void initState() { super.initState(); id = nextPlainId++; }
@override Widget build(BuildContext context) => SizedBox(height: 50, child: Text('plain $id'));}
class KeepAliveDemoList extends StatelessWidget { const KeepAliveDemoList({super.key, required this.controller}); final ScrollController controller;
@override Widget build(BuildContext context) { return SizedBox( height: 300, child: ListView.builder( controller: controller, itemCount: 200, itemExtent: 50, itemBuilder: (context, i) { if (i == 0) return const KeepAliveItem(); if (i == 1) return const PlainItem(); return SizedBox(height: 50, child: Text('filler $i')); }, ), ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/keep_alive_demo.dart';
void main() { testWidgets('AutomaticKeepAliveClientMixin keeps its State after scrolling far away and back; a plain State does not', (tester) async { nextKeepAliveId = 0; nextPlainId = 0; final controller = ScrollController(); await tester.pumpWidget(MaterialApp(home: Scaffold(body: KeepAliveDemoList(controller: controller))));
final firstKeepAliveId = tester.state<KeepAliveItemState>(find.byType(KeepAliveItem)).id; final firstPlainId = tester.state<PlainItemState>(find.byType(PlainItem)).id; expect(firstKeepAliveId, 0); expect(firstPlainId, 0);
// Scroll far enough away that both items leave the built+cached range. controller.jumpTo(8000); await tester.pump(); expect(find.byType(KeepAliveItem), findsNothing, reason: 'scrolled far off-screen — not in the current built widget tree either way'); expect(find.byType(PlainItem), findsNothing);
// Scroll back to the top. controller.jumpTo(0); await tester.pump();
expect( tester.state<KeepAliveItemState>(find.byType(KeepAliveItem)).id, 0, reason: 'AutomaticKeepAliveClientMixin: the SAME State object survived the round trip', ); expect( tester.state<PlainItemState>(find.byType(PlainItem)).id, 1, reason: 'plain State: a brand-new State was created on the way back — the old one was genuinely disposed', ); });}00:00 +0: AutomaticKeepAliveClientMixin keeps its State after scrolling far away and back; a plain State does not00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__slivers-and-scrolling/ run on this Mac)
Both items are equally absent from find.byType while scrolled 8000px away — neither is part of the currently-built widget tree at that point. The difference only shows up on the way back: the keep-alive item’s id is still 0 (the exact same State, reattached from wherever Flutter held onto it), while the plain item’s id is 1 — proof a genuinely new State was constructed. Reach for this only for state that’s expensive to rebuild and needs to survive scroll churn (a video player’s playback position, a form the user partially filled in a list item) — keeping many items alive defeats the memory benefit of laziness that this whole lesson is about.
NestedScrollView
Section titled “NestedScrollView”NestedScrollView coordinates an outer scrollable (typically slivers like a SliverAppBar) with an inner scrollable (like a TabBarView’s per-tab list) so they share one continuous scroll gesture — the outer header collapses as the inner content scrolls, rather than the two fighting over gestures the way two independent nested scrollables would (the same nesting hazard as the Constraints & Layout lesson’s unbounded-height crash, but for scroll gesture conflicts rather than layout constraints).
NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ const SliverAppBar(title: Text('Profile'), expandedHeight: 200, pinned: true), ], body: TabBarView(children: [FeedTab(), PhotosTab()]),)Scrollbar
Section titled “Scrollbar”Scrollbar wraps a scrollable to draw a visual scroll indicator, and on desktop/web adds drag-to-scroll support for the indicator itself. It needs either a controller explicitly passed (matching the one on the scrollable it wraps) or PrimaryScrollController wired up automatically (the default for a single top-level scrollable per route).
Scrollbar( controller: myController, thumbVisibility: true, // always show it, not just while scrolling child: ListView(controller: myController, children: [...]),)SliverFillRemaining
Section titled “SliverFillRemaining”Inside a CustomScrollView, SliverFillRemaining claims whatever space is left in the viewport after the other slivers — the sliver equivalent of Expanded inside a Column, useful for an empty-state message or a form’s submit button that should sit at the bottom of the visible area rather than immediately after the content above it.
CustomScrollView( slivers: [ const SliverAppBar(title: Text('Empty state demo')), SliverFillRemaining( hasScrollBody: false, // content does not itself need to scroll child: const Center(child: Text('Nothing here yet')), ), ],)Common scrolling pitfalls
Section titled “Common scrolling pitfalls”- Nesting scrollables the same direction (a
ListViewinside aListView) fights over the scroll and throws unbounded-constraint errors — use slivers orshrinkWrapdeliberately (the exact mechanism and real error text are proven in the Constraints & Layout lesson). shrinkWrap: truemakes a list size to its content instead of the viewport, but it builds everything and loses laziness — use it only for genuinely small lists (also proven, with all three real fixes, in the Constraints & Layout lesson).- Missing keys on reorderable lists cause the wrong state to attach to the wrong item (proven with a real checkbox test in the Keys lesson).
Mental Model
Section titled “Mental Model”A scrollable is a projector showing a filmstrip through a small window: the window (viewport) only ever needs the few frames (items) currently lined up behind it, plus a short buffer on each side so advancing the reel doesn’t show a blank flash (scrollCacheExtent) — building the entire filmstrip up front, as ListView(children: [...]) does, is printing every frame of a three-hour movie before showing frame one. AutomaticKeepAliveClientMixin is putting a specific frame in a special pocket instead of letting it go back in the film canister when it passes out of view — expensive to do for every frame, essential for the one frame (a music player’s currently-playing row, say) that genuinely needs to remember something across the reel advancing.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
ListView.builder/GridView.builder | Lazy — proven above to build a tiny fraction of a huge list | Item widgets must be constructible independently (no reliance on siblings existing) |
Larger scrollCacheExtent | Smoother fast scrolling — more is ready ahead of time, proven above | More memory and up-front build work per frame |
CustomScrollView + slivers | One continuous scroll gesture across heterogeneous content (header + grid + list) | More ceremony than a single ListView; only worth it when you genuinely need more than one scroll effect |
AutomaticKeepAliveClientMixin | A specific item’s state genuinely survives scrolling away — proven above | Defeats the memory benefit of laziness for that item; overusing it across many items re-creates the “build everything” problem this whole lesson exists to avoid |
shrinkWrap: true | Sizes a list to its content | Builds every item up front — the opposite of the laziness proven above; only for small, bounded lists |
Common Misconceptions
Section titled “Common Misconceptions”- “ListView() and ListView.builder() behave the same, builder is just a style choice.” Proven above:
.buildermeasurably builds a tiny fraction of the list (11of1000); a plainListView(children: [...])constructs every single item immediately, regardless of viewport size. - “cacheExtent/scrollCacheExtent only affects scroll smoothness, not what gets built.” Proven above with a real, measured count: it directly controls how many items are built ahead of the visible range (
11→26items for a1000pxcache) — and notecacheExtentitself is deprecated since 3.41.0 in favor ofscrollCacheExtent(proven above with the real analyzer diagnostic). - “A ScrollController can be told to jump to a position immediately after pumpWidget.” The proven pattern above needs
addPostFrameCallbackspecifically becausecontroller.positiondoesn’t exist until after the first layout — callingjumpToany earlier has nothing to act on yet. - “AutomaticKeepAliveClientMixin keeps ALL list items alive automatically once you add it anywhere.” Proven above to be per-item and opt-in: only the specific widget whose
Statemixes it in (and returnswantKeepAlive => true) survives the round trip — the siblingPlainItemin the exact same list, one index away, was proven to NOT survive it.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Flutter list code frequently reaches for the non-lazy ListView(children: [for (...) Tile(...)]) form for data whose length is actually unbounded or database-backed — because a huge fraction of training-data tutorials use the simple form for a hardcoded handful of items, and that habit carries over into contexts where it silently doesn’t scale.
// @expect-error compiles clean — builds every single order up front, the opposite of the laziness proven above, once orders.length is largeclass OrderHistoryScreen extends StatelessWidget { const OrderHistoryScreen({super.key, required this.orders}); final List<Order> orders;
@override Widget build(BuildContext context) { return ListView( children: [for (final order in orders) OrderTile(order: order)], ); }}Before trusting generated scrolling code, check:
- Is the list’s length bounded and small (a handful of settings toggles) or could it realistically grow large (search results, order history, chat messages)? Only the latter needs
.builder, proven above to matter enormously. - Does an item need to remember something (scroll-internal state, a video’s playback position, an in-progress edit) across being scrolled away and back? If so, is it actually wrapped with
AutomaticKeepAliveClientMixin, proven above to be the real mechanism — not just hoped to “still be there”? - Is a
ScrollController.jumpTo/animateTocall happening directly inbuild()orinitState()withoutaddPostFrameCallback? That’s the exact ordering bug proven above (positionnot existing yet). - Are two scrollables nested in the same direction without deliberate
shrinkWrap/slivers? That’s the real crash proven in the Constraints & Layout lesson.
🐛 Spot the bug
class ChatScreen extends StatefulWidget {const ChatScreen({super.key});@overrideState<ChatScreen> createState() => _ChatScreenState();}
class _ChatScreenState extends State<ChatScreen> {final controller = ScrollController();
@overridevoid initState() { super.initState(); controller.jumpTo(controller.position.maxScrollExtent);}
@overrideWidget build(BuildContext context) { return ListView.builder( controller: controller, itemCount: 500, itemBuilder: (context, i) => MessageBubble(index: i), );}}Show the answer
controller.jumpTo(...) is called directly inside initState(), before the ListView has ever been laid out — controller.position does not exist yet at that point (no scrollable has attached to this controller), so this throws immediately rather than scrolling to the bottom. The fix, proven above with a real test: wrap the jump in WidgetsBinding.instance.addPostFrameCallback((_) { ... }) so it runs after the first frame has actually built and laid out the list, once position genuinely exists.