Slivers & CustomScrollView
This is Part 5 of the Flutter Internals series. You've used ListView and GridView since day one. But the moment you want a collapsing app bar above a list above a grid, all scrolling as one, the simple scroll widgets fall apart — and you discover the engine beneath them: slivers.
"Sliver" sounds exotic and the API looks intimidating (SliverGeometry, SliverConstraints, delegates). It isn't, once you have the core picture. And you're well-prepared: a sliver is just a different layout protocol, one level below the box layout you know — much like the render objects we'll meet next. Let's demystify Flutter's scrolling engine.
Builds on the series so far; pairs with Fundamentals: layouts. Flutter 3.38 / Dart 3.12.
The big picture: a viewport and its slivers
Analogy — the conveyor belt. Imagine a single conveyor belt behind a window (the screen). The belt is longer than the window, so only part of it shows at once; scrolling moves the belt. Now imagine the belt is made of segments bolted end to end: a banner segment, then a hundred list-row segments, then a grid segment. They all move together as one belt.
- The window + belt mechanism is the
Viewport. - Each segment is a sliver.
- The thing that arranges segments on one belt is
CustomScrollView.
The key reframe: a normal
ListViewis a viewport with one sliver inside it (aSliverList).CustomScrollViewis a viewport that lets you place many slivers on the same belt, so they scroll as a single coordinated unit. That's the whole reason slivers exist — multiple scroll effects sharing one viewport.
CustomScrollView(
slivers: [
SliverAppBar(title: Text('Feed'), expandedHeight: 200, pinned: true),
SliverList(delegate: SliverChildBuilderDelegate(
(context, i) => ListTile(title: Text('Item $i')),
childCount: 50,
)),
SliverGrid.count(crossAxisCount: 2, children: gridTiles),
],
)
App bar, list, and grid — one belt, one scroll.
What makes a sliver different from a box
Every normal widget you know is a box: layout asks "given these width/height constraints, what size are you?" A Container, a Text, a Row — all boxes. (See Fundamentals: constraints.)
A sliver plays a different game because a scrollable segment needs to answer scroll-aware questions:
A box thinks in 2D size (width × height). A sliver thinks in 1D scroll extent: "How far do I stretch along the scroll axis? How much of me is currently visible? Where does the next sliver start?" It's layout aware of scroll position and what's on screen.
This is why you can't drop a Container directly into CustomScrollView.slivers — a box doesn't speak the sliver protocol. You wrap it:
// Adapt a normal box widget into a sliver:
SliverToBoxAdapter(child: MyBanner()),
// Or fill the remaining viewport:
SliverFillRemaining(child: EmptyState()),
The sliver protocol (one level below box constraints)
Here's the part that looks scary and is actually simple. Just as boxes exchange BoxConstraints → Size, slivers exchange SliverConstraints → SliverGeometry:
The viewport hands each sliver a SliverConstraints describing the current scroll situation:
| SliverConstraints field | Means |
| --- | --- |
| scrollOffset | how far this sliver has been scrolled past the leading edge |
| remainingPaintExtent | how much visible space is left in the viewport |
| crossAxisExtent | the width (for a vertical scroll) |
| axisDirection | scroll direction |
The sliver replies with a SliverGeometry describing how it laid out:
| SliverGeometry field | Means |
| --- | --- |
| scrollExtent | total length this sliver occupies on the belt |
| paintExtent | how much of it is visible right now |
| maxPaintExtent | its full visible size when fully on screen |
| layoutExtent | how much space it consumes for the next sliver's placement |
// Conceptually, every sliver answers:
SliverGeometry performLayout(SliverConstraints c) {
// "Given I'm scrolled by c.scrollOffset with c.remainingPaintExtent left,
// here's my total scrollExtent and how much (paintExtent) shows now."
}
That's the entire secret. A
SliverAppBarcollapsing is just itspaintExtentshrinking asscrollOffsetgrows. A pinned header is a sliver that keeps a non-zeropaintExtentat the top even when scrolled. Everything fancy is this protocol responding to scroll offset.
Laziness: why sliver lists scale
A crucial performance property ties back to the efficiency rules: sliver lists build children lazily, only for what's visible (plus a small cache).
// Builds ONLY the rows currently on screen — not all 100,000:
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => RowTile(data[index]),
childCount: data.length,
),
)
SliverChildBuilderDelegate(the engine behindListView.builder) calls your builder on demand as the viewport reveals items, and disposes off-screen ones. That's how Flutter scrolls a million-row list smoothly — it only ever has a screen's worth of Elements/RenderObjects alive. UsingSliverListwith a fixedchildren:list (not the builder delegate) loses this — it builds them all.
The sliver toolbox
You'll compose most screens from these:
| Sliver | Role |
| --- | --- |
| SliverAppBar | Collapsing/floating/pinned header — the showcase sliver |
| SliverList | Lazy linear list (use a builder delegate) |
| SliverGrid | Lazy grid |
| SliverToBoxAdapter | Wrap a single normal (box) widget as a sliver |
| SliverFillRemaining | Fill whatever viewport space is left |
| SliverPadding | Add padding around a sliver (you can't use box Padding) |
| SliverPersistentHeader | A custom header that can pin/float with full control |
| SliverList.separated | Lazy list with separators |
SliverAppBar, the star
SliverAppBar(
expandedHeight: 240, // tall when expanded
pinned: true, // keep the toolbar visible when collapsed
floating: false, // don't reappear on slight upward scroll
flexibleSpace: FlexibleSpaceBar(
title: const Text('Profile'),
background: Image.network(url, fit: BoxFit.cover),
),
)
The pinned/floating/snap flags are just presets for how the sliver's paintExtent behaves as you scroll — exactly the protocol above. pinned: true = "never let paintExtent drop below the toolbar height."
A complete coordinated scroll
CustomScrollView(
slivers: [
// 1. Collapsing header
const SliverAppBar(
expandedHeight: 200, pinned: true,
flexibleSpace: FlexibleSpaceBar(title: Text('Shop')),
),
// 2. A one-off banner (a box, adapted)
SliverToBoxAdapter(child: PromoBanner()),
// 3. Padding around the next sliver
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
delegate: SliverChildBuilderDelegate(
(context, i) => ProductCard(products[i]),
childCount: products.length, // lazy
),
),
),
// 4. Fill any leftover space
const SliverFillRemaining(hasScrollBody: false, child: Footer()),
],
)
All four slivers share one viewport and scroll together — impossible to coordinate with nested ListViews, trivial with slivers.
Anti-pattern reminder: don't nest scrollables to fake this (a
ListViewinside aColumninside aListView) — it causes unbounded-height errors and double scrollbars. When multiple scrolling regions must move as one, that's a singleCustomScrollViewwith multiple slivers.
Practice Challenges
Challenge 1 — Why a CustomScrollView? You need a collapsing image header above a long list, both scrolling together. Why can't you just stack an Image and a ListView in a Column?
Show solution
They'd be separate layout regions — the Image wouldn't collapse with the scroll and a ListView in a Column needs bounded height (error) or scrolls independently. A CustomScrollView with a SliverAppBar + SliverList puts both on one viewport/belt, so they scroll and collapse as a coordinated unit.
Challenge 2 — Adapt a box. You want a single Card banner between two slivers. Which sliver wraps it?
Show solution
SliverToBoxAdapter(child: Card(...)) — it adapts a normal box widget so it speaks the sliver protocol and can sit among other slivers.
Challenge 3 — Keep it lazy. A SliverList with children: [for (...) Tile()] is janky on a huge dataset. Fix it.
Show solution
Use a builder delegate so children build on demand:
SliverList(delegate: SliverChildBuilderDelegate(
(context, i) => Tile(data[i]), childCount: data.length));
A fixed children: list builds everything up front; the builder delegate builds only what's visible (efficiency).
Challenge 4 — Pinned vs floating. In SliverConstraints/SliverGeometry terms, what does pinned: true do to a SliverAppBar?
Show solution
It keeps the app bar's paintExtent from dropping below the toolbar height even as scrollOffset grows — so the toolbar stays visible (pinned) at the top instead of scrolling fully away. It's a constraint on the sliver's geometry response to scroll offset.
Challenge 5 — Padding a sliver. Why can't you wrap a sliver in a normal Padding widget, and what do you use?
Show solution
Padding is a box widget and doesn't speak the sliver protocol, so it can't go in slivers: or wrap a sliver. Use SliverPadding, which adds padding while remaining a sliver.
Questions to test yourself
Q1 (basic). What is a sliver, in one sentence?
Show answer
A scrollable segment of a viewport that lays out using a scroll-aware protocol — a portion of the "conveyor belt" that a CustomScrollView places alongside other slivers so they scroll as one.
Q2 (basic). How does CustomScrollView differ from ListView?
Show answer
ListView is a viewport with one sliver (a SliverList). CustomScrollView lets you place multiple slivers (app bar, list, grid…) in one viewport so they scroll together — for coordinated, multi-effect scrolling.
Q3 (intermediate). How does sliver layout differ from box layout?
Show answer
Boxes exchange BoxConstraints → Size (2D width × height). Slivers exchange SliverConstraints → SliverGeometry (1D, scroll-aware): how far they extend along the scroll axis, how much is currently visible (paintExtent), and where the next sliver starts — layout that knows the scroll position.
Q4 (intermediate). Why do sliver lists scale to huge datasets?
Show answer
With a SliverChildBuilderDelegate, children are built lazily — only the visible ones (plus a small cache) exist as Elements/RenderObjects at a time, and off-screen ones are disposed. So memory/work stay roughly constant regardless of list length.
Q5 (advanced). Explain a collapsing SliverAppBar purely in protocol terms.
Show answer
As scrollOffset increases, the app bar sliver reduces its paintExtent (visible size) from expandedHeight down toward the toolbar height — collapsing. pinned: true clamps paintExtent at the toolbar height so it never disappears; floating lets paintExtent grow again on a slight upward scroll. It's all the sliver responding to scroll offset via its SliverGeometry.
Q6 (advanced). Why is nesting scrollables to combine regions an anti-pattern, and what replaces it?
Show answer
Nested scrollables (e.g. a ListView inside a Column inside a ListView) cause unbounded-height errors, conflicting scroll physics, and double scrollbars, and they don't coordinate. The correct approach is a single CustomScrollView with multiple slivers, which shares one viewport so all regions scroll as a unit.
Wrapping up
- A sliver is a scrollable segment of a viewport;
CustomScrollViewplaces many slivers on one "belt" so they scroll together (a plainListViewis a viewport with one sliver). - Slivers use a scroll-aware protocol —
SliverConstraints→SliverGeometry(scroll extent,paintExtent= what's visible) — one level below boxSize. - Lazy building via
SliverChildBuilderDelegatekeeps huge lists smooth (only visible children exist). - Toolbox:
SliverAppBar(collapsing/pinned/floating =paintExtentbehavior),SliverList/SliverGrid,SliverToBoxAdapter,SliverPadding,SliverFillRemaining,SliverPersistentHeader. - For multiple regions that must move as one, use one
CustomScrollView— never nested scrollables.
In Part 6, the finale before the question bank, we go one level below widgets entirely to where size and pixels are decided: RenderObject — the layout/paint protocol and writing your own.