Part 00
Foundations
The mental model, the viewport, the hosts, and every pitfall you will hit along the way.
01. What is a sliver?
TL;DR
A sliver is a piece of scrollable content that knows how to lay itself out one slice at a time as it enters and leaves the screen during a scroll. Most Flutter widgets are boxes (they know their width and height in advance). Slivers are not boxes — they exist only inside a Viewport, and they negotiate with the viewport about how much space they need right now, based on how far the user has scrolled.
If a regular widget is a photograph (a finished, fixed rectangle), a sliver is a film reel — the viewport is the projector, and only the visible frames matter at any given moment.
Why slivers exist
Imagine you want to build a screen that:
- Has a big colorful header that shrinks and pins to the top as the user scrolls.
- Below it, a horizontal grid of products.
- Below that, a vertical list of reviews — possibly thousands of them.
- All of it scrolls together as one smooth scroll, not three nested scrolls.
You cannot do this with a ListView alone, because a ListView is just one kind of scrollable. You cannot do it with Column + ListView, because Column does not scroll, and putting a scrolling thing inside another scrolling thing is a mess (and Flutter will yell at you).
The solution is:
Make the screen one scrollable, and let it host many different “scroll behaviours” stacked on top of each other.
Each of those behaviours is a sliver. The header is one sliver. The grid is another. The review list is a third. The host that stitches them together is a CustomScrollView.
Box vs sliver — the core distinction
In Flutter, every widget eventually becomes a RenderObject. There are two big families:
| Family | Example widgets | Constraint type | Layout question they answer |
|---|---|---|---|
| Box | Container, Text, Row, Column, ListView, GridView, Image | BoxConstraints (min/max width and height) | “Given this rectangle of allowed size, how big do I want to be?” |
| Sliver | SliverList, SliverAppBar, SliverGrid, SliverPadding | SliverConstraints (scroll offset, remaining space, axis direction, …) | ”Given that the user has scrolled X pixels and there are Y pixels left in the viewport, how should I draw myself right now?” |
That second question is much richer. A sliver doesn’t just know its size — it knows:
- Where it starts in the scroll.
- How much of itself is currently above the visible area.
- How much is currently visible.
- How much is still to come.
- Whether it has been pinned, floated, or absorbed by an overlap handler.
This is why slivers can do things boxes simply cannot: collapse, pin, parallax, lazy-build, fade out at the edges, and stitch into the same scroll as their siblings.
Mental rule: if a widget has to react to the user scrolling — really react, not just move with the scroll — it is probably a sliver.
What a viewport actually does
A Viewport is the rectangle through which you see scrollable content. Picture a window. Behind the window is an infinite hallway full of slivers, lined up one after the other along an axis (usually vertical).
When the user scrolls:
- The viewport tells the first sliver: “the user has scrolled this far. How much vertical space are you taking right now? Are you visible? Where is your top edge?” The sliver answers with a
SliverGeometry(more on that in the Glossary). - The viewport then asks the next sliver, but with a different scroll offset — namely, the previous answer minus what the first sliver consumed.
- This continues sliver by sliver until either the viewport is full, or there are no more slivers.
This is the trick. The viewport never asks for the total size of all the content. It only asks for as much as it needs to fill itself plus a small cache around the edges. That is why a CustomScrollView with 100,000 items still launches instantly — Flutter never builds, lays out, or paints the items that aren’t on screen.
This walking-the-slivers loop is also what makes pinning, floating, and overlap absorption possible. A sliver can lie about its paintExtent (how much it draws) while telling the truth about its layoutExtent (how much it consumes from the scroll). That difference is the magic that makes a SliverAppBar stay glued to the top of the screen even after its scroll position has gone offscreen.
A sliver is not a widget you can drop anywhere
Here is the most common beginner mistake:
// ❌ This will throw a runtime error.
Column(
children: <Widget>[
SliverList(
delegate: SliverChildListDelegate(<Widget>[
Text('one'),
Text('two'),
]),
),
],
)
Column expects boxes. It hands its children BoxConstraints. SliverList does not know how to deal with BoxConstraints — it only speaks SliverConstraints. The error you will see is a long stack trace mentioning RenderObject and SliverConstraints.
The opposite mistake is just as common:
// ❌ This will also throw a runtime error.
CustomScrollView(
slivers: <Widget>[
Container(color: Colors.red, height: 200),
],
)
CustomScrollView expects slivers. Container is a box. The fix is to wrap the box in SliverToBoxAdapter, which is the universal “I have a box and I need it in a sliver list” adapter:
// ✅ This works.
CustomScrollView(
slivers: <Widget>[
SliverToBoxAdapter(
child: Container(color: Colors.red, height: 200),
),
],
)
Rule of thumb:
- Inside a
CustomScrollViewslivers:list → only slivers.- Inside a sliver’s
child:slot (likeSliverToBoxAdapterorSliverPadding’ssliver:slot) → check the parameter name.child:means box.sliver:means sliver.
A first real example
Here is the smallest meaningful sliver-based screen. It has a colored box, a list, and a grid, all sharing one scroll position.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
// 1) A plain box, lifted into the sliver world.
SliverToBoxAdapter(
child: Container(
height: 160,
color: Colors.indigo,
alignment: Alignment.center,
child: const Text(
'Header',
style: TextStyle(color: Colors.white, fontSize: 28),
),
),
),
// 2) A lazy list of 50 items.
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => ListTile(
title: Text('List item #$index'),
),
childCount: 50,
),
),
// 3) A lazy grid below the list.
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Container(
color: Colors.teal.shade100,
alignment: Alignment.center,
child: Text('Tile $index'),
),
childCount: 30,
),
),
],
),
),
);
}
}
If you scroll this screen, all three sections move together as one continuous scroll. The list and grid are lazy — the items off screen do not even exist as widgets yet. The header is a normal box, but it lives inside the sliver tree because we wrapped it in SliverToBoxAdapter.
This is the entire point of slivers in one screen. Everything else in this book is variations on this idea: different ways of being a “behaviour in a scroll”.
Three things to remember from this chapter
- Slivers are scrollable parts of a viewport. They are the unit Flutter uses to compose scrollable content out of multiple behaviours.
- Slivers and boxes do not mix without an adapter. Wrap a box with
SliverToBoxAdapterto put it in a sliver list. To put a sliver next to other widgets, you need a sliver-aware host, almost alwaysCustomScrollView. - Slivers are lazy. They build, lay out, and paint only the part that is visible right now. This is why a sliver-based screen can host millions of items and still launch instantly.
Next
→ 02. The viewport and the scroll model
02. The viewport and the scroll model
TL;DR
A Viewport is the rectangular window on screen. Behind that window, a long virtual strip of slivers is laid out one after another along the scroll axis. When the user scrolls, the viewport asks each sliver — in order — “how much of the visible space do you take, and how much of yourself is currently inside the window?”. Each sliver answers with a small data record called SliverGeometry, and the viewport adds up the answers until it has filled itself. Anything past that point is not built.
That single sentence is the entire scroll model. Everything else is just vocabulary.
The mental picture
Picture a hallway, very tall, with a ribbon of content rolled out along its length. Each section of the ribbon is a sliver. The viewport is a small window cut into the wall of the hallway — and the only thing the user ever sees is whatever is currently inside that window.
┌─────────────┐ ← visible window (the viewport)
│ sliver A │
│ (header) │ ← partly inside, partly above the window
├─────────────┤
│ sliver B │
│ (list) │ ← entirely visible
│ │
├─────────────┤
│ sliver C │ ← still off-screen, NOT BUILT YET
│ (grid) │
└─────────────┘
: :
: sliver D : ← far off-screen, also not built
: (footer) :
└─────────────┘
Scrolling does not move the slivers themselves. Scrolling changes a single number called the scroll offset, and then the viewport asks every sliver again: “given the new offset, what does your geometry look like now?”
This is what makes scrolls cheap. The slivers don’t move. The number moves.
Five words you have to know
You will see these words constantly in the Flutter docs and in error messages. They sound scary. They are not.
1. Scroll offset
How far the user has scrolled, in pixels, measured from the start of the scrollable content.
If the user has not scrolled at all, the scroll offset is 0. If they scroll the screen down by 200 pixels, the scroll offset becomes 200. The scroll offset is one number for the whole CustomScrollView. It does not belong to any one sliver.
2. Layout extent
How much space a sliver consumes along the scroll axis. This is “how much of the scrollable did this sliver eat”.
If a sliver has a layoutExtent of 400, then the next sliver starts 400 pixels later in the virtual ribbon. Think of layout extent as the sliver’s contribution to the total scrollable height.
3. Paint extent
How much space a sliver draws on screen right now. This is “how much of the visible window did this sliver fill”.
For most slivers, layoutExtent and paintExtent are the same — they consume what they draw. But pinned headers cheat: a SliverAppBar that is pinned to the top can have a layoutExtent of 0 (it has finished consuming its share of the scroll) while still having a paintExtent of 60 (it is still drawn at the top of the window). This trick is how pinning works.
4. Scroll extent
How tall (or wide) the whole sliver would be if you laid it out completely, ignoring scrolling. For a SliverList of 1000 list tiles, the scroll extent is roughly 1000 × tile height. The viewport uses scroll extent to know how big the scrollbar thumb should be and how far there is left to scroll.
A sliver has a scroll extent even if only 10 of its 1000 children have actually been built. It just estimates.
5. Cache extent
A small invisible margin around the visible window where slivers are still built but not painted. This is what makes scrolling feel buttery — items just below the screen are already laid out and ready when you scroll into them, so there is no flicker. Default cache extent is 250 logical pixels.
How a scroll actually progresses
Let’s trace what happens when the user drags the screen up by 50 pixels in a CustomScrollView that contains:
- Sliver A: a
SliverToBoxAdapterholding a 200-pixel header. - Sliver B: a
SliverListof 100 ListTiles, each 56 pixels tall.
Before the drag (scroll offset = 0):
| Sliver | Asked offset | Reports layoutExtent | Reports paintExtent | Visible? |
|---|---|---|---|---|
| A | 0 | 200 | 200 | yes (top 200px) |
| B | 0 (because A consumed 200, but the viewport sets the local scroll offset for B to max(0, viewportOffset - 200)) | 56 × however many fit | same | yes (below A) |
After the drag (scroll offset = 50):
The viewport now asks A: “the global scroll offset is 50; what is your geometry?”. A reports layoutExtent: 200, paintExtent: 150 (the top 50 pixels are gone). The viewport then asks B with a local scroll offset of max(0, 50 - 200) = 0, because B is still entirely below the visible window’s top. B reports its geometry; the visible portion grows by 50 pixels at the bottom.
After scrolling 250 pixels:
A is now entirely above the window. The viewport asks A and gets back layoutExtent: 200, paintExtent: 0. A is no longer drawn but is still part of the scrollable. B is asked with local scroll offset 250 - 200 = 50, so B skips its first child and begins building from list index 1 onward.
Notice the recurring pattern: the viewport never asks a sliver to lay itself out completely. It only ever asks “given how much I have scrolled, what part of yourself is relevant right now”. That is why a sliver list of a million items is cheap.
Lazy building, in three steps
This is exactly what happens for a lazy sliver like SliverList:
- Estimation. Before any child is built, the viewport asks the sliver to estimate its scroll extent.
SliverListestimates by averaging the size of children it has already built (or, if it hasn’t built any, it uses a placeholder). - Window calculation. The viewport tells the sliver: “based on the current scroll offset, the visible window for you starts at local offset
Xand ends at local offsetY. Build only the children that fall in[X − cacheExtent, Y + cacheExtent]”. - Build, layout, paint. The sliver builds those children using its
delegate, lays them out one by one until the window is full, and paints them. Children that scroll out of the window are disposed — they are removed from the widget tree, not just hidden.
This third step is the most surprising one for newcomers. Items that scroll off-screen are destroyed and re-built when they come back. This is why you should never rely on a StatefulWidget inside a list to remember anything — once it scrolls out, its state is gone. Either lift the state above the list, or use a PageStorageKey (covered in the SliverList page).
Two scroll axes, two main directions
A viewport has two properties that define how the slivers are arranged inside it:
scrollDirection—Axis.vertical(default) orAxis.horizontal. This is whether the user scrolls up-and-down or left-and-right.reverse—false(default) ortrue. When true, the first sliver is at the bottom (or right) instead of the top (or left). Useful for chat screens where the newest message should sit at the bottom and you scroll up to read older ones.
These two together determine the sliver’s AxisDirection, which can be down, up, right, or left. Most slivers don’t care which one you pick — they just lay themselves out the right way.
The one place this matters a lot is when you mix slivers with different cross-axis behaviour, like a horizontal scroll inside a vertical scroll. We will see that in 04. NestedScrollView.
What you can ignore (for now)
You will see these classes in the docs and in error messages. You do not need to understand them to use slivers — they are the render-layer plumbing.
| Class | What it is | When to care |
|---|---|---|
SliverConstraints | The data the viewport hands to a sliver during layout. | Only when you build a custom sliver. |
SliverGeometry | The data a sliver returns to the viewport during layout. | Only when you build a custom sliver. |
RenderSliver | The render-object base class for all slivers. | Only when you build a custom sliver. |
SliverPhysicalParentData / SliverLogicalParentData | Tracking data the parent attaches to its sliver children. | Only when you build a multi-child custom sliver. |
If you ever do need them, see the Glossary for one-line definitions.
Three things to remember from this chapter
- The scroll offset is one number. Scrolling does not move widgets — it changes that number, and the viewport re-asks every sliver where they are.
layoutExtentis what a sliver eats from the scroll.paintExtentis what it draws now. They are different for pinned and floating slivers; that difference is the trick behind every collapsing/pinning/floating effect in this book.- Off-screen items don’t exist. Slivers build, lay out, and paint only what is visible (plus a small cache). Anything outside that is disposed, not hidden.
Previous · Next
← 01. What is a sliver? → 03. CustomScrollView — the host container
03. CustomScrollView — the host container
TL;DR
CustomScrollView is the only scroll view in Flutter that takes a list of slivers directly. It is the host that lets you stitch many sliver behaviours into one continuous scroll. If you are using slivers, you are almost certainly using CustomScrollView (the only common alternative is NestedScrollView, covered in the next chapter).
If Scaffold is the page frame, CustomScrollView is the page’s scroll engine.
What it is
CustomScrollView is a ScrollView subclass with one defining feature: instead of a child: or children:, it has a slivers: parameter. That slivers: list is the entire content, and every item in it must be a sliver (or a sliver-aware widget).
CustomScrollView(
slivers: <Widget>[
SliverAppBar(...),
SliverList(...),
SliverGrid(...),
SliverPadding(...),
],
)
When CustomScrollView builds, it creates a Viewport and hands it the slivers list. From that point on, the viewport drives the layout (see chapter 02).
That is everything CustomScrollView does. It does not add an app bar, it does not add padding, it does not add a refresh control. It is a thin wrapper around Viewport. All the interesting behaviour comes from the slivers you put inside it.
The constructor, plain English
Here are every parameter you will actually use, in order of importance:
| Parameter | What it does | When to set it |
|---|---|---|
slivers | The list of slivers to scroll through, top to bottom. | Always. |
scrollDirection | Axis.vertical (default) or Axis.horizontal. | Only for horizontal scrolls. |
reverse | If true, the first sliver is at the bottom (or right). | Chat screens, “newest first” feeds. |
controller | A ScrollController if you need to read the scroll position or programmatically scroll. | Whenever you want to jump to a position, listen to scroll, or react to “user reached the bottom”. |
physics | The “feel” of the scroll: BouncingScrollPhysics, ClampingScrollPhysics, NeverScrollableScrollPhysics, etc. | When you want iOS-like bounce on Android, or to disable scrolling. |
primary | If true, this scroll view becomes the page’s “primary” scrollable. Lets Scaffold connect things like MediaQuery insets correctly. | Rare. Defaults are usually right. |
shrinkWrap | If true, the scroll view tries to be only as tall as its content. | Almost never inside a CustomScrollView. See pitfalls below. |
cacheExtent | How many extra pixels worth of slivers to build outside the visible window. Default 250. | When tiles are very tall and scrolling feels janky. |
center | The Key of a sliver that should be at scroll offset 0. Lets you scroll both forward and backward from a center. | Bidirectional scrolls (image viewers, calendars). |
anchor | A value 0.0..1.0 that says where the center sliver should sit in the viewport. 0.0 is the leading edge, 0.5 is the middle. | Together with center, for bidirectional scrolls. |
keyboardDismissBehavior | manual (default) or onDrag — dismiss soft keyboard when scrolling. | Forms inside scroll views. |
restorationId | A string ID for state restoration of the scroll position. | When you want the scroll position to survive app kill. |
dragStartBehavior | start (default) or down. Tweaks how drag gestures begin. | Almost never. |
clipBehavior | How the viewport clips its overflow. hardEdge by default. | Only when you want children to draw outside the scroll bounds. |
semanticChildCount | Number of children for accessibility, when not statically known. | Only for accessibility audits. |
hitTestBehavior | How the scroll view participates in hit testing. | Almost never. |
The two you will use 99% of the time are slivers and (sometimes) controller.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(
title: Text('CustomScrollView demo'),
floating: true,
snap: true,
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Card(
child: ListTile(title: Text('Row $index')),
),
childCount: 40,
),
),
),
SliverToBoxAdapter(
child: Container(
height: 80,
alignment: Alignment.center,
color: Colors.amber.shade100,
child: const Text('— end of feed —'),
),
),
],
),
),
);
}
}
This is roughly the structure of half the screens in any production Flutter app. Notice the order: app bar, padded list, footer. The order in the slivers: list is the order on screen, top to bottom.
Listening to scrolls — the controller pattern
A common requirement is “load more items when the user reaches the bottom”. Here is the canonical pattern:
class FeedScreen extends StatefulWidget {
const FeedScreen({super.key});
@override
State<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends State<FeedScreen> {
final ScrollController _controller = ScrollController();
final List<String> _items = List.generate(20, (i) => 'Item $i');
bool _loading = false;
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
}
@override
void dispose() {
_controller.removeListener(_onScroll);
_controller.dispose();
super.dispose();
}
void _onScroll() {
final position = _controller.position;
final reachedBottom = position.pixels >= position.maxScrollExtent - 200;
if (reachedBottom && !_loading) {
_loadMore();
}
}
Future<void> _loadMore() async {
setState(() => _loading = true);
await Future<void>.delayed(const Duration(milliseconds: 600));
setState(() {
_items.addAll(List.generate(20, (i) => 'Item ${_items.length + i}'));
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
controller: _controller,
slivers: <Widget>[
const SliverAppBar(title: Text('Infinite feed'), pinned: true),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text(_items[index])),
childCount: _items.length,
),
),
if (_loading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
),
],
),
);
}
}
Two things worth noticing:
- The controller is owned by the
State, created ininitStateand disposed indispose. Always. - The “load more” trigger uses
position.maxScrollExtent - 200. The200is a soft margin so loading begins before the user actually hits the bottom. Tune to taste.
Real-world use cases
- A product detail screen with hero image, gallery, description, reviews list, recommended items grid — all in one scroll.
- A chat thread where the message list scrolls and the input bar stays pinned (plus a
reverse: trueflag). - A profile screen with a parallax cover photo, sticky tab bar, and tab body.
- A settings screen with grouped sections that have headers (using
SliverPersistentHeaderorSliverMainAxisGroup). - A calendar that scrolls both backward and forward in time from “today” (the bidirectional
center+anchorpattern).
When to use it
- ✅ You need more than one kind of scroll behaviour in the same scroll (header + list + grid + footer).
- ✅ You need pinning, floating, parallax, or collapsing effects.
- ✅ You have a screen that today uses a
Columncontaining aListViewand you keep fighting withshrinkWrap,Expanded, or scroll conflicts. (Replace theColumn+ListViewwithCustomScrollView+ slivers.) - ✅ You need very long lists or grids and you want them to share scroll position with other content.
When NOT to use it
- ❌ You only need a flat list. Use
ListVieworListView.builder. It is a thin wrapper that already callsCustomScrollViewfor you. - ❌ You only need a flat grid. Use
GridView.builder. Same reason. - ❌ You only need to fit a few widgets that overflow the screen by a small amount. Use
SingleChildScrollView. - ❌ Two completely independent scroll positions in the same screen. Use
NestedScrollViewor two separate scroll views.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
SingleChildScrollView containing a Column containing a ListView.builder(shrinkWrap: true) | The inner list builds everything at startup because shrinkWrap forces it to measure itself. Performance dies. | CustomScrollView with a SliverToBoxAdapter and a SliverList. |
Column containing several Expanded(child: ListView) widgets | Multiple scroll positions, gestures fight each other, and Column cannot scroll. | CustomScrollView with multiple slivers under one scroll. |
Stack with a fixed app bar on top of a list | App bar always covers the top of the list, even when scrolled to top. | CustomScrollView with a SliverAppBar. |
Dos and don’ts
| Do | Don’t |
|---|---|
Use CustomScrollView whenever you have more than one scrollable behaviour on a screen. | Don’t put a ListView inside a CustomScrollView’s slivers: list. ListView is not a sliver. (Use SliverList directly.) |
Use SliverToBoxAdapter to drop a single non-sliver widget into the slivers list. | Don’t wrap every widget in SliverToBoxAdapter and never use real slivers. You will lose all the laziness benefits. |
Pass a ScrollController if you need scroll position. | Don’t create a new ScrollController inside build() — it will leak and the scroll will reset every rebuild. |
Use cacheExtent to make scrolls smoother for tall items. | Don’t crank cacheExtent to a huge value “just in case”. You will eat memory and slow down startup. |
Common pitfalls
”RenderViewport does not support returning intrinsic dimensions”
You wrapped the CustomScrollView in a widget that asks for its intrinsic size — typically IntrinsicHeight, Wrap, or an AlertDialog content area. A viewport’s height is infinite (it scrolls), so it cannot give an intrinsic answer.
Fix: wrap the CustomScrollView in a SizedBox or constrain its height some other way. Or use shrinkWrap: true, but only if the content is small.
A Container shows up in the slivers: list and the app crashes
Container is a box. Wrap it: SliverToBoxAdapter(child: Container(...)).
shrinkWrap: true makes everything slow
shrinkWrap forces the scroll view to lay out all of its slivers at startup, just to measure its own height. You lose laziness. Only use it for very small content (a dialog or a popup), and never for a long list.
The keyboard covers a text field
Because CustomScrollView doesn’t automatically scroll the focused field into view. Wrap your Scaffold with resizeToAvoidBottomInset: true (the default) and the framework’s Scrollable.ensureVisible will do its job, as long as the field is inside the sliver tree and is currently built.
Two CustomScrollViews in the same screen interfere
If both want to be the page’s primary scrollable, set primary: false on the inner one.
Related widgets
NestedScrollView— when you need an outer collapsing header and an inner scroll body that share a single scroll position but need their own physics.ListView,GridView,PageView— pre-built scroll views for the simple cases. All of them are wrappers around something sliver-shaped.Scrollable— the lower-level primitive. You almost never use it directly.
Official docs
Previous · Next
← 02. The viewport and the scroll model → 04. NestedScrollView — two scroll positions in one view
04. NestedScrollView — two scroll positions in one view
TL;DR
NestedScrollView is the answer to one specific problem: a screen with a collapsing header on top of a tab bar, where each tab contains its own scrollable list. It coordinates two scroll positions — the outer one (the header) and the inner one (the current tab’s list) — so the user feels like they are scrolling a single thing, but each list inside each tab can still remember its own scroll offset.
If you don’t need tabs, you don’t need NestedScrollView. Use CustomScrollView instead — it is simpler.
What it is
NestedScrollView is a widget that hosts:
- A
headerSliverBuildercallback that returns a list of slivers for the outer scroll (typically aSliverAppBarand maybe some other slivers above the tab bar). - A
bodywidget that is the inner scroll. The body usually contains aTabBarView, and each tab is its own scroll view (aCustomScrollView,ListView, orGridView).
When the user drags up:
- First, the inner body scrolls. If the inner is at its top, the outer header collapses.
- When the inner body is at the top and the outer header is fully collapsed, dragging up does nothing further.
When the user drags down:
- First, the outer header expands.
- When the header is fully expanded and the inner body is at the top, dragging down can trigger overscroll (or pull-to-refresh, if you wired one).
This “outer first, inner second” coordination is the entire point. It is what makes the screen feel like one scrollable, even though there are technically multiple Scrollables nested inside.
Why we can’t just use CustomScrollView here
You might think: “I’ll just put a SliverAppBar and a TabBarView inside one CustomScrollView”. This does not work for two reasons:
TabBarViewis not a sliver, and it cannot be made into one withSliverToBoxAdapter— the adapter requires its child to know its own height, butTabBarViewis “as tall as the viewport, please”.- Even if you could, each tab needs its own scroll position. If the user scrolls down 500 pixels in tab A, swipes to tab B, scrolls to the top, and swipes back to tab A — they expect tab A to still be at 500 pixels. A single shared scroll position cannot remember per-tab offsets.
NestedScrollView solves both problems by giving the body its own scroll context, then coordinating it with the outer header through a special class called SliverOverlapAbsorberHandle (covered in chapter 60-overlap).
Constructor parameters
| Parameter | What it does | Notes |
|---|---|---|
headerSliverBuilder | A callback that returns the list of slivers for the outer scroll. Receives BuildContext and bool innerBoxIsScrolled (true when the inner body has been scrolled). | Required. The boolean lets you change the outer slivers based on inner scroll, e.g. to elevate the app bar shadow. |
body | The widget that contains the inner scroll. Usually a TabBarView, but can be any scrollable. | Required. |
controller | An optional ScrollController that controls the outer scroll. | Use this when you want to read/listen to the header’s scroll. |
physics | Physics for the outer scroll. | Same as CustomScrollView. |
scrollDirection | Axis.vertical (default). | Horizontal nested scroll views are very rare. |
reverse | Like CustomScrollView.reverse. | Rare. |
floatHeaderSlivers | If true, the header slivers can scroll back into view as soon as the user starts scrolling down (instead of having to scroll all the way to the top of the inner body first). | Use for floating headers. |
clipBehavior | How the viewport clips. | Almost never changed. |
restorationId | For state restoration of the scroll position. | Optional. |
dragStartBehavior | Like CustomScrollView.dragStartBehavior. | Almost never. |
Minimal example: collapsing header with tabs
This is the canonical NestedScrollView screen — collapsing app bar, two tabs, each tab has its own scroll list.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: ProfileScreen());
}
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title: const Text('Profile'),
expandedHeight: 220,
pinned: true,
forceElevated: innerBoxIsScrolled,
flexibleSpace: const FlexibleSpaceBar(
background: ColoredBox(color: Colors.deepPurple),
),
bottom: const TabBar(
tabs: <Widget>[
Tab(text: 'Posts'),
Tab(text: 'Likes'),
],
),
),
),
];
},
body: TabBarView(
children: <Widget>[
_Tab(label: 'Post', count: 50),
_Tab(label: 'Like', count: 30),
],
),
),
),
);
}
}
class _Tab extends StatelessWidget {
const _Tab({required this.label, required this.count});
final String label;
final int count;
@override
Widget build(BuildContext context) {
return Builder(
builder: (BuildContext context) {
return CustomScrollView(
// Each tab MUST be a CustomScrollView with this key + the
// SliverOverlapInjector that matches the absorber above.
key: PageStorageKey<String>(label),
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => ListTile(
title: Text('$label #$index'),
),
childCount: count,
),
),
],
);
},
);
}
}
This is denser than a normal CustomScrollView, so let’s walk through every unfamiliar piece:
DefaultTabControllerwraps the screen so that theTabBarandTabBarViewfind each other automatically.SliverOverlapAbsorber+NestedScrollView.sliverOverlapAbsorberHandleFor(context): this is the bridge between the outer and inner scrolls. The outer header (SliverAppBar) tells the absorber: “I am pinned and I am drawing on top of the inner body. Please don’t let the inner body’s first item hide under me.” The absorber then communicates that to the correspondingSliverOverlapInjectorinside each tab, which adds invisible top padding equal to the pinned header’s height.PageStorageKeyon the tab’sCustomScrollView: lets each tab remember its own scroll position when you swipe between tabs. Without this, tabs reset to 0 every time you swipe back.forceElevated: innerBoxIsScrolled: when the inner body has been scrolled, the outer app bar should show its drop shadow. The boolean flag is passed in byheaderSliverBuilderfor exactly this purpose.
This pattern looks complicated the first time you see it, but it is always the same five pieces: NestedScrollView + SliverOverlapAbsorber (around the app bar) + SliverOverlapInjector (inside each tab) + PageStorageKey + forceElevated. Memorize the recipe; you will use it every time.
Real-world use cases
- Profile screens with a hero header and Posts/Followers/Following tabs.
- Music player album screens with a cover image, song list tab, and lyrics tab.
- Product detail screens with a gallery header and Description/Specifications/Reviews tabs.
- News reader sections where each topic is a tab and the magazine header collapses.
When to use it
- ✅ You have a collapsing header and tabs, each tab being its own scrollable list.
- ✅ You need each tab to remember its own scroll offset when the user swipes back.
- ✅ You want the outer header and the inner body to share a single “feels like one scroll” interaction.
When NOT to use it
- ❌ You have a collapsing header but no tabs. Use
CustomScrollViewwithSliverAppBarand the rest of your slivers underneath. Much simpler. - ❌ You have tabs but no collapsing header. Use
Scaffoldwith anappBar:and aTabBarViewbody. No nested scroll needed. - ❌ You are tempted to use it because “the docs example uses it”. The docs example is a tab screen. If your screen is not a tab screen, the simpler answer is
CustomScrollView. - ❌ You want two completely independent scrollables side by side. Just put two scroll views in a
RoworColumn; they don’t need coordination.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Scaffold(appBar: AppBar(bottom: TabBar(...)), body: TabBarView(...)) and you want the AppBar to collapse | Scaffold.appBar is a fixed widget; it cannot collapse with the inner scroll. | NestedScrollView with a SliverAppBar in the header. |
CustomScrollView with SliverFillRemaining(child: TabBarView(...)) | Each tab cannot scroll independently because they all share the outer scroll position; tab B sees tab A’s offset. | NestedScrollView with proper overlap absorbers. |
Stacking a TabBar over a Stack of ListViews and manually animating the header | Custom, brittle, and the per-tab scroll memory is hard to get right. | NestedScrollView recipe above. |
Dos and don’ts
| Do | Don’t |
|---|---|
Always use the SliverOverlapAbsorber + SliverOverlapInjector pair when the header has pinned content and the body has a scrollable. | Don’t skip them — without them, the first item of each tab will be hidden under the pinned header. |
Give each tab a PageStorageKey so its scroll position is remembered. | Don’t share a single ScrollController across tabs. Each tab has its own Scrollable. |
Use forceElevated: innerBoxIsScrolled so the app bar shadow appears at the right moment. | Don’t mix floatHeaderSlivers: true with pinned: true SliverAppBar — the result is unpredictable. |
Keep each tab’s content inside a CustomScrollView, even if it is a single SliverList. | Don’t put a non-scrollable body inside NestedScrollView. The body is supposed to scroll. |
Common pitfalls
The first item of each tab is hidden under the pinned app bar
You forgot the SliverOverlapInjector. Add it as the first sliver inside every tab’s CustomScrollView, with the same handle as the absorber.
Tabs reset their scroll position when I swipe back
You forgot PageStorageKey on the inner CustomScrollView. Each tab needs a unique key.
The header collapses but the body content doesn’t move
You wrapped the body in a Padding or Container with a fixed height. The body should be the NestedScrollView’s direct child, with no fixed-height ancestor between them.
headerSliverBuilder is called many times per scroll
That is normal. Make sure your builder is cheap and does not allocate large objects.
NestedScrollView.sliverOverlapAbsorberHandleFor(context) returns null
You called it from a context that is not a descendant of the NestedScrollView. Move the call deeper inside the body, or wrap it in a Builder.
Related widgets
SliverOverlapAbsorber— the “tell the inner body about the outer pinned header’s height” widget.SliverOverlapInjector— the receiving half on the inner side.SliverAppBar— the most common occupant ofheaderSliverBuilder.CustomScrollView— what each tab body should be.
Official docs
Previous · Next
← 03. CustomScrollView — the host container → 05. When to use slivers (and when not to)
05. When to use slivers (and when not to)
TL;DR
Slivers are not always the right answer. Most Flutter screens are perfectly fine with ListView, GridView, or SingleChildScrollView. Reach for slivers (i.e., a CustomScrollView or NestedScrollView) only when you need a scroll behaviour that the simpler widgets cannot give you.
This chapter is a decision chart, in plain words.
The decision chart
┌───────────────────────────────┐
│ Does the screen scroll? │
└────────────┬───────────────────┘
│
┌───────────────┴────────────────┐
│ │
Yes No
│ │
│ └─→ Just use Column / Row / Stack.
│
┌──────────────┴──────────────────────────────────┐
│ │
Is it ONE flat Is it MULTIPLE
list or grid? different scrollable
│ behaviours stacked
│ together?
Yes │
│ Yes
↓ │
┌──────────────────────────┐ ↓
│ ListView, ListView.builder│ ┌──────────────────────┐
│ GridView, GridView.builder│ │ Do you also need │
│ PageView │ │ tabs, each tab being │
└──────────────────────────┘ │ a list? │
└──────────┬───────────┘
│
┌────────────┴───────────┐
│ │
No Yes
│ │
↓ ↓
┌──────────────────┐ ┌─────────────────────┐
│ CustomScrollView │ │ NestedScrollView │
└──────────────────┘ └─────────────────────┘
That is the whole rule.
Five concrete questions to ask yourself
When you are building a new screen, walk through these five yes/no questions in order. The first “yes” picks your widget.
1. “Do I need a collapsing or pinning header?”
If yes → CustomScrollView (or NestedScrollView if there are also tabs). The collapsing/pinning behaviour comes from SliverAppBar, SliverPersistentHeader, SliverResizingHeader, PinnedHeaderSliver, or SliverFloatingHeader. None of those exist outside the sliver world.
2. “Do I need to mix a list with a grid (or two grids, etc.) in one continuous scroll?”
If yes → CustomScrollView. Without slivers you would need either two scroll views (which feels wrong) or a ListView containing a GridView (which has its own performance trap, see “Common pitfalls” below).
3. “Do I need a horizontal scroll inside a vertical scroll, where both share gestures?”
If yes → CustomScrollView with one of the slivers being a horizontal scroll widget (like a ListView with scrollDirection: Axis.horizontal wrapped in SliverToBoxAdapter).
4. “Do I have a screen with tabs, where each tab is a long list, and the header should collapse as the user scrolls within a tab?”
If yes → NestedScrollView. This is its one specialty.
5. “Is none of the above true? Is it really just a list, just a grid, or just a column of fixed content?”
Then you do not need slivers. Use:
| Need | Use |
|---|---|
| Flat list, fixed children | ListView(children: [...]) |
| Flat list, lazy | ListView.builder |
| Flat list, separators | ListView.separated |
| Flat grid | GridView.builder |
| Page-by-page swipe | PageView |
| Non-scrolling layout that might overflow on small screens | SingleChildScrollView(child: Column(...)) |
These widgets are all implemented internally as CustomScrollView + slivers. You don’t lose performance by using them — you just don’t get to use the sliver-only features.
”But everyone says use slivers always”
Some tutorials online recommend using CustomScrollView for every screen as a default. Don’t. There are real costs:
- More boilerplate. A
SliverListwith aSliverChildBuilderDelegateis more typing than aListView.builder. Multiply that by every screen. - Easier to introduce bugs. Forgetting
SliverToBoxAdapteraround a box is a runtime crash. Forgettingpinned: truemakes the app bar do nothing useful. ForgettingPageStorageKeyin a NestedScrollView resets tab scroll positions. - Harder for new contributors to read. A
ListView.buildersays “this is a flat list” at a glance. ACustomScrollViewsays “buckle up”.
Use slivers when you need them. Don’t use them when you don’t.
Performance: when slivers actually help
Slivers shine in three performance situations:
1. Very long lists or grids (thousands+ items)
Lazy building means only the visible window plus a small cache is laid out. SliverList and SliverGrid are the lazy versions. (ListView.builder and GridView.builder are the same thing under the hood — they wrap a SliverList/SliverGrid for you.)
2. Heterogeneous content
If you have a screen with a header, a banner, a horizontal carousel, a vertical list, an info card, and a footer, the alternative to slivers is a Column inside a SingleChildScrollView. That works, but SingleChildScrollView builds every single child eagerly. If the list inside it has 500 items, all 500 are built at startup. Slivers avoid this — every sliver is lazy on its own.
3. Pinned headers with shared scroll
A pinned SliverAppBar does not pay the cost of being painted twice (once as content, once as a fixed widget on top). It is one widget that lies about its paintExtent after consuming its share of the scroll. This is more efficient than the Stack(AppBar over ListView) workaround.
When the simpler widgets are actually faster
Here is the surprise: a single ListView.builder is sometimes faster than a CustomScrollView with one SliverList, because the ListView.builder constructor pre-computes some things at build time and the CustomScrollView cannot. The difference is small (microseconds), but the point stands: do not assume slivers are universally faster. They are equally fast for the cases that the simple widgets cover, and they are uniquely capable for the cases the simple widgets do not cover.
Common pitfalls (and how slivers fix them)
These are real anti-patterns that show up in production code. Each one has a sliver-based fix.
Anti-pattern 1: SingleChildScrollView with a Column containing a ListView.builder(shrinkWrap: true, physics: NeverScrollableScrollPhysics())
// ❌ Slow on long lists.
SingleChildScrollView(
child: Column(
children: [
Header(),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: 500,
itemBuilder: (_, i) => Tile(i),
),
Footer(),
],
),
)
shrinkWrap: true forces the inner ListView to lay out all 500 items to figure out its height, every time the parent rebuilds. The whole point of ListView.builder is laziness, and shrinkWrap cancels it.
Fix:
// ✅ Lazy and fast.
CustomScrollView(
slivers: [
const SliverToBoxAdapter(child: Header()),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, i) => Tile(i),
childCount: 500,
),
),
const SliverToBoxAdapter(child: Footer()),
],
)
Now Tile(i) is built only for the visible rows. The header and footer are still there.
Anti-pattern 2: Stack with a fixed app bar
// ❌ The app bar always covers the top of the list, even at scroll offset 0.
Stack(
children: [
ListView(...),
Positioned(top: 0, left: 0, right: 0, child: AppBar(...)),
],
)
The list scrolls under the app bar permanently. You also lose the visual cue of “scroll to the top to see the full content”.
Fix:
// ✅ The app bar is part of the scroll and can collapse/pin.
CustomScrollView(
slivers: [
SliverAppBar(pinned: true, title: Text('...')),
SliverList(...),
],
)
Anti-pattern 3: Column with multiple Expanded(child: ListView) widgets
// ❌ Two competing scroll positions, two competing gestures.
Column(
children: [
Expanded(child: ListView(...)),
Expanded(child: ListView(...)),
],
)
Each list scrolls separately. Touch gestures fight each other. The user can never scroll the screen as one thing.
Fix: if the two lists should scroll together, use CustomScrollView with two SliverLists. If they should scroll separately on purpose (split-screen pattern), keep the Column+Expanded approach but document the intent.
Anti-pattern 4: ListView containing GridView.count
// ❌ Inner GridView lays out all items eagerly.
ListView(
children: [
Header(),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
children: List.generate(60, (i) => Tile(i)),
),
Footer(),
],
)
Same issue as anti-pattern 1, but with a grid.
Fix:
// ✅ Header, lazy grid, footer — all in one scroll.
CustomScrollView(
slivers: [
SliverToBoxAdapter(child: Header()),
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
delegate: SliverChildBuilderDelegate(
(_, i) => Tile(i),
childCount: 60,
),
),
SliverToBoxAdapter(child: Footer()),
],
)
A short flowchart in words
“Should this screen use slivers?”
- If you need a collapsing/pinning header → yes.
- If you have multiple scrollable sections that should share one scroll → yes.
- If you have tabs whose contents are long lists and the header should collapse → yes (and use
NestedScrollView).- Otherwise → no, use the simpler widget.
Three things to remember from this chapter
ListViewandGridVieware slivers under the hood. Using them is not “the wrong way”. They are the right way for flat scrollables.- Reach for slivers when you need behaviour, not just laziness. Laziness comes for free with
ListView.builder. The collapsing header and the multi-section layout do not. - Don’t use
shrinkWrapandNeverScrollableScrollPhysicsto nest scroll views. That is the universal red flag that says: “this should have been aCustomScrollViewwith slivers”.
Previous · Next
← 04. NestedScrollView — two scroll positions in one view → 06. Delegates explained
06. Delegates explained
TL;DR
A delegate is a small object whose only job is to supply children to a sliver on demand. Slivers do not hold their children directly — they ask the delegate “give me child #42, please” whenever they need to build a row that just scrolled into view. This indirection is what makes lazy building possible.
There are three families of delegates in Flutter:
SliverChildDelegate— supplies the children ofSliverList,SliverGrid,SliverFixedExtentList, etc.SliverGridDelegate— describes the layout of aSliverGrid(how many columns, how big each tile, etc.).SliverPersistentHeaderDelegate— supplies the contents of aSliverPersistentHeaderand tells it how to resize as the user scrolls.
Once you understand “the sliver does not hold the children, the delegate does”, every sliver constructor in Flutter starts to make sense.
Why delegates exist at all
A naïve list widget would look like this:
class FakeList extends Widget {
final List<Widget> children;
const FakeList({required this.children});
}
This works for short lists. It does not work for a 10,000-item list because all 10,000 widgets exist in memory at all times, even when you can only see 8 of them. That is the entire problem slivers solve.
The fix is to give the list a callback instead of a list:
class LazyList extends Widget {
final Widget Function(int index) builder;
final int childCount;
const LazyList({required this.builder, required this.childCount});
}
Now the list doesn’t know about its children until it asks. It calls builder(42) only when child #42 is about to scroll into view, and discards the result when child #42 scrolls back out. Memory usage stops being a function of “how many items” and starts being a function of “how many are visible”.
Flutter’s slivers use exactly this idea, but they wrap the callback inside an object instead of taking it directly. That object is the delegate. Why an object? Because a delegate can carry more than just one function:
- A way to compute
childCount(sometimesnullfor “infinite”). - An optional
findChildIndexCallbackto help Flutter recycle widgets when the list rebuilds. - A flag for whether to wrap children in
RepaintBoundaryandAutomaticKeepAlive. - A way to pre-compute the
Keyof a child by its index.
Bundling all of this into an object means the same delegate works with SliverList, SliverGrid, SliverPrototypeExtentList, SliverFixedExtentList, and so on — they all accept a SliverChildDelegate. One contract, many slivers.
Family 1: SliverChildDelegate (for list/grid items)
SliverChildDelegate is the abstract base. You almost never use it directly. Instead, you use one of its two concrete subclasses:
SliverChildBuilderDelegate — the lazy one
The one you will use 95% of the time.
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(title: Text('Item $index'));
},
childCount: 1000,
),
)
The builder is called only for visible (and cached) indices. childCount tells the delegate when to stop. If you set childCount: null, the list is treated as infinite — the builder is called until it returns null.
SliverChildListDelegate — the eager one
The one for short, fixed lists.
SliverList(
delegate: SliverChildListDelegate(<Widget>[
const Text('Apples'),
const Text('Bananas'),
const Text('Cherries'),
]),
)
All children are constructed up front and held in the list. Use this only when you have a handful of items. For anything longer than ~20 items, switch to SliverChildBuilderDelegate.
Rule of thumb: if you would naturally write
List.generate(...)or aforloop to make the children, you want the builder delegate.
Family 2: SliverGridDelegate (for grid layout)
A grid needs to know two things: how many columns it has, and how each cell should be sized. That information is layout information, not child information, so it lives in a separate delegate: SliverGridDelegate.
You will see two concrete subclasses:
SliverGridDelegateWithFixedCrossAxisCount
“Always show exactly N columns.”
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1, // square tiles
),
delegate: SliverChildBuilderDelegate(
(context, index) => Card(child: Center(child: Text('$index'))),
childCount: 30,
),
)
Notice how the grid takes two delegates: one for the layout (gridDelegate) and one for the children (delegate). They are independent — you can change the layout without touching the children, and vice versa.
SliverGridDelegateWithMaxCrossAxisExtent
“Show as many columns as fit, but no tile may be wider than this.”
SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1,
),
delegate: SliverChildBuilderDelegate(
(context, index) => Card(child: Center(child: Text('$index'))),
childCount: 30,
),
)
This is the responsive option. On a phone you might get 2 columns, on a tablet 4, on desktop 6 — without any media query code.
Rule of thumb: use
WithFixedCrossAxisCountwhen the design demands “exactly 3 columns”. UseWithMaxCrossAxisExtentwhen the design demands “tiles around 200dp wide, fill the row”.
Family 3: SliverPersistentHeaderDelegate (for collapsing headers)
This one is different. It is not for items in a list — it is for a single header that needs to resize as the user scrolls. It is what SliverAppBar uses internally, and what you use directly when you build a custom collapsing header with SliverPersistentHeader.
It is an abstract class with four required overrides:
class MyHeaderDelegate extends SliverPersistentHeaderDelegate {
@override
double get minExtent => 56; // collapsed height
@override
double get maxExtent => 200; // expanded height
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
// Called every time the header needs to be drawn.
// shrinkOffset = how many pixels of the header have been "collapsed"
// from its max extent. 0 = fully expanded, maxExtent - minExtent = fully collapsed.
final t = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0);
return Container(
color: Color.lerp(Colors.indigo, Colors.indigoAccent, t),
alignment: Alignment.center,
child: Text('Scroll progress: ${(t * 100).toInt()}%'),
);
}
@override
bool shouldRebuild(covariant MyHeaderDelegate oldDelegate) => false;
}
And you use it like this:
CustomScrollView(
slivers: <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: MyHeaderDelegate(),
),
SliverList(...),
],
)
The delegate is the entire behaviour of the header. The sliver itself (SliverPersistentHeader) is just the wiring.
We will see this in much more detail on the SliverPersistentHeader and SliverPersistentHeaderDelegate pages. For now, just remember the shape: min, max, build, shouldRebuild.
What shouldRebuild actually means
Every delegate has a shouldRebuild(covariant T oldDelegate) method that returns a bool. Flutter uses it the same way Widget.operator== is used in normal widgets: to decide whether the underlying sliver needs to redo its work.
- Return
truewhen something inside your delegate has changed and the sliver should re-build / re-layout. - Return
falsewhen the new delegate carries the same data as the old one.
If you forget to override it (or always return false from a delegate that does change), the sliver will get stale and refuse to update. If you always return true, you lose the optimization but the sliver still works.
For SliverChildBuilderDelegate and SliverChildListDelegate, Flutter handles this for you by comparing internal fields. For SliverPersistentHeaderDelegate, you have to write it yourself.
Putting it all together
Here is a single CustomScrollView that uses all three delegate families. Read the comments alongside the code.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
// 1) A custom collapsing header — uses a SliverPersistentHeaderDelegate.
SliverPersistentHeader(
pinned: true,
delegate: _ColorHeader(),
),
// 2) A grid of squares — uses a SliverGridDelegate AND a SliverChildBuilderDelegate.
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 1,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
delegate: SliverChildBuilderDelegate(
(context, index) => Container(
color: Colors.primaries[index % Colors.primaries.length].shade200,
alignment: Alignment.center,
child: Text('G$index'),
),
childCount: 32,
),
),
// 3) A list of rows — uses a SliverChildBuilderDelegate.
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Row $index')),
childCount: 50,
),
),
],
),
),
);
}
}
class _ColorHeader extends SliverPersistentHeaderDelegate {
@override
double get minExtent => 64;
@override
double get maxExtent => 180;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final t = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0);
return Container(
color: Color.lerp(Colors.indigo, Colors.deepPurple, t),
alignment: Alignment.bottomLeft,
padding: const EdgeInsets.all(16),
child: const Text('Header', style: TextStyle(color: Colors.white, fontSize: 24)),
);
}
@override
bool shouldRebuild(covariant _ColorHeader oldDelegate) => false;
}
When you run this, the screen has:
- A custom-colored header that smoothly darkens as you collapse it.
- A grid of 32 colored tiles in 4 columns.
- A list of 50 rows underneath.
Three slivers, three delegate families, all in one scroll. This is a representative real-world layout.
Common pitfalls
”Why is childCount an int? instead of an int?”
Because null means “infinite”. A builder delegate with childCount: null will keep calling the builder until the builder returns null. This is how chat apps with paged backwards-loading work.
”I changed my data and the list didn’t update”
You probably created a new SliverChildListDelegate(...) with the same content but a different list reference. Flutter’s == comparison will see they are different and rebuild — unless you wrapped the children in const. For builder delegates, just call setState and pass a fresh delegate; Flutter will figure out which children to rebuild.
”My persistent header doesn’t update when I change my data”
You forgot shouldRebuild. Override it and return true when the data your build method depends on has changed.
”My grid items are tiny”
childAspectRatio defaults to 1 (square). If your grid is wide and you want shorter, fatter tiles, set childAspectRatio: 2 (twice as wide as tall) or 0.5 (twice as tall as wide).
”I get duplicate keys when items reorder”
Pass a findChildIndexCallback to SliverChildBuilderDelegate so Flutter can recycle the widgets correctly when items move. The callback maps a Key back to its current int index.
Three things to remember from this chapter
- Slivers don’t hold children — delegates do. This indirection is the source of laziness. The same delegate works with multiple sliver widgets.
- There are three delegate families:
SliverChildDelegate(items),SliverGridDelegate(grid layout),SliverPersistentHeaderDelegate(collapsing headers). - For builder delegates,
nullchildCountmeans “infinite”. For grids,WithFixedCrossAxisCountis “exactly N columns” andWithMaxCrossAxisExtentis “responsive”.
Previous · Next
← 05. When to use slivers (and when not to) → 07. Common pitfalls and error messages
07. Common pitfalls and error messages
TL;DR
This chapter is a triage list. When something goes wrong with slivers, the symptom is usually an unhelpful error message (“RenderViewport…” something), and the cause is one of about ten things. Here is the list, in order of frequency.
Bookmark this page.
1. “A RenderBox object was given an infinite size during layout”
What you wrote
You put a regular widget (a box) directly in a CustomScrollView’s slivers: list, or you put a sliver inside a widget that expects a box.
Why it breaks
The viewport hands its children SliverConstraints. A box widget doesn’t know what to do with sliver constraints, so the layout machinery falls back to defaults — and ends up trying to give the widget an infinite size in the scroll direction.
Fix
If you have a box and need a sliver, wrap with SliverToBoxAdapter:
// ❌
CustomScrollView(slivers: [Container(height: 100, color: Colors.red)])
// ✅
CustomScrollView(slivers: [
SliverToBoxAdapter(child: Container(height: 100, color: Colors.red)),
])
If you have a sliver and need a box (you are inside a Column, Row, Stack, etc.), wrap a CustomScrollView around the sliver:
// ❌
Column(children: [SliverList(...)])
// ✅
Column(children: [
Expanded(child: CustomScrollView(slivers: [SliverList(...)])),
])
2. “RenderViewport does not support returning intrinsic dimensions”
What you wrote
You wrapped a CustomScrollView (or any scroll view) inside a widget that asks for the child’s intrinsic size: IntrinsicHeight, IntrinsicWidth, Wrap, AlertDialog.content, Tooltip.message (if it ever wraps content), or a custom widget that calls getMinIntrinsicHeight.
Why it breaks
A viewport is infinite in the scroll direction. There is no “intrinsic” height for “all my content”. The viewport refuses to answer the question.
Fix
Constrain the scroll view to a finite height before passing it to the intrinsic-asking parent:
// ❌
AlertDialog(
content: CustomScrollView(slivers: [SliverList(...)]),
)
// ✅
AlertDialog(
content: SizedBox(
width: double.maxFinite,
height: 400,
child: CustomScrollView(slivers: [SliverList(...)]),
),
)
For dialogs, a SizedBox with an explicit height is the standard fix.
3. The list builds all items at startup and the screen is slow
What you wrote
You set shrinkWrap: true on a ListView (or CustomScrollView) and put it inside another scroll view, or you used SliverChildListDelegate(List.generate(1000, ...)) instead of the builder version.
Why it breaks
shrinkWrap: true tells the scroll view “lay yourself out completely so you can report your own height”. A 1000-item list means 1000 builds. Same for SliverChildListDelegate, which is eager by design.
Fix
- Replace
shrinkWrap: truewith a sliver-based screen, where the inner content is just aSliverListinstead of a wrappedListView. - Replace
SliverChildListDelegatewithSliverChildBuilderDelegatewhenever you have more than ~20 items.
// ❌
SingleChildScrollView(
child: Column(children: [
Header(),
ListView.builder(shrinkWrap: true, physics: NeverScrollableScrollPhysics(), itemBuilder: ...),
Footer(),
]),
)
// ✅
CustomScrollView(slivers: [
const SliverToBoxAdapter(child: Header()),
SliverList(delegate: SliverChildBuilderDelegate(itemBuilder, childCount: 1000)),
const SliverToBoxAdapter(child: Footer()),
])
4. The pinned SliverAppBar doesn’t pin
What you wrote
SliverAppBar(title: Text('Hi')) // ❌ pinned defaults to false
Why it breaks
SliverAppBar does not pin or float by default. It just scrolls away like any other content.
Fix
Set the right flag:
pinned: true— stays glued to the top of the viewport.floating: true— re-appears as soon as the user scrolls down (even if the list is not at top).floating: true, snap: true— re-appears in one snap instead of incrementally.pinned: true, floating: true— collapses to itstoolbarHeight, then unfolds when scrolling down.
5. The first item of each tab is hidden under the pinned header (NestedScrollView)
What you wrote
You used NestedScrollView with a pinned SliverAppBar in the header, and forgot the overlap absorber/injector pair.
Fix
Wrap the SliverAppBar with SliverOverlapAbsorber, and add a SliverOverlapInjector as the first sliver of every tab. Both share the same handle:
final handle = NestedScrollView.sliverOverlapAbsorberHandleFor(context);
// In headerSliverBuilder:
SliverOverlapAbsorber(handle: handle, sliver: SliverAppBar(...))
// As the first sliver of every tab:
SliverOverlapInjector(handle: handle)
See NestedScrollView for the full recipe.
6. Tab scroll positions reset every time I swipe back
What you wrote
A NestedScrollView with tabs, each tab being a CustomScrollView, but no PageStorageKey on those inner scroll views.
Fix
Give each tab’s CustomScrollView a unique PageStorageKey:
CustomScrollView(
key: const PageStorageKey<String>('posts-tab'),
slivers: [...],
)
The page storage bucket is what NestedScrollView uses to remember each tab’s offset.
7. State inside list items disappears when scrolled offscreen
What you wrote
A StatefulWidget inside a SliverList (or ListView.builder) that holds something important in its State — a checkbox value, a counter, an animation state, an expand/collapse flag.
Why it breaks
Off-screen list items are disposed. When they scroll back in, they are built fresh with default state. Lists are virtualized; they don’t keep your state for free.
Fix
Two options.
Option A — lift the state up. Store the per-item state in the parent StatefulWidget (or a controller, provider, riverpod, bloc, etc.) and pass it down.
class _State extends State<MyScreen> {
final Set<int> _checked = {};
@override
Widget build(BuildContext context) {
return CustomScrollView(slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(_, i) => CheckboxListTile(
value: _checked.contains(i),
onChanged: (v) => setState(() {
v ?? false ? _checked.add(i) : _checked.remove(i);
}),
title: Text('Item $i'),
),
childCount: 1000,
),
),
]);
}
}
Option B — keep the item alive with AutomaticKeepAliveClientMixin.
class _Tile extends StatefulWidget {
const _Tile({required this.index});
final int index;
@override
State<_Tile> createState() => _TileState();
}
class _TileState extends State<_Tile> with AutomaticKeepAliveClientMixin {
bool _expanded = false;
@override
bool get wantKeepAlive => _expanded;
@override
Widget build(BuildContext context) {
super.build(context); // required by the mixin
return ExpansionTile(
title: Text('Item ${widget.index}'),
onExpansionChanged: (v) => setState(() => _expanded = v),
children: const [Text('details')],
);
}
}
wantKeepAlive tells Flutter to retain this item’s state even when scrolled offscreen. Use sparingly — keeping items alive defeats the memory benefits of laziness.
Default to Option A. Use Option B only when lifting state up is awkward.
8. Pull-to-refresh does nothing on a CustomScrollView
What you wrote
A CustomScrollView wrapped in a RefreshIndicator, but without physics: AlwaysScrollableScrollPhysics().
Why it breaks
If the content fits the screen, the scroll view has no overscroll, so the refresh indicator never triggers. By default, CustomScrollView uses ClampingScrollPhysics, which does not allow overscroll when the content fits.
Fix
RefreshIndicator(
onRefresh: _onRefresh,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(), // ← the missing line
slivers: [...],
),
)
9. The ScrollController complains “ScrollController not attached to any scroll views”
What you wrote
You created a ScrollController and tried to read controller.position before the CustomScrollView had attached to it.
Why it breaks
The controller and the scroll view connect during build. Reading position before that build happens (e.g. in initState, before any frame has rendered) throws.
Fix
Schedule the read for after the first frame:
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
print(_controller.position.pixels);
});
}
Or, simpler: only read position from inside an event handler (after the user has interacted with the scroll view), not from build/init lifecycle methods.
10. Two CustomScrollViews on the same screen fight over the primary scroll
What you wrote
A nested layout with two scroll views, both implicitly claiming primary: true.
Why it breaks
In a Scaffold, the primary scroll controller is what MediaQuery insets and ScrollNotification listeners hook into. If two scrollables both claim it, you get unpredictable behaviour.
Fix
Set primary: false on the inner one (or both, if neither is the page’s main scroll):
CustomScrollView(
primary: false, // ← explicit
slivers: [...],
)
11. The SliverFillRemaining looks wrong / gets clipped
What you wrote
A SliverFillRemaining with content larger than the remaining viewport space, expecting it to scroll.
Why it breaks
SliverFillRemaining makes its child fill whatever space is left in the viewport at this moment. If the child wants more space, it gets clipped. It is not a scrollable inside a scrollable.
Fix
Either:
- Use
hasScrollBody: trueon theSliverFillRemainingand let the child be a scrollable. (But then the child cannot scroll past the parent — they share the scroll position.) - Or replace
SliverFillRemainingwith the appropriate sliver (aSliverList, a realContainer, etc.).
See the SliverFillRemaining page for the full nuance.
12. The grid items keep getting wider (or shorter) than I want
What you wrote
SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3) without setting childAspectRatio.
Why it breaks
childAspectRatio defaults to 1.0, meaning each tile is laid out as a square based on the available width. If the screen is wide, the tiles get tall. If you wanted “fixed height tiles”, you have to compute the aspect ratio yourself or use mainAxisExtent instead.
Fix
// Tiles fixed at 120dp tall, regardless of screen width
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisExtent: 120,
)
mainAxisExtent gives you direct control over tile height (or width, in horizontal grids), bypassing the childAspectRatio calculation.
13. The viewport scrolls but my widget doesn’t move because I wrapped it in a Positioned inside a Stack
What you wrote
A Stack with a CustomScrollView and a Positioned overlay, and you expected the overlay to scroll with the content.
Why it breaks
Positioned is relative to the Stack, not to the scroll content. The viewport scrolls; the overlay stays put.
Fix
Move the overlay into the slivers list (e.g. as a SliverToBoxAdapter at the right index), or accept that overlays don’t scroll with content. There is no in-between.
14. “type ‘X’ is not a subtype of type ‘SliverPhysicalContainerParentData’ in type cast”
What you wrote
You used SliverCrossAxisExpanded outside a SliverCrossAxisGroup.
Why it breaks
SliverCrossAxisExpanded is a ParentDataWidget — its only job is to attach extra layout information to its child for the parent to read. If the parent isn’t a SliverCrossAxisGroup, the parent doesn’t know what to do with that data, and the cast fails.
Fix
Wrap the SliverCrossAxisExpandeds in a SliverCrossAxisGroup:
CustomScrollView(slivers: [
SliverCrossAxisGroup(slivers: [
SliverCrossAxisExpanded(flex: 2, sliver: SliverList(...)),
SliverCrossAxisExpanded(flex: 1, sliver: SliverList(...)),
]),
])
15. Hot reload changes my list builder but the items don’t update
What you wrote
A SliverChildBuilderDelegate whose builder closure captures variables from the State, and you changed the closure code in the builder.
Why it breaks
Sometimes Flutter reuses the existing element tree for the slivers and doesn’t notice the closure changed. Hot restart fixes it; hot reload sometimes doesn’t.
Fix
Hot restart (R instead of r in the terminal). Or briefly toggle the childCount to force a rebuild.
A short reminder card
When something is wrong with a sliver layout, ask yourself in this order:
- Is something a box where a sliver should be? Or vice versa? (Wrap/unwrap with
SliverToBoxAdapter.) - Is the parent asking for an intrinsic size? (Wrap in a
SizedBox.) - Did I forget
pinned: true/shrinkWrap/PageStorageKey/AlwaysScrollableScrollPhysics? (One of the four “magic flags”.) - Is the content building eagerly? (Switch to a builder delegate.)
- Am I using
NestedScrollViewand forgot the overlap absorber/injector pair? (See chapter 04.) - Am I trying to read state from a list item that scrolled off? (Lift state up.)
If after all six the problem is still mysterious, read the actual error message slowly. Sliver errors are long, but the third or fourth line usually names the exact widget that’s broken.
Previous · Next
← 06. Delegates explained → Back to the book index, or jump straight into 10-scrollables.
08. ScrollPhysics and scroll behaviour
TL;DR
ScrollPhysics is the object that decides how a scroll feels — whether it bounces at the edges, clamps hard, snaps to positions, or refuses to scroll at all. Slivers don’t care which physics you use; they just scroll. The physics is set on the host scroll view (CustomScrollView, NestedScrollView, ListView, etc.) and affects every sliver inside it equally.
Rule of thumb: if it’s about how the scroll feels, it’s physics. If it’s about what gets drawn while scrolling, it’s the sliver.
The four built-in physics you actually use
| Physics | Behaviour | Default on |
|---|---|---|
BouncingScrollPhysics | iOS-style overscroll bounce. Lets you pull past the edges and snap back. | iOS, macOS |
ClampingScrollPhysics | Android-style hard stop at the edges. Shows a glowing “stretch” indicator instead of bouncing. | Android, Linux, Windows |
AlwaysScrollableScrollPhysics | Forces the scroll view to be scrollable even when the content fits the screen. Required for pull-to-refresh on short content. | (none — opt in) |
NeverScrollableScrollPhysics | Disables scrolling entirely. The scroll view becomes a fixed layout. | (none — opt in) |
There are more (PageScrollPhysics, FixedExtentScrollPhysics, RangeMaintainingScrollPhysics), but those four cover ~95% of real apps.
How physics is picked when you don’t set it
If you do not pass physics:, the scroll view asks the surrounding ScrollConfiguration (and through it the ScrollBehavior) for the platform default. That is why a ListView in your app feels Android-y on Android and iOS-y on iOS without you doing anything.
You override the platform default in three ways, in increasing order of scope:
- Per scroll view — pass
physics:directly. - Per subtree — wrap the subtree in a
ScrollConfigurationwith a customScrollBehavior. - Per app — set
MaterialApp.scrollBehavior(orCupertinoApp.scrollBehavior).
For most apps, option 1 is the only one you need.
Combining physics with parent
Most physics classes accept a parent: argument. The trick is that physics chain — the parent’s behaviour kicks in for the things the child does not handle. The most common pattern:
// "I want bounce on iOS even on Android"
const physics = BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics());
CustomScrollView(
physics: physics,
slivers: [...],
)
That reads as: “always scrollable, with bouncing overscroll”.
This is the standard physics for any scroll view that hosts a RefreshIndicator or a CupertinoSliverRefreshControl, because pull-to-refresh requires the scroll to be active even when there’s nothing to scroll to.
Pull-to-refresh requires the right physics
This is the single most common physics-related bug. The fix is one line.
RefreshIndicator(
onRefresh: _onRefresh,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(), // ← without this, short lists never refresh
slivers: [...],
),
)
Why? Because ClampingScrollPhysics (the default on Android) refuses to overscroll when the content already fits the screen. No overscroll, no refresh trigger.
Disabling scroll temporarily
When you want a scroll view to exist but not scroll right now (e.g., during an animation, or because a modal is open), use NeverScrollableScrollPhysics:
CustomScrollView(
physics: _isModalOpen ? const NeverScrollableScrollPhysics() : null,
slivers: [...],
)
This is cheaper than rebuilding the whole tree without the scroll view.
Don’t use
NeverScrollableScrollPhysicsto nest a list inside another list. That is the anti-pattern from chapter 05. UseCustomScrollViewinstead.
Snap physics (the rare cases)
For “snap to the next item” behaviour — image carousels, page-by-page scrolls — Flutter has dedicated widgets and physics:
PageViewwith the defaultPageScrollPhysicsfor full-screen page snapping.ListWheelScrollViewwithFixedExtentScrollPhysicsfor iOS-style picker wheels.
Slivers themselves do not have built-in snap. If you need a custom snap (e.g. snap-to-section in a long scroll), you write a ScrollPhysics subclass that overrides createBallisticSimulation — but that is advanced and rarely needed.
For “snap the SliverAppBar in or out”, you don’t need custom physics. You just use floating: true, snap: true on the app bar — that snap is implemented inside the sliver itself, not in the physics.
Minimal example: the four common physics
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: const Text('Physics demo'),
bottom: const TabBar(tabs: [
Tab(text: 'Bounce'),
Tab(text: 'Clamp'),
Tab(text: 'Always'),
Tab(text: 'Never'),
]),
),
body: const TabBarView(children: [
_Demo(physics: BouncingScrollPhysics()),
_Demo(physics: ClampingScrollPhysics()),
_Demo(physics: AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics())),
_Demo(physics: NeverScrollableScrollPhysics()),
]),
),
),
);
}
}
class _Demo extends StatelessWidget {
const _Demo({required this.physics});
final ScrollPhysics physics;
@override
Widget build(BuildContext context) {
return CustomScrollView(
physics: physics,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 30,
),
),
],
);
}
}
Swipe between tabs and try to scroll in each. The Never tab won’t scroll at all; the Always tab will overscroll-bounce even if you make the list short.
Dos and don’ts
| Do | Don’t |
|---|---|
Use AlwaysScrollableScrollPhysics whenever you wrap a scroll view in RefreshIndicator. | Don’t use NeverScrollableScrollPhysics to nest scrollables. Use slivers. |
Chain physics with parent: for “almost the default, but also X”. | Don’t write a custom ScrollPhysics subclass unless you need a custom simulation. |
| Test scroll behaviour on both Android and iOS — the defaults differ. | Don’t hard-code BouncingScrollPhysics if you want platform-correct feel. |
Three things to remember
- Physics is about the feel of the scroll. Slivers do not depend on physics.
- Pull-to-refresh on short content needs
AlwaysScrollableScrollPhysics. NeverScrollableScrollPhysicsis for temporarily freezing a scroll, not for nesting scrolls. Nest with slivers instead.
Previous · Next
← 07. Common pitfalls and error messages → 09. ScrollController and ScrollNotification
09. ScrollController and ScrollNotification
TL;DR
There are two ways to know what a scroll view is doing:
ScrollController— pull. You hold a controller, attach it to a scroll view, and read itspositionwhenever you want. You can also call methods likejumpToandanimateToto move the scroll programmatically.NotificationListener<ScrollNotification>— push. You wrap a scroll view in a listener and React when the scroll fires events: start, update, end.
Use a controller when you want to ask “where is the scroll right now?” or want to drive it. Use a notification listener when the scroll wants to tell you “something happened”.
ScrollController, the pull side
A controller is a long-lived object you create in initState and dispose in dispose. You pass it to the scroll view’s controller: parameter, and Flutter wires it up.
class _MyScreenState extends State<MyScreen> {
final ScrollController _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: _controller,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(_, i) => ListTile(title: Text('Row $i')),
childCount: 200,
),
),
],
);
}
}
Now you can do anything from outside the build:
_controller.jumpTo(0); // jump to top
_controller.animateTo(500, duration: const Duration(milliseconds: 400), curve: Curves.easeOut);
final position = _controller.position.pixels; // read offset
final maxOffset = _controller.position.maxScrollExtent; // read max
final isAtBottom = _controller.position.atEdge && position > 0; // at bottom?
Listening for scroll changes
A controller also fires a notification every time the position changes. The cheapest way to listen:
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
}
void _onScroll() {
final p = _controller.position;
if (p.pixels >= p.maxScrollExtent - 300) {
_loadMoreIfNeeded();
}
}
The listener is called many times per frame during a scroll. Make it cheap.
Don’t
setStateinside the listener unless you really need a rebuild. Most “react to scroll” UI (like a “scroll to top” button that fades in past 400px) should use a tinyValueNotifier<bool>and aValueListenableBuilderso only the button rebuilds, not the whole screen.
Common controller mistakes
- Creating the controller inside
build(). It will be re-created every rebuild and the scroll position will reset. - Forgetting to dispose. Memory leak.
- Reading
_controller.positionininitState. The controller hasn’t attached yet — wait until after the first frame (addPostFrameCallback) or until a scroll event fires. - Sharing one controller across two scroll views. A controller can attach to multiple, but
positionthen becomes ambiguous. Don’t do it unless you know what you’re doing.
ScrollNotification, the push side
NotificationListener<ScrollNotification> is the other way. You wrap a scroll view (or any widget tree containing scroll views) and Flutter calls your callback whenever a scroll notification bubbles up.
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification is ScrollStartNotification) {
// user just touched the screen and started scrolling
} else if (notification is ScrollUpdateNotification) {
// scroll position changed; notification.metrics.pixels is the new offset
} else if (notification is ScrollEndNotification) {
// user lifted finger and scrolling stopped (after any momentum)
} else if (notification is OverscrollNotification) {
// user dragged past an edge; notification.overscroll is the amount
}
return false; // false = let the notification keep bubbling
},
child: CustomScrollView(slivers: [...]),
)
Each notification carries a metrics object that is essentially the same data as controller.position — pixels, maxScrollExtent, viewportDimension, etc.
Why use notifications instead of a controller?
- You don’t have to manage the controller’s lifecycle. No dispose, no state class.
- You can listen to scroll events from multiple scroll views in the same subtree with one listener (the listener catches notifications from any descendant scroll).
- You can stop the notification from bubbling further by returning
true— useful when you have nested scroll views and only want to react to the inner one.
Why use a controller instead of notifications?
- You want to drive the scroll, not just observe it. Notifications can’t
jumpTo. - You want the current position outside of an event — e.g. read it inside a button’s
onPressed. With a notification listener you only have the position at the moment of an event. - You want the scroll position to survive widget rebuilds — controllers are state.
In practice, a lot of screens use both: a controller to drive scroll programmatically, and a small ValueNotifier updated from a notification listener to power UI like “show ‘scroll to top’ button”.
A complete example: scroll-to-top button + load more
This is the canonical pattern. It uses both a controller and a notifier-based UI.
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: FeedScreen());
}
class FeedScreen extends StatefulWidget {
const FeedScreen({super.key});
@override
State<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends State<FeedScreen> {
final ScrollController _controller = ScrollController();
final ValueNotifier<bool> _showJumpUp = ValueNotifier<bool>(false);
final List<int> _items = List.generate(40, (i) => i);
bool _loading = false;
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
}
@override
void dispose() {
_controller.removeListener(_onScroll);
_controller.dispose();
_showJumpUp.dispose();
super.dispose();
}
void _onScroll() {
final p = _controller.position;
_showJumpUp.value = p.pixels > 600;
if (p.pixels >= p.maxScrollExtent - 200 && !_loading) {
_loadMore();
}
}
Future<void> _loadMore() async {
setState(() => _loading = true);
await Future<void>.delayed(const Duration(milliseconds: 500));
setState(() {
_items.addAll(List.generate(20, (i) => _items.length + i));
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
controller: _controller,
slivers: [
const SliverAppBar(title: Text('Feed'), pinned: true),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, i) => ListTile(title: Text('Item ${_items[i]}')),
childCount: _items.length,
),
),
if (_loading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
),
],
),
floatingActionButton: ValueListenableBuilder<bool>(
valueListenable: _showJumpUp,
builder: (context, show, _) => AnimatedSlide(
offset: show ? Offset.zero : const Offset(0, 2),
duration: const Duration(milliseconds: 200),
child: FloatingActionButton(
onPressed: () => _controller.animateTo(
0,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
),
child: const Icon(Icons.arrow_upward),
),
),
),
);
}
}
The whole screen rebuilds at most when the item list changes. The “show jump up” button is wired through a ValueNotifier, so its rebuild is local to the FloatingActionButton. This is the right way to react to scroll without breaking performance.
Three things to remember
- Use a
ScrollControllerto drive or to read scroll on demand. Use aNotificationListenerto react to events as they happen. They are not interchangeable. - Never
setStateinside a scroll listener. Use aValueNotifier+ValueListenableBuilderso only the bit of UI that depends on scroll rebuilds. - A controller’s lifecycle is your responsibility. Create in
initState, dispose indispose. Never insidebuild().
Previous · Next
← 08. ScrollPhysics and scroll behaviour → 10. Building your own custom sliver
10. Building your own custom sliver
TL;DR
Most of the time you don’t need to write a custom sliver. The 47 built-in slivers in this book cover the situations real apps run into. But sometimes you genuinely need a sliver that doesn’t exist yet — a parallax background that responds to its own scroll offset, a sticky band that snaps to a different schedule than SliverAppBar, a custom layout for a 2D grid that the built-in delegates can’t express.
Writing a custom sliver means writing a RenderSliver subclass and a thin widget that creates it. It is one level below normal widget code, and the API is small but unforgiving. This chapter is the map.
If you are new to Flutter, you can safely skip this chapter and come back when you actually need it.
The four pieces of a custom sliver
To write your own, you need four things:
- A
Widgetsubclass — usuallySingleChildRenderObjectWidgetorMultiChildRenderObjectWidget— that creates the render object. - A
RenderSliversubclass — the render object that does the actual layout. - An override of
performLayout()that readsSliverConstraintsand returns aSliverGeometry. - An override of
paint()that draws the visible portion.
The first one is short. The other three are where the real work is.
The constraint-and-geometry contract
When the viewport asks a sliver to lay itself out, it passes a SliverConstraints object with these fields (the ones you actually use):
| Field | Meaning |
|---|---|
axisDirection | down, up, right, or left. |
scrollOffset | How many pixels the user has scrolled past this sliver’s start. |
remainingPaintExtent | How much visible viewport space is left for this sliver. |
viewportMainAxisExtent | The total height of the viewport. |
crossAxisExtent | The width (in vertical scrolls) available for this sliver. |
cacheOrigin, remainingCacheExtent | Like the paint versions, but for the invisible cache. |
The sliver responds with a SliverGeometry:
| Field | Meaning |
|---|---|
scrollExtent | Total height of the sliver, including the part not visible right now. |
paintExtent | How much it draws on screen right now. |
layoutExtent | How much it eats from the scroll (≤ paintExtent). |
maxPaintExtent | The largest paintExtent it could ever draw (used for scrollbar sizing). |
paintOrigin | Where to start drawing relative to the viewport’s top edge for this sliver. Used for floating/pinning. |
hitTestExtent | How much of itself accepts pointer events. Defaults to paintExtent. |
visible | false to skip painting entirely. |
hasVisualOverflow | true if the sliver paints outside its layout box (so the parent can clip). |
Filling these correctly is most of the work. Get them wrong and the viewport above and below this sliver will misalign.
A minimal example: a “fixed pixel band” sliver
Suppose you want a sliver that always takes exactly 80 pixels of scroll, draws a colored band, and does nothing else. (This is what SliverToBoxAdapter would do for an 80-pixel Container, so this is a toy example — but it shows every part of the contract.)
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class FixedBand extends LeafRenderObjectWidget {
const FixedBand({super.key, required this.color, this.height = 80});
final Color color;
final double height;
@override
RenderObject createRenderObject(BuildContext context) =>
_RenderFixedBand(color: color, height: height);
@override
void updateRenderObject(BuildContext context, _RenderFixedBand renderObject) {
renderObject
..color = color
..height = height;
}
}
class _RenderFixedBand extends RenderSliver {
_RenderFixedBand({required Color color, required double height})
: _color = color,
_height = height;
Color _color;
Color get color => _color;
set color(Color value) {
if (_color == value) return;
_color = value;
markNeedsPaint();
}
double _height;
double get height => _height;
set height(double value) {
if (_height == value) return;
_height = value;
markNeedsLayout();
}
@override
void performLayout() {
final double paintExtent = calculatePaintOffset(constraints, from: 0, to: _height);
final double cacheExtent = calculateCacheOffset(constraints, from: 0, to: _height);
geometry = SliverGeometry(
scrollExtent: _height,
paintExtent: paintExtent,
cacheExtent: cacheExtent,
maxPaintExtent: _height,
hitTestExtent: paintExtent,
hasVisualOverflow: false,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (geometry!.visible) {
final Rect rect = offset & Size(constraints.crossAxisExtent, geometry!.paintExtent);
context.canvas.drawRect(rect, Paint()..color = _color);
}
}
}
And you use it like any other sliver:
CustomScrollView(slivers: [
const FixedBand(color: Colors.indigo),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, i) => ListTile(title: Text('Row $i')),
childCount: 50,
),
),
])
Notice the helpers calculatePaintOffset and calculateCacheOffset. Those are convenience methods on SliverConstraints that turn a “from..to” range in your local sliver coordinates into the right number of visible pixels for the current scroll. Use them — they handle the edge cases (sliver partially scrolled, partially visible, fully off screen) for you.
Three patterns from real custom slivers
If you decide to build a real one, you will probably hit one of these three patterns.
1. A sliver that wraps a single box child
Use SingleChildRenderObjectWidget and RenderObjectWithChildMixin<RenderBox>. Your performLayout() first calls child!.layout(BoxConstraints(...)) to lay out the child as a box, then computes the SliverGeometry from the child’s resulting size. SliverToBoxAdapter is a 30-line example of this pattern.
2. A sliver that pins or floats
Use paintOrigin. The viewport asks where to draw your sliver from; if you return a non-zero paintOrigin, the visual position separates from the scroll position. SliverPersistentHeader does this. The trick: report a layoutExtent smaller than paintExtent so the next sliver moves on while you keep drawing.
3. A sliver with multiple children that lays them out lazily
Subclass RenderSliverMultiBoxAdaptor and implement the lazy build/layout cycle. This is what SliverList and SliverGrid do internally. It is a hundred lines of careful child-management code, and you should only attempt it if no combination of existing slivers does what you need.
What to do before writing a custom sliver
In order:
- Re-read chapter 05. Many “custom sliver” needs are actually a stack of two existing slivers in disguise.
- Try
SliverPersistentHeaderwith a custom delegate for collapsing/floating effects. It already wraps the hard parts. - Try
SliverMainAxisGroupandSliverCrossAxisGroupfor layouts that combine multiple slivers along an axis. - Try
SliverLayoutBuilderif your need is “build a sliver based on the current scroll state”. It lets you switch between slivers without writing render-object code.
If after all four the need still isn’t covered, then a custom sliver is the right answer.
Three things to remember
performLayout()readsSliverConstraintsand writesSliverGeometry. That is the entire contract.paintExtentis what you draw,layoutExtentis what you eat from the scroll,paintOriginis where you start drawing. Pinning and floating are entirely about decoupling these three numbers.- Try every existing sliver first. Custom render objects are fragile, untested by the framework, and easy to get wrong.
Previous · Next
← 09. ScrollController and ScrollNotification → Back to the book index, or jump into 10-scrollables.
Part 10
Scrollables
The item producers: lists, grids, fills, trees, and the adapters that bridge boxes to slivers.
SliverList
TL;DR: A lazy linear list of children, sized by each child’s intrinsic height. The sliver version of ListView.
What is it?
SliverList is the bread-and-butter sliver for “I want a vertical list of rows inside a CustomScrollView”. Each child can be any height — the sliver measures them as it goes. It does not pre-compute total height; it estimates as it builds.
If ListView.builder is the convenience widget, SliverList is what ListView.builder actually creates internally. The only reason to use SliverList directly (instead of ListView) is to put it next to other slivers in the same scroll.
Mental model
The viewport asks SliverList: “starting at scroll offset X, please lay out children until the visible window is full”. SliverList walks its delegate, builds child 0, asks how tall it is, places it, builds child 1, and so on, until the cumulative height fills the window plus the cache extent. Children outside this window are disposed.
Heights can vary — that is the whole point. If each row is the same height, SliverFixedExtentList is faster.
Constructor & key parameters
SliverList has three constructors:
| Constructor | Use when |
|---|---|
SliverList({required SliverChildDelegate delegate}) | You already have a delegate (rare in app code, common when wrapping). |
SliverList.builder({required NullableIndexedWidgetBuilder itemBuilder, int? itemCount, ...}) | The default. Lazy, indexed, the most common form. |
SliverList.separated({required NullableIndexedWidgetBuilder itemBuilder, required NullableIndexedWidgetBuilder separatorBuilder, int? itemCount, ...}) | When you want a separator widget between rows (a Divider, a spacer, etc.). |
Important parameters on the builder/separated forms:
| Parameter | What it does |
|---|---|
itemBuilder | Builds child at a given index. Return null to signal “no more items” if itemCount is null. |
itemCount | Total item count, or null for infinite. Always provide it if known — it lets the scrollbar size correctly. |
findChildIndexCallback | Maps a Key back to its current index. Set this whenever items can move/reorder so widgets get recycled correctly. |
addAutomaticKeepAlives | Defaults true. Makes children that opt into AutomaticKeepAliveClientMixin actually be kept. |
addRepaintBoundaries | Defaults true. Wraps each child in a RepaintBoundary so a redraw of one child does not redraw its neighbours. |
addSemanticIndexes | Defaults true. Tags each child with a semantic index for accessibility. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverList')),
SliverList.builder(
itemCount: 50,
itemBuilder: (BuildContext context, int index) {
return ListTile(
leading: CircleAvatar(child: Text('$index')),
title: Text('Row $index'),
subtitle: Text('Each row can have a different height.'),
);
},
),
],
),
),
);
}
}
Real-world use cases
- A feed of mixed-height cards (image card, text card, ad card).
- Comments under a post — each comment can be one line or twenty.
- Settings groups with varying numbers of options inside each section.
- A search results list with thumbnails of different sizes.
When to use it
- ✅ Children have different heights.
- ✅ You need it inside a
CustomScrollViewnext to other slivers. - ✅ You want lazy building of a long or infinite list.
When NOT to use it
- ❌ Every child is the same height — use
SliverFixedExtentListfor ~30% better scroll perf. - ❌ Heights are determined by index but vary — use
SliverVariedExtentList, which lets you compute extent without building. - ❌ You only need a flat list with no sibling slivers — use
ListView.builderdirectly. - ❌ The list is only 5-10 items — use
ColumnorSliverChildListDelegate.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Column(children: List.generate(1000, ...)) inside SingleChildScrollView | Eager build, OOM on long lists. | CustomScrollView + SliverList.builder. |
ListView.builder(shrinkWrap: true, physics: NeverScrollableScrollPhysics()) inside another scroll view | Eager build to compute height. | SliverList.builder directly inside the parent CustomScrollView. |
Dos and don’ts
| Do | Don’t |
|---|---|
Always pass itemCount when you know it. | Don’t pass null itemCount unless you really mean “infinite”. |
Use SliverList.builder for anything more than ~20 items. | Don’t pre-build a List<Widget> and pass it to SliverChildListDelegate for long lists. |
Pass findChildIndexCallback when items can reorder. | Don’t rely on StatefulWidget state inside list items — it dies on scroll-off. |
Add Keys to items if they have identity (e.g. by ID). | Don’t use the index as a key; the index changes when items insert/delete. |
Common pitfalls
- State disappears on scroll-off. Lift state to the parent or use
AutomaticKeepAliveClientMixin(sparingly). See chapter 07 pitfall #7. - Janky scroll on tall items. Increase
cacheExtenton the hostCustomScrollView, or split tall items into smaller pieces. - Items “jump” when inserted at top. Use
findChildIndexCallbackso Flutter can recycle widgets across the insertion.
Related widgets
SliverFixedExtentList— same shape, fixed height, faster.SliverVariedExtentList— variable height computed without building.SliverPrototypeExtentList— height inferred from a prototype widget.SliverGrid— same idea, two-dimensional.
Official docs
SliverFixedExtentList
TL;DR: A SliverList where every child is forced to the same main-axis size. Faster than SliverList because the sliver can compute layout without building any children.
What is it?
SliverFixedExtentList is SliverList with one extra promise: every child has the same height (or width, in horizontal scrolls). You give it a number — itemExtent — and the sliver enforces it. Children that try to be a different size are clipped or stretched.
That promise is worth a real performance win. Because the sliver knows each child is itemExtent pixels tall, it can answer “what’s at scroll offset 12,000 in a list of 10,000 items?” with one division — no building, no measuring. SliverList, by contrast, has to build and measure every child up to that point.
Mental model
If SliverList is “build, measure, place, repeat”, SliverFixedExtentList is “skip ahead, place, repeat”. The skip-ahead is what makes very long lists smooth.
Constructor & key parameters
| Constructor | Use when |
|---|---|
SliverFixedExtentList({required SliverChildDelegate delegate, required double itemExtent}) | You already have a delegate. |
SliverFixedExtentList.builder({required NullableIndexedWidgetBuilder itemBuilder, required double itemExtent, int? itemCount, ...}) | The default. Lazy + fixed height. |
SliverFixedExtentList.list({required List<Widget> children, required double itemExtent, ...}) | Short, fixed list of children. |
| Parameter | What it does |
|---|---|
itemExtent | The required size of each child along the scroll axis, in logical pixels. |
itemBuilder, itemCount, findChildIndexCallback, addAutomaticKeepAlives, addRepaintBoundaries, addSemanticIndexes | Same meaning as on SliverList. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Fixed extent list')),
SliverFixedExtentList.builder(
itemExtent: 64,
itemCount: 5000,
itemBuilder: (BuildContext context, int index) {
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
),
),
child: Text('Row $index'),
);
},
),
],
),
),
);
}
}
5,000 rows, instant scroll-to-end, zero jank. The itemExtent: 64 is the magic line.
Real-world use cases
- Long contact lists where every row is the same fixed-height tile.
- Music tracks in an album view.
- Calendar day cells in a long horizontal day picker.
- Any list where the design says “rows are exactly N pixels tall”.
When to use it
- ✅ Every child has the same main-axis size, and you know it ahead of time.
- ✅ The list is long (> 100 items).
- ✅ You want jump-to-anywhere scrolling without waiting for items to build.
When NOT to use it
- ❌ Children have different heights — use
SliverList. - ❌ Heights vary but are computable from index (e.g. by reading data) — use
SliverVariedExtentList. - ❌ You want the height to come from a prototype widget — use
SliverPrototypeExtentList. - ❌ You have a short fixed list (< 20 items) —
SliverListis fine.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
SliverList.builder for a 10,000-item uniform-height list | Builds N items just to figure out where item 9,000 starts. | SliverFixedExtentList.builder with itemExtent. |
ListView(itemExtent: 56, ...) inside a Column | Same eager-build problem inside the host. | CustomScrollView + SliverFixedExtentList. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Use this whenever your design has fixed-height tiles. | Don’t use it if any child can be a different size — your child will be clipped or stretched. |
Pick itemExtent to match your tile design (most Material list tiles are 56 or 72). | Don’t pick itemExtent smaller than your child’s content; you’ll lose the bottom of the tile. |
Common pitfalls
- Children look squashed. Your child wants more than
itemExtentpixels. Either increaseitemExtentor use a smaller child (no extra padding, smaller text). - You wanted variable rows but used this widget. Switch to
SliverList. The fixed-extent contract is enforced, not advisory. itemExtentchanges between rebuilds and the list jumps. Avoid recomputingitemExtentfrom a value that changes. Make it aconstif possible.
Related widgets
SliverList— variable heights, slower for long lists.SliverVariedExtentList— variable heights, computed from index.SliverPrototypeExtentList— height inferred from a prototype.SliverGrid— 2D version withmainAxisExtentparameter for the same effect.
Official docs
SliverVariedExtentList
TL;DR: A list where each child has a different height, and you tell the sliver each height without building the child. Bridges the gap between SliverList (slow because it builds to measure) and SliverFixedExtentList (fast but uniform).
What is it?
SliverVariedExtentList is the “I know my row heights from data, not from layout” sliver. You give it an itemExtentBuilder callback that takes an index and returns a number — the height of that row. The sliver uses those numbers to compute scroll positions without ever building the children that aren’t visible.
This was added in Flutter 3.13. Before it existed, your only options were SliverList (slow on long heterogeneous lists) or SliverFixedExtentList (uniform only).
Mental model
itemExtentBuilder(0) → 80
itemExtentBuilder(1) → 56
itemExtentBuilder(2) → 120
itemExtentBuilder(3) → 56
...
The sliver adds these up to figure out where each item sits in the scroll. To jump to scroll offset 12,000, the sliver does a search through the extent values (cheap) instead of building 200 widgets and measuring them (expensive).
The callback is called only for indices the sliver currently needs — not for every item up front.
Constructor & key parameters
| Constructor | Use when |
|---|---|
SliverVariedExtentList({required SliverChildDelegate delegate, required ItemExtentBuilder itemExtentBuilder}) | You already have a delegate. |
SliverVariedExtentList.builder({required NullableIndexedWidgetBuilder itemBuilder, required ItemExtentBuilder itemExtentBuilder, int? itemCount, ...}) | The default. |
SliverVariedExtentList.list({required List<Widget> children, required ItemExtentBuilder itemExtentBuilder, ...}) | Short, fixed list. |
| Parameter | What it does |
|---|---|
itemExtentBuilder | double? Function(int index, SliverLayoutDimensions dimensions). Returns the height of the item at index. Return null if asked for an out-of-range index. |
itemBuilder, itemCount, … | Same as SliverList. |
The dimensions argument gives you viewportMainAxisExtent, crossAxisExtent, etc., so you can compute heights based on viewport size — for example, “make every fifth row 50% of the viewport”.
Minimal example
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show SliverLayoutDimensions;
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final List<int> heights = List.generate(500, (i) => 56 + (i % 5) * 24);
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Varied extent list')),
SliverVariedExtentList.builder(
itemCount: heights.length,
itemExtentBuilder: (int index, SliverLayoutDimensions dimensions) {
if (index >= heights.length) return null;
return heights[index].toDouble();
},
itemBuilder: (BuildContext context, int index) {
return Container(
color: index.isEven ? Colors.indigo.shade50 : Colors.white,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text('Row $index — ${heights[index]}px'),
);
},
),
],
),
),
);
}
}
500 rows of 5 different heights, all scrollable smoothly with cheap jump-to-anywhere. The trick is that heights[index] is precomputed (or comes from data), so the sliver never has to build a row to learn its height.
Real-world use cases
- A chat where each message’s height depends on its character count, but you can compute the height from text length without rendering.
- A timeline where rows are 56px for normal events and 120px for highlighted ones — chosen by data, not by content.
- A long form where each field’s height is known from its
type(text=56, multiline=120, dropdown=56). - A photo grid where each cell’s height is taken from a server-supplied aspect ratio.
When to use it
- ✅ Heights vary, but you can compute them cheaply from
indexand your data model. - ✅ The list is long enough that
SliverList’s build-to-measure cost matters. - ✅ You want fast jump-to-position (e.g. “scroll to message #5000”).
When NOT to use it
- ❌ Heights are uniform — use
SliverFixedExtentList. - ❌ Heights depend on the rendered content and you cannot compute them ahead — use
SliverList. - ❌ You don’t have the data needed to compute heights yet (e.g. text needs to be measured to know its size) —
SliverListis the only honest answer.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
SliverList for a long list with computable heights | Builds and measures each item, pays the cost twice. | SliverVariedExtentList with a precomputed extents map. |
| Maintaining your own scroll-to-index by summing heights | You build a duplicate of what the sliver should do internally. | Pass the same height function to itemExtentBuilder. |
Dos and don’ts
| Do | Don’t |
|---|---|
Make itemExtentBuilder cheap and pure. | Don’t read from a Future or do disk I/O inside the builder. |
| Cache height computations if they are expensive (e.g., text measurement). | Don’t measure text inside the builder for every call — pre-compute and store. |
Return null for out-of-range indices to signal end of list. | Don’t return a positive number for an out-of-range index — the sliver will try to build it. |
Common pitfalls
itemExtentBuilderis called many times. That is normal during scrolling. Make it O(1).- Heights and items diverge. If you change the heights but not the items (or vice versa), the sliver will paint the wrong content. Always update both together.
- Wrong height returned. The child is forced to that height. If you return 56 but build a 120-pixel widget, it gets clipped.
Related widgets
SliverFixedExtentList— uniform fast version.SliverList— variable, builds to measure.SliverPrototypeExtentList— uniform but height inferred from a prototype.
Official docs
SliverPrototypeExtentList
TL;DR: A list where every child is forced to be the same size as a prototype widget you provide. Faster than SliverList (uniform), but more flexible than SliverFixedExtentList (the size comes from a real widget instead of a hard-coded number).
What is it?
SliverPrototypeExtentList is a uniform-extent list, like SliverFixedExtentList, except you don’t pass a pixel value. You pass a prototype widget, and the sliver lays it out once (offstage) to figure out its size, then forces every real child to match.
This is useful when the row height should track theme or content changes — for example, if your tile uses Theme.of(context).textTheme.bodyLarge.fontSize, the prototype will pick up the theme automatically, and you never have to hard-code a pixel value.
Mental model
1. Build the prototype widget once.
2. Measure it. The result becomes itemExtent.
3. Use that itemExtent for every real child, exactly like SliverFixedExtentList.
The prototype is not painted and not interactive. It exists only to be measured.
Constructor & key parameters
| Constructor | Use when |
|---|---|
SliverPrototypeExtentList({required SliverChildDelegate delegate, required Widget prototypeItem}) | You already have a delegate. |
SliverPrototypeExtentList.builder({required NullableIndexedWidgetBuilder itemBuilder, required Widget prototypeItem, int? itemCount, ...}) | The default. |
SliverPrototypeExtentList.list({required List<Widget> children, required Widget prototypeItem, ...}) | Short, fixed list. |
| Parameter | What it does |
|---|---|
prototypeItem | A widget whose size determines every child’s main-axis extent. |
itemBuilder, itemCount, findChildIndexCallback, … | Same as SliverList. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Prototype extent list')),
SliverPrototypeExtentList.builder(
prototypeItem: const ListTile(
title: Text('Prototype'),
subtitle: Text('used only for measurement'),
),
itemCount: 200,
itemBuilder: (BuildContext context, int index) {
return ListTile(
leading: CircleAvatar(child: Text('$index')),
title: Text('Item $index'),
subtitle: const Text('Real row, same size as the prototype.'),
);
},
),
],
),
),
);
}
}
The prototype ListTile is laid out once. Whatever size it ends up — based on theme, padding, and font — becomes the height of every actual row.
Real-world use cases
- A list whose row height should match the current text scale (accessibility settings).
- A list shared across screens where the row design has padding/margins that vary with the surrounding theme.
- A list of “loading skeleton” placeholders whose size should match the eventual real row.
- Any list where “this is what one row looks like” is easier to express as a widget than as a number.
When to use it
- ✅ Every child has the same height, but the height should track a widget instead of a constant.
- ✅ The list is long and you want fast scroll-to-index.
- ✅ The “right” pixel value is theme-dependent and you don’t want to recompute it on every theme change.
When NOT to use it
- ❌ You already know the exact pixel height — use
SliverFixedExtentList. It is one less object to construct. - ❌ Heights vary — use
SliverListorSliverVariedExtentList. - ❌ The prototype is expensive to build (e.g., reads from disk or talks to a service). The prototype is built every layout.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
SliverFixedExtentList(itemExtent: 56) next to a theme that scales row height | The number 56 does not change with the theme; rows look squashed when the user enables large text. | SliverPrototypeExtentList with a prototype ListTile. |
Wrapping every row in a SizedBox(height: knownHeight) | Duplicates the height in two places. | Move the height into the prototype, drop the SizedBox. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Make the prototype as similar to a real row as possible. | Don’t make the prototype expensive — it is built and measured during layout. |
Wrap the prototype in const if you can. | Don’t include input controls or animations in the prototype; they will be wasted work. |
Common pitfalls
- Real rows don’t match the prototype size. That is fine — the children are forced to the prototype’s height. If your real row is taller, it gets clipped.
- The prototype shows up on screen. It shouldn’t. The sliver builds it offstage and discards the visual. If you see it, you wrapped it in a different widget by mistake.
- Layout thrash on theme change. The prototype rebuilds and re-measures, the entire list reflows. This is expected behaviour, but can feel slow on very long lists.
Related widgets
SliverFixedExtentList— same idea with a hard-coded pixel value.SliverVariedExtentList— different size per index.SliverList— variable, builds to measure.
Official docs
SliverGrid
TL;DR: A lazy two-dimensional grid of children. The sliver version of GridView. Takes two delegates: one for the layout (how many columns, how big the tiles), one for the children.
What is it?
SliverGrid is the grid you put inside a CustomScrollView. It works exactly like GridView.builder, except you can place it next to other slivers. The grid layout is described by a SliverGridDelegate (see chapter 06) — pick WithFixedCrossAxisCount for “exactly N columns” or WithMaxCrossAxisExtent for “responsive, fit as many as possible”.
Mental model
The grid asks the SliverGridDelegate for a SliverGridLayout, which is a recipe for “where does tile #N sit and how big is it?”. The grid then walks visible tiles by index and places them with that recipe. Off-screen tiles are not built — same laziness story as SliverList.
Constructor & key parameters
| Constructor | Use when |
|---|---|
SliverGrid({required SliverChildDelegate delegate, required SliverGridDelegate gridDelegate}) | The default. |
SliverGrid.builder({required SliverGridDelegate gridDelegate, required NullableIndexedWidgetBuilder itemBuilder, int? itemCount, ...}) | Lazy + builder. |
SliverGrid.count({required int crossAxisCount, double mainAxisSpacing = 0, double crossAxisSpacing = 0, double childAspectRatio = 1, required List<Widget> children}) | Short list, fixed columns. |
SliverGrid.extent({required double maxCrossAxisExtent, double mainAxisSpacing = 0, double crossAxisSpacing = 0, double childAspectRatio = 1, required List<Widget> children}) | Short list, responsive columns. |
| Parameter | What it does |
|---|---|
gridDelegate | The layout recipe. See the delegate pages. |
delegate | Supplies the children. Usually a SliverChildBuilderDelegate. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverGrid')),
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1,
),
itemCount: 60,
itemBuilder: (BuildContext context, int index) {
return Container(
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length].shade200,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: Text('$index', style: const TextStyle(fontSize: 24)),
);
},
),
),
],
),
),
);
}
}
On a phone you get 2 columns, on a tablet 4, on desktop 6 — without any media query code, because WithMaxCrossAxisExtent does the math.
Real-world use cases
- Photo galleries.
- Product catalogues.
- Emoji pickers.
- Color pickers.
- Settings pages with icon-based action tiles.
When to use it
- ✅ You need a grid inside a
CustomScrollViewnext to other slivers (a header, a list, a footer). - ✅ You want responsive column counts (
WithMaxCrossAxisExtent). - ✅ You want lazy building of a long grid.
When NOT to use it
- ❌ You only need a flat grid with no sibling slivers — use
GridView.builder. It is the same thing, with less typing. - ❌ The “tiles” are actually rows of data — use a list, not a grid.
- ❌ Tiles have wildly different sizes (Pinterest-style waterfall) —
SliverGridcannot do that. Use a 3rd-party staggered grid package, or build a custom sliver.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
GridView.count(shrinkWrap: true, physics: NeverScrollableScrollPhysics()) inside a Column inside SingleChildScrollView | Eager build, slow. | CustomScrollView + SliverGrid.builder. |
Wrap of children inside a scroll view | Eager build, no laziness, no per-row spacing control. | SliverGrid with the right delegate. |
Dos and don’ts
| Do | Don’t |
|---|---|
Use SliverGridDelegateWithMaxCrossAxisExtent for responsive layouts. | Don’t hard-code crossAxisCount if your app supports tablet/desktop. |
Use mainAxisExtent (on the delegate) when you want fixed-height tiles regardless of width. | Don’t fight childAspectRatio to get a specific tile height — mainAxisExtent is simpler. |
Wrap the grid in SliverPadding for outer margins. | Don’t put Padding around each tile to fake margins; use mainAxisSpacing and crossAxisSpacing. |
Common pitfalls
- Tiles look too tall or too short.
childAspectRatio: 1gives you square tiles based on tile width. If the screen is wide, the tiles get tall. UsemainAxisExtentinstead for “fixed height regardless of width”. - Items at row edges have no spacing from the screen edge. Wrap the grid in
SliverPadding. - Performance dies on a long grid with complex tiles. Each tile rebuilds when scrolled in. Add a
RepaintBoundary(the delegate does this by default), and avoid reading largeInheritedWidgets in the builder. - You wanted variable tile sizes (staggered).
SliverGriddoes not support that. Look forflutter_staggered_grid_viewor write a custom sliver.
Related widgets
SliverList— 1D version.SliverGridDelegateWithFixedCrossAxisCount— fixed columns delegate.SliverGridDelegateWithMaxCrossAxisExtent— responsive delegate.
Official docs
SliverToBoxAdapter
TL;DR: The universal “I have a regular box widget and I need it inside a slivers: list” adapter. Wraps a single box child in a sliver shell.
What is it?
SliverToBoxAdapter is one of the most-used slivers in real apps. It does exactly one job: take a single box widget (a Container, Image, Card, Padding, Column, anything) and present it as a sliver so it can sit in a CustomScrollView’s slivers: list.
It does not make the child lazy. The child is built every time the adapter is built, regardless of whether it is on screen. For a single header or banner that is fine. For a long list of items, it is wrong — use SliverList instead.
Mental model
SliverToBoxAdapter
↓ wraps
[Box widget]
→ measured once during layout
→ painted whenever it overlaps the visible window
→ its scrollExtent = its box height
→ its layoutExtent = scrollExtent
It is the cheapest possible “lift a box into the sliver world” wrapper.
Constructor & key parameters
const SliverToBoxAdapter({Key? key, Widget? child})
That’s it. One optional child. There are no flags.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverToBoxAdapter(
child: Container(
height: 200,
color: Colors.indigo,
alignment: Alignment.center,
child: const Text(
'Header banner',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
),
SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('— end of feed —')),
),
),
],
),
),
);
}
}
A header on top, a lazy list in the middle, a footer on the bottom — all in one scroll. The adapter is used for the header and footer; the list uses a real SliverList because it is many items.
Real-world use cases
- A hero header above a list.
- A “no results” placeholder above a list.
- A footer / “end of feed” widget under a list.
- A horizontal carousel (a
SizedBoxcontaining aListView(scrollDirection: Axis.horizontal)) wrapped in an adapter to live inside a vertical scroll. - An info card or banner stuck between two lists.
- A
Columnof static fields wrapped to live inside aCustomScrollView.
When to use it
- ✅ You have one non-sliver widget that needs to participate in a sliver scroll.
- ✅ The child is small enough that building it eagerly is fine (a header, a banner, a footer).
- ✅ You want to host a horizontal scroll inside a vertical scroll.
When NOT to use it
- ❌ The child is a long list — use
SliverList. Wrapping a list in an adapter destroys laziness. - ❌ The child is a long grid — use
SliverGrid. - ❌ You are using it many times in a row (5+) — that is a sign you should be using
SliverListwith a list of children, orSliverMainAxisGroup.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Hand-rolled Stack of fixed widgets and a list | The “fixed” widgets cover the list and don’t scroll with it. | CustomScrollView with SliverToBoxAdapters for the headers/footers. |
Putting a Container directly in slivers: and getting a runtime crash | Containers are boxes, not slivers. | SliverToBoxAdapter(child: Container(...)). |
Dos and don’ts
| Do | Don’t |
|---|---|
| Wrap exactly one widget. | Don’t wrap a list of items — use SliverList. |
| Use it for headers, footers, banners, and “single chunk of UI” content. | Don’t use it for repeated content. |
Combine with SliverPadding for margins around the wrapped box. | Don’t add padding inside the wrapped child if you already have a SliverPadding ancestor — you’ll double up. |
Common pitfalls
- You wrapped a
ListViewand now everything is slow. Don’t. The wrapped list builds eagerly. UseSliverListdirectly. - You wrapped a
Columnof 50 items. Same issue, less obvious. Convert to aSliverList. - Layout error: “BoxConstraints forces an infinite height”. The box you wrapped wants to be infinitely tall (it has no height). Give it an explicit height or wrap it in a
SizedBox.
Related widgets
SliverPadding— wrap a sliver to add padding around it.SliverList— for repeated content. Don’t use the adapter for that.SliverFillRemaining— for “fill the leftover space” content.
Official docs
SliverFillViewport
TL;DR: A list where each child fills the viewport (or a fraction of it). Useful for full-screen page-by-page scrolls inside a CustomScrollView.
What is it?
SliverFillViewport is a list whose every child is forced to be the size of the viewport, scaled by viewportFraction. With viewportFraction: 1.0 (default), each child is exactly one screen tall — you scroll one full screen per swipe. With viewportFraction: 0.8, each child is 80% of the viewport, and you can see a peek of the next item.
If PageView is what you reach for when the entire screen is the page, SliverFillViewport is what you reach for when you need that same behaviour next to other slivers.
Mental model
viewportFraction: 1.0 → one child = one screen, hard snap to page boundaries
viewportFraction: 0.8 → one child = 80% of screen, two children visible at once
viewportFraction: 0.5 → half-screen tiles, two children always visible
viewportFraction: 1.5 → child is bigger than the screen (rare)
Constructor & key parameters
const SliverFillViewport({
Key? key,
required SliverChildDelegate delegate,
double viewportFraction = 1.0,
bool padEnds = true,
})
| Parameter | What it does |
|---|---|
delegate | Same delegate as SliverList. Usually a SliverChildBuilderDelegate. |
viewportFraction | Each child takes this fraction of the viewport along the main axis. Must be > 0. |
padEnds | When viewportFraction < 1.0, adds padding at both ends so the first and last children can centre in the viewport when scrolled to the edges. Defaults true. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Onboarding'), pinned: true),
SliverFillViewport(
viewportFraction: 0.85,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
color: Colors.primaries[index % Colors.primaries.length].shade200,
borderRadius: BorderRadius.circular(24),
),
alignment: Alignment.center,
child: Text('Page $index', style: const TextStyle(fontSize: 32)),
);
},
childCount: 5,
),
),
],
),
),
);
}
}
You scroll vertically; each onboarding page takes 85% of the visible area, with the next one peeking from below. The SliverAppBar stays pinned on top.
Real-world use cases
- An onboarding flow where each step is a full-screen card, and the whole flow lives inside a scroll with a header.
- A feed of “stories” where each story takes most of the screen, with a header above and a “scroll for more” hint.
- A vertical carousel of full-bleed product images.
- A photo viewer with a top toolbar.
When to use it
- ✅ You want page-style scrolling inside a
CustomScrollView. - ✅ The “page size” should be the viewport, not a fixed pixel value.
- ✅ You want a peek of the next page (
viewportFraction < 1.0).
When NOT to use it
- ❌ The whole screen is the page — use
PageView. It is simpler and has its own snap physics. - ❌ You need horizontal swipe with vertical scroll inside each page — that is a different shape; nest a
PageViewinside the sliver tree instead. - ❌ You need fixed-pixel page sizes that don’t depend on the viewport — use
SliverFixedExtentList.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
PageView inside a SliverFillRemaining | Forces a fixed height for the PageView, fights with the outer scroll. | SliverFillViewport directly. |
Stack of full-screen Containers in a Column | No laziness, no peek, no reuse. | SliverFillViewport. |
Dos and don’ts
| Do | Don’t |
|---|---|
Use viewportFraction < 1.0 to expose a peek of the next page. | Don’t combine viewportFraction: 1.0 with padEnds: true — the padding has no effect. |
Combine with snap physics (custom ScrollPhysics) if you want hard page snaps. | Don’t use snap physics with viewportFraction: 0.5 — the result feels random. |
Common pitfalls
- Pages aren’t centred at edges. You set
padEnds: falseandviewportFraction < 1.0. SetpadEnds: trueto centre the first/last page. - Unwanted gap above the first page. That gap is the
padEndspadding. SetpadEnds: false, or add a sliver above theSliverFillViewport(the padding only kicks in when theSliverFillViewportis the only thing in the scroll). - The pages don’t snap.
SliverFillViewporthas no snapping by default. The hostCustomScrollView’s physics is what scrolls; pass a snap physics if you want snapping.
Related widgets
PageView— full-screen pages without slivers.SliverFillRemaining— single child that fills the leftover space.SliverFixedExtentList— uniform sized children, but in pixels.
Official docs
SliverFillRemaining
TL;DR: A sliver with a single child that fills whatever vertical space is left over in the viewport after the previous slivers have been laid out. Most often used for “no results” placeholders, footers, and the body of a NestedScrollView.
What is it?
SliverFillRemaining is the sliver that says “I take whatever space is left after the slivers above me”. If you have a header and a short list, then a SliverFillRemaining after them, the empty area below the list will be filled by the SliverFillRemaining’s child.
It has three modes, controlled by two flags:
hasScrollBody | fillOverscroll | Behaviour |
|---|---|---|
true (default) | (ignored) | The child is treated as a scrollable. It can be larger than the remaining space; the rest scrolls. This is what NestedScrollView uses internally. |
false | false (default) | The child fills the remaining space and stops there. If the child wants more, it gets clipped. |
false | true | The child stretches to fill overscroll on iOS-style bouncing physics. |
Mental model
viewport: 800px tall
- Slivers above use 300px of scroll
- Remaining = 800 - 300 = 500px
- SliverFillRemaining gets a 500px-tall slot for its child
If the slivers above already exceed the viewport (because the user scrolled), SliverFillRemaining gets 0 extra and contributes nothing visible.
Constructor & key parameters
const SliverFillRemaining({
Key? key,
Widget? child,
bool hasScrollBody = true,
bool fillOverscroll = false,
})
| Parameter | What it does |
|---|---|
child | The single widget to fill the remaining space. |
hasScrollBody | If true, treats the child as another scroll view. If false, treats it as a fixed-size box that fills the leftover area. The default is true. |
fillOverscroll | When hasScrollBody: false, allows the child to stretch into the overscroll area on bouncing physics. |
Minimal example: empty-state placeholder
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final List<String> items = <String>[]; // empty
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[
const SliverAppBar(title: Text('Inbox'), pinned: true),
if (items.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const <Widget>[
Icon(Icons.inbox, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('Your inbox is empty', style: TextStyle(fontSize: 18)),
],
),
),
)
else
SliverList.builder(
itemCount: items.length,
itemBuilder: (_, i) => ListTile(title: Text(items[i])),
),
],
),
),
);
}
}
When the inbox is empty, the placeholder centres in the visible space below the app bar — even though the app bar uses very little of the screen.
Real-world use cases
- Empty states — “no messages”, “no results”, etc.
- Loading states — a
CircularProgressIndicatorthat occupies the visible area below the header. - Footers that should always be at the bottom of the visible area when content is short.
NestedScrollViewbody — the entirebody:of aNestedScrollViewis logically aSliverFillRemaining(hasScrollBody: true).
When to use it
- ✅ You want one widget to fill whatever space is left in the viewport.
- ✅ The remaining space might be all of it (empty list) or none of it (long list).
- ✅ You want an empty state that centres in the visible area regardless of header height.
When NOT to use it
- ❌ You want to fill the entire viewport regardless of other slivers — you can’t. The “remaining” is computed after other slivers.
- ❌ You want a footer that sticks at the bottom even when the list is long — that’s a different shape; use a
Stackoutside the scroll view, or aScaffold.bottomNavigationBar. - ❌ The child wants to scroll independently of the outer scroll — it cannot. The child shares the outer scroll position.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Stack with a Positioned empty state on top of an empty list | The empty state hard-codes its position. | SliverFillRemaining(hasScrollBody: false, child: ...). |
Computing MediaQuery.size.height - appBarHeight - statusBarHeight for a placeholder | Fragile, breaks on rotation, ignores other slivers. | SliverFillRemaining. The framework does the math. |
Dos and don’ts
| Do | Don’t |
|---|---|
Set hasScrollBody: false for empty/loading states. | Don’t leave hasScrollBody: true (the default!) for static content — your child will stretch beyond the viewport and you won’t notice until the layout looks weird. |
Use Center and Column(mainAxisSize: MainAxisSize.min) inside the child for centred placeholders. | Don’t put scrollable content inside hasScrollBody: false — it cannot scroll independently. |
Use fillOverscroll: true only on iOS-style scrolls where you want a coloured background to follow the bounce. | Don’t combine fillOverscroll: true with hasScrollBody: true — the flag is ignored. |
Common pitfalls
- The child gets clipped. You used
hasScrollBody: falseand the child wants more space than is left. Either accept the clipping, simplify the child, or switch tohasScrollBody: true. - The empty state is at the top, not centred. You forgot
Centerinside the child.SliverFillRemainingdoes not centre — it just gives a slot. - The placeholder is invisible when the user scrolls down. Of course — the remaining space is now zero.
SliverFillRemainingis for remaining space, not for “always visible”. hasScrollBody: trueand the child disappears. With this flag, the child can scroll, and if it has nothing in it, it occupies zero scroll. Usefalsefor static content.
Related widgets
SliverFillViewport— many children, each filling the viewport.SliverToBoxAdapter— a single box without “fill remaining” semantics.SliverFixedExtentList— for fixed-height tiles.
Official docs
TreeSliver
TL;DR: A sliver that displays a hierarchical tree of expandable/collapsible nodes. The lazy, sliver-friendly version of “expandable list” widgets.
Note: the class is
TreeSliver, notSliverTree. It is the only sliver in the book whose name doesn’t start withSliver.
What is it?
TreeSliver<T> is a sliver that lays out a List<TreeSliverNode<T>> as an indented, expandable hierarchy. Each node carries a value of type T and an optional list of child nodes. The sliver handles indentation, expand/collapse animations, and lazy building.
It was added in Flutter 3.13 and is the official answer to “I want a tree view inside a CustomScrollView”.
Mental model
You build a tree of TreeSliverNode objects:
Animals
├── Mammals
│ ├── Dogs
│ └── Cats
└── Birds
├── Sparrow
└── Eagle
Each node knows its content (the T), its children, and whether it is expanded. The sliver flattens the visible (expanded) part of the tree into a list and displays it. Collapsing a node removes its children from the visible list with an animation.
Constructor & key parameters
const TreeSliver({
Key? key,
required List<TreeSliverNode<T>> tree,
TreeSliverNodeBuilder treeNodeBuilder = TreeSliver.defaultTreeNodeBuilder,
TreeSliverRowExtentBuilder treeRowExtentBuilder = TreeSliver.defaultTreeRowExtentBuilder,
TreeSliverController? controller,
TreeSliverNodeCallback? onNodeToggle,
AnimationStyle? toggleAnimationStyle,
TreeSliverIndentationType indentation = TreeSliverIndentationType.standard,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
int? Function(Key)? findChildIndexCallback,
// ...
})
| Parameter | What it does |
|---|---|
tree | The root list of nodes. Each node has its own children and isExpanded state. |
treeNodeBuilder | Builds the row for one node. The default builder shows an arrow + text. |
treeRowExtentBuilder | Returns the height of one row. Default is 40. |
controller | Optional TreeSliverController to expand/collapse nodes from outside. |
onNodeToggle | Called when the user expands or collapses a node. |
toggleAnimationStyle | The animation curve and duration for expand/collapse. Default is 150ms linear. |
indentation | How much each depth level is indented in the cross axis. standard, none, or custom. |
There are three classes in the TreeSliver family:
TreeSliver<T>— the sliver itself.TreeSliverNode<T>— one node in the tree, withcontent,children, andisExpanded.TreeSliverController— optional controller for programmatic expand/collapse from outside.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final List<TreeSliverNode<String>> tree = <TreeSliverNode<String>>[
TreeSliverNode<String>(
'Animals',
expanded: true,
children: <TreeSliverNode<String>>[
TreeSliverNode<String>(
'Mammals',
expanded: true,
children: <TreeSliverNode<String>>[
TreeSliverNode<String>('Dogs'),
TreeSliverNode<String>('Cats'),
],
),
TreeSliverNode<String>(
'Birds',
children: <TreeSliverNode<String>>[
TreeSliverNode<String>('Sparrow'),
TreeSliverNode<String>('Eagle'),
],
),
],
),
TreeSliverNode<String>('Plants'),
];
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('TreeSliver demo'), pinned: true),
TreeSliver<String>(
tree: tree,
onNodeToggle: (TreeSliverNode<dynamic> node) {
debugPrint('Toggled: ${node.content}');
},
),
],
),
),
);
}
}
You’ll see “Animals” expanded, “Mammals” expanded inside it, leaf nodes “Dogs” and “Cats”, and “Birds” + “Plants” collapsed by default. Tap the arrow to expand/collapse.
Real-world use cases
- File browsers (folders and files).
- Comment threads with replies.
- Org charts.
- Settings categories with sub-categories.
- A chapter/section navigator for a book or course.
When to use it
- ✅ Your data is naturally hierarchical and the user should see the hierarchy.
- ✅ You want lazy building inside the sliver tree.
- ✅ You want expand/collapse animations for free.
When NOT to use it
- ❌ Your data is flat — use a
SliverList. - ❌ You only have one level of expansion (cards that open) —
ExpansionTileinside aSliverListis simpler. - ❌ The “tree” is dynamic and built from a graph (cycles allowed) —
TreeSliverassumes a real tree. - ❌ You need drag-and-drop reordering across branches —
TreeSliverdoes not support that.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Manually flattening the tree into a list and using SliverList | You write your own expand/collapse animations and indentation math. | TreeSliver does it. |
ExpansionTiles nested inside each other | Heavy, eager build of all branches, awkward to update. | TreeSliver is lazy and uses TreeSliverNode data. |
Dos and don’ts
| Do | Don’t |
|---|---|
Use TreeSliverController if you need to expand/collapse from outside (e.g. “expand all”). | Don’t mutate node.children directly without rebuilding the tree — the sliver may not notice. |
Provide a custom treeNodeBuilder for branded styling. | Don’t put expensive widgets inside each node — every visible node rebuilds on toggle. |
Use TreeSliverIndentationType.custom if the default indent looks wrong for your design. | Don’t hard-code indentation in your row builder; use the indentation argument. |
Common pitfalls
- Nodes don’t re-render when I change my data. The sliver uses object identity. Replace the affected node with a new one (or rebuild the whole
treelist) and pass a new instance. - Animation is too fast or too slow. Pass a
toggleAnimationStylewith a customduration. - Per-node state lost on collapse. Same as
SliverList— collapsed children are disposed. Lift state into your data model.
Related widgets
SliverList— flat version.ExpansionTile— single-level expand for one item, not a sliver.TreeSliverNode<T>— the data structure for a node.TreeSliverController— programmatic expand/collapse controller.
Official docs
- https://api.flutter.dev/flutter/widgets/TreeSliver-class.html
- https://api.flutter.dev/flutter/widgets/TreeSliverNode-class.html
- https://api.flutter.dev/flutter/widgets/TreeSliverController-class.html
Part 20
Headers & App Bars
Material and Cupertino app bars, pinned headers, resizing headers, and floating headers.
SliverAppBar
TL;DR: A Material Design app bar that lives inside a CustomScrollView and can collapse, pin, float, snap, and stretch as the user scrolls. It is the most-used sliver in the whole Flutter SDK.
What is it?
SliverAppBar is the sliver version of AppBar. It looks almost identical — title, leading, actions, background — but it participates in the scroll. You configure its behaviour with four independent flags:
pinned— stays glued to the top after collapsing. Your toolbar never fully scrolls away.floating— reappears as soon as the user scrolls down, even if the list is not at the top.snap— whenfloating: true, the reappear animation snaps open all the way instead of incrementally.stretch— when the user overscrolls past the top, the app bar stretches beyond itsexpandedHeightwith itsflexibleSpace(nice for parallax images).
These combine. The four most common combinations have names:
| Combination | Name | Behaviour |
|---|---|---|
pinned: false, floating: false | normal | Scrolls away with the rest; classic hero app bar. |
pinned: true | pinned | Toolbar sticks; the flexible space above it collapses. |
floating: true | floating | Disappears on scroll-up, reappears on any scroll-down. |
floating: true, snap: true | snap | Same as floating, but the reappear is one instant snap. |
Constructor & key parameters
SliverAppBar has three constructors — the default plus .medium and .large for Material 3 “scroll-under” titles.
The parameters you actually use:
| Parameter | What it does |
|---|---|
title | The title widget. Usually a Text. |
leading | Custom leading widget (defaults to back button or drawer icon). |
actions | Trailing icons. |
flexibleSpace | The content that lives in the “expanded” area above the toolbar. Usually a FlexibleSpaceBar with a background image. |
bottom | A PreferredSizeWidget below the toolbar, typically a TabBar. |
expandedHeight | Maximum height when fully expanded. If null, the bar has no expanded area. |
collapsedHeight | Minimum height when collapsed. Must be >= toolbarHeight. |
toolbarHeight | Height of the always-present toolbar row. Defaults to kToolbarHeight (56). |
pinned, floating, snap, stretch | The four behaviour flags above. |
stretchTriggerOffset | Overscroll distance before onStretchTrigger fires. Defaults 100. |
onStretchTrigger | Callback when the user pulls past stretchTriggerOffset. Use for pull-to-refresh-style gestures. |
backgroundColor, foregroundColor | Colors. Defaults come from the theme. |
elevation, scrolledUnderElevation | Shadow elevation when at rest and when scrolled under, respectively. |
forceElevated | Force the scrolled-under shadow even when there is nothing under the app bar. Useful inside NestedScrollView. |
centerTitle | Override theme for centring the title. |
systemOverlayStyle | Status bar icon colour (light vs dark). |
shape | Custom border shape — usually left as the default. |
primary | If true, leaves room for the status bar. Usually leave as default. |
Minimal example — the four modes in one screen
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
pinned: true,
stretch: true,
expandedHeight: 220,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Collapsing header'),
background: Image.network(
'https://picsum.photos/id/1018/800/400',
fit: BoxFit.cover,
),
),
actions: <Widget>[
IconButton(onPressed: () {}, icon: const Icon(Icons.search)),
],
),
SliverList.builder(
itemCount: 40,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
],
),
),
);
}
}
Scroll this and you get the classic “parallax header that collapses and pins” screen. Overscroll past the top and the image stretches beyond its expanded height — that is the stretch: true flag in action.
Real-world use cases
- Profile / product / album header with a hero image.
- Tabbed screens (with a
TabBarinbottom). - Newsfeed headers with brand colors that collapse to a plain toolbar.
- “Hide on scroll” toolbars on content-heavy screens (
floating: true, snap: true). - Pull-to-refresh-like gestures with
stretch: trueandonStretchTrigger.
When to use it
- ✅ You want a Material Design app bar that reacts to scroll.
- ✅ You want pinning, floating, or collapsing behaviour.
- ✅ You are inside a
CustomScrollVieworNestedScrollView.
When NOT to use it
- ❌ You want a fixed, non-scrolling app bar — use
Scaffold.appBarwith a plainAppBar. - ❌ You want iOS style — use
CupertinoSliverNavigationBar. - ❌ You want a completely custom collapsing header with non-toolbar content — use
SliverPersistentHeaderwith a custom delegate. - ❌ You want a section header that pins inline in a long list — use
SliverPersistentHeaderorPinnedHeaderSliver.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Scaffold(appBar: AppBar(...)) + manual scroll-to-collapse code | Impossible without slivers. | SliverAppBar with pinned: true, expandedHeight: .... |
Stack of AppBar over a ListView | No real collapse, no parallax. | SliverAppBar inside CustomScrollView. |
Dos and don’ts
| Do | Don’t |
|---|---|
Use pinned: true when the toolbar should never disappear. | Don’t enable snap without floating — the assertion will fail. |
Use floating: true, snap: true for “hide on scroll up, snap in on scroll down”. | Don’t set expandedHeight smaller than toolbarHeight. It is an error. |
Wrap in SliverOverlapAbsorber when inside NestedScrollView with a pinned bar. | Don’t forget forceElevated: innerBoxIsScrolled inside NestedScrollView — without it the shadow doesn’t appear. |
Use FlexibleSpaceBar for the expanded area — it handles the title fade for you. | Don’t try to hand-animate the title — FlexibleSpaceBar already does it. |
Common pitfalls
- Setting
titledoesn’t place it where I want. ThetitleinSliverAppBaralways lives in the static toolbar row. To show a title inside the flexible area, useFlexibleSpaceBar(title: Text(...)). - Pinned bar doesn’t pin. You forgot
pinned: true— it defaults tofalse. snap: trueassertion error.snaprequiresfloating: true. Set both.- The bar doesn’t collapse. You didn’t set
expandedHeight. With no expanded height, the bar is just the toolbar — there’s nothing to collapse. - Hero image stretches weirdly. Use
FlexibleSpaceBarwithbackground:andcollapseMode: CollapseMode.parallax(default) for parallax feel. - Shadow appears/disappears wrong. Tweak
scrolledUnderElevationor useforceElevatedinside aNestedScrollView.
Related widgets
AppBar— box version for non-scrolling scaffolds.FlexibleSpaceBar— the content of the expanded area.CupertinoSliverNavigationBar— iOS equivalent.SliverPersistentHeader— lower-level custom headers.SliverOverlapAbsorber— pair with it insideNestedScrollView.
Official docs
CupertinoSliverNavigationBar
TL;DR: The iOS-11-style navigation bar with a large title that collapses to a small inline title as you scroll. The Cupertino counterpart to SliverAppBar.
What is it?
CupertinoSliverNavigationBar reproduces the classic iOS “large title that shrinks” behaviour. When the list is at the top, the bar shows a large bold title below a thin top bar. As the user scrolls up, the large title smoothly shrinks into the small centred title in the top bar, matching native iOS behaviour in Settings, Messages, and Mail.
It has two constructors:
- Default — the classic large-title bar.
.search— adds asearchFieldbelow the title; tapping it slides the bar into a search mode with a Cancel button.
Mental model
[fully expanded] [fully collapsed]
┌────────────────────┐ ┌────────────────────┐
│ ← Back Trailing │ │ ← Back middle Tr │
│ │ └────────────────────┘
│ Large Title │
│ │
└────────────────────┘
The transition is automatic. You don’t write any animation code.
Constructor & key parameters
const CupertinoSliverNavigationBar({
Key? key,
Widget? largeTitle,
Widget? leading,
bool automaticallyImplyLeading = true,
bool automaticallyImplyTitle = true,
bool alwaysShowMiddle = true,
String? previousPageTitle,
Widget? middle,
Widget? trailing,
Border border = _kDefaultNavBarBorder,
Color? backgroundColor,
bool automaticBackgroundVisibility = true,
bool enableBackgroundFilterBlur = true,
Brightness? brightness,
EdgeInsetsDirectional? padding,
bool transitionBetweenRoutes = true,
Object heroTag = _defaultHeroTag,
bool stretch = false,
Widget? bottom,
NavigationBarBottomMode? bottomMode,
})
| Parameter | What it does |
|---|---|
largeTitle | The big title below the toolbar. Required unless automaticallyImplyTitle is true and the route has a title. |
middle | Small title in the always-visible top row. Optional. |
leading | Usually the back button (provided automatically) or a custom widget. |
trailing | Right-side widget, often an action icon. |
previousPageTitle | Text label next to the back chevron when automaticallyImplyLeading is true. |
alwaysShowMiddle | When true (default), middle is visible in both states. When false, only in collapsed state. |
stretch | Stretches the large title area on iOS-bouncing overscroll. |
backgroundColor | Bar background. Defaults to translucent from theme. |
enableBackgroundFilterBlur | Toggles iOS-style backdrop blur. Defaults true. |
automaticBackgroundVisibility | Fades the background in only when content is scrolled under the bar. |
bottom | Widget placed below the title (e.g. a segmented control). |
bottomMode | NavigationBarBottomMode.automatic or .always. |
transitionBetweenRoutes, heroTag | Enables the native “bar slides over” transition when pushing a Cupertino route. |
Minimal example
import 'package:flutter/cupertino.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return CupertinoApp(
home: CupertinoPageScaffold(
child: CustomScrollView(
slivers: <Widget>[
const CupertinoSliverNavigationBar(
largeTitle: Text('Messages'),
trailing: Icon(CupertinoIcons.add_circled),
previousPageTitle: 'Back',
),
SliverList.builder(
itemCount: 40,
itemBuilder: (BuildContext context, int index) {
return CupertinoListTile(
title: Text('Contact $index'),
subtitle: const Text('Last message preview...'),
);
},
),
],
),
),
);
}
}
Note the host is CupertinoPageScaffold with a CustomScrollView, not Scaffold. The nav bar is a sliver inside the scroll view — unlike CupertinoPageScaffold.navigationBar, which is a fixed bar.
Real-world use cases
- Any iOS-first app — Mail, Messages, Notes, Settings-style screens.
- iOS tab bar screens where each tab has its own large title.
- Apps that want to feel native on iOS and use
AppBaron Android.
When to use it
- ✅ You want the iOS large-title behaviour.
- ✅ Your app uses Cupertino widgets.
- ✅ You want the built-in route transition animation on Cupertino routes.
When NOT to use it
- ❌ You want Material Design — use
SliverAppBar. - ❌ You want a non-scrolling nav bar — use
CupertinoNavigationBarinsideCupertinoPageScaffold.navigationBar. - ❌ You want a completely custom collapsing header — use
SliverPersistentHeader.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
CupertinoNavigationBar fixed in CupertinoPageScaffold.navigationBar + manual large title below | Large title doesn’t collapse smoothly; you reinvent the animation. | CupertinoSliverNavigationBar directly. |
SliverAppBar on iOS | Wrong visual style for iOS apps. | CupertinoSliverNavigationBar. |
Dos and don’ts
| Do | Don’t |
|---|---|
Set previousPageTitle when pushing routes manually. | Don’t set both middle and largeTitle and expect them to show at the same time unless you enable alwaysShowMiddle. |
Use .search when the screen should have an integrated search bar. | Don’t put a CupertinoSearchTextField separately above/below — use the .search constructor. |
| Keep your background colour translucent to match iOS convention. | Don’t disable enableBackgroundFilterBlur unless the design specifically demands it. |
Common pitfalls
- “No largeTitle has been provided” assertion. Either pass
largeTitle:or leaveautomaticallyImplyTitle: trueand make the route aCupertinoPageRoutewith atitle. - The large title doesn’t shrink. You put the bar inside a
ScaffoldorCupertinoPageScaffold.navigationBarinstead of inside aCustomScrollView. It must be a sliver inside the scroll view to react to scroll. - Route transition is broken. You set different
heroTags for the bar on the previous and next pages. The default tag is the same across pages. - Search bar doesn’t animate. You used the default constructor and tried to wire search yourself. Use
.searchfor the integrated behaviour.
Related widgets
CupertinoNavigationBar— non-sliver, fixed version.CupertinoPageScaffold— the Cupertino equivalent ofScaffold.SliverAppBar— Material equivalent.SliverPersistentHeader— lower-level custom headers.
Official docs
SliverPersistentHeader
TL;DR: The lowest-level “collapsing / pinning / floating header” sliver. You hand it a SliverPersistentHeaderDelegate that describes a minimum size, a maximum size, and a builder — and the sliver handles the scroll interaction.
It is what SliverAppBar uses internally. Use it directly when you want a custom-shaped header that SliverAppBar cannot express.
What is it?
SliverPersistentHeader is a generic collapsing/pinning/floating header. Three things make it work:
delegate— aSliverPersistentHeaderDelegatethat definesminExtent,maxExtent,build, andshouldRebuild.pinned— once collapsed, stay glued to the top.floating— reappear as soon as the user scrolls down.
The delegate’s build method receives a shrinkOffset parameter: how many pixels of the header’s max extent have been “eaten” by scrolling. You use it to interpolate colors, sizes, opacity, whatever you want. This is how you build a custom collapsing effect that SliverAppBar does not have a flag for.
Mental model
shrinkOffset = 0 → header at max size (fully expanded)
shrinkOffset = maxExtent - minExtent → header at min size (fully collapsed)
You lerp visual properties from 0 to 1:
final t = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0);
final color = Color.lerp(Colors.transparent, Colors.indigo, t);
t == 0 is the expanded look; t == 1 is the collapsed look.
Constructor & key parameters
const SliverPersistentHeader({
Key? key,
required SliverPersistentHeaderDelegate delegate,
bool pinned = false,
bool floating = false,
})
| Parameter | What it does |
|---|---|
delegate | The SliverPersistentHeaderDelegate that describes sizing and content. |
pinned | Stick to the top once collapsed. |
floating | Reappear on reverse scroll. |
Behaviour combinations:
pinned: false, floating: false— scrolls away like normal content.pinned: true— sticks.floating: true— reappears on reverse scroll.pinned: true, floating: true— sticks after collapse; reappears to full size on reverse scroll.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: _ColorHeaderDelegate(
minExtent: 72,
maxExtent: 220,
title: 'My Header',
),
),
SliverList.builder(
itemCount: 50,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
],
),
),
);
}
}
class _ColorHeaderDelegate extends SliverPersistentHeaderDelegate {
_ColorHeaderDelegate({
required this.minExtent,
required this.maxExtent,
required this.title,
});
@override
final double minExtent;
@override
final double maxExtent;
final String title;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final double t = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0);
return Container(
color: Color.lerp(Colors.indigo, Colors.deepPurple.shade900, t),
alignment: Alignment.lerp(Alignment.bottomLeft, Alignment.centerLeft, t),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: 32 - 14 * t, // 32 expanded → 18 collapsed
fontWeight: FontWeight.w600,
),
),
);
}
@override
bool shouldRebuild(covariant _ColorHeaderDelegate oldDelegate) {
return oldDelegate.title != title ||
oldDelegate.minExtent != minExtent ||
oldDelegate.maxExtent != maxExtent;
}
}
Scroll the list: the header smoothly darkens, the title shrinks and shifts to centre, and the bar pins at 72 pixels.
Real-world use cases
- A custom-shaped collapsing hero with rounded bottom edges that straighten as it pins.
- A sticky section header (
pinned: true, minExtent == maxExtent) that acts like a label for the list section below it. - A gradient header that crossfades between two palettes as it collapses.
- A header with a search bar that collapses its other content but keeps the search input visible.
When to use it
- ✅ You want collapsing/pinning behaviour but
SliverAppBar’s parameters don’t express your design. - ✅ You want a custom interpolation during collapse (colors, sizes, opacity, positioning).
- ✅ You want a sticky section header inside a list.
When NOT to use it
- ❌ You want a normal Material app bar — use
SliverAppBar. It is higher level. - ❌ Your header needs to resize between two intrinsic sizes — use
SliverResizingHeader. - ❌ You want a non-shrinking pinned child — use
PinnedHeaderSliver.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
SliverAppBar with fake flexibleSpace content because you can’t fit your custom design into the Material shape | Fighting FlexibleSpaceBar conventions. | SliverPersistentHeader with a custom delegate. |
Stack + AnimatedBuilder watching a ScrollController | You reinvent what the delegate does. | SliverPersistentHeader; the shrinkOffset is the animation driver. |
Dos and don’ts
| Do | Don’t |
|---|---|
Override shouldRebuild — return true whenever the data the delegate depends on changes. | Don’t return false unconditionally. A stale header will never update. |
Keep minExtent <= maxExtent. | Don’t use different minExtent/maxExtent if you want a strictly pinned (non-shrinking) header. Use PinnedHeaderSliver instead. |
Use shrinkOffset directly rather than computing scroll from outside. | Don’t read a ScrollController in the build method — the shrinkOffset is already correct. |
Common pitfalls
- Header doesn’t update when I change its data. You forgot
shouldRebuildor returnedfalsefrom it. Fix the comparison. - Header overlaps content. The delegate’s output is drawn at the top of the viewport; content below it begins at
paintOrigin + paintExtent. If your header draws outside its box, passhasVisualOverflowin your content (e.g. with aClipRect). - Pinned header flickers at the boundary.
minExtentis too small — the build method runs during every pixel of scroll near the boundary. Increase the minimum. floating: truedoesn’t re-open the header all the way. You need thesnapConfigurationon the delegate for that. See theSliverPersistentHeaderDelegatepage.- Content under the header is hidden by overlap inside
NestedScrollView. Wrap the sliver inSliverOverlapAbsorberand add aSliverOverlapInjectoron the inner side.
Related widgets
SliverAppBar— Material app bar built on top ofSliverPersistentHeader.SliverPersistentHeaderDelegate— the contract.SliverResizingHeader— “resize between two intrinsic sizes” shortcut.PinnedHeaderSliver— “pin a fixed-size widget”.SliverFloatingHeader— “reappear on scroll-down”.
Official docs
PinnedHeaderSliver
TL;DR: Pins a single child widget at the top of a CustomScrollView, without collapsing it. The simplest “sticky header” sliver — no delegate, no min/max extent, no flags.
What is it?
PinnedHeaderSliver is a newer (Flutter 3.13+), much simpler alternative to SliverPersistentHeader(pinned: true). You give it a single box child and it behaves like this:
- The child participates in the scroll until its top edge reaches the top of the viewport.
- From that point on, it stays glued to the top.
- It does not shrink or collapse. Its size is whatever its child’s intrinsic size is.
That’s it. No delegate. No minExtent / maxExtent. No shouldRebuild. Just a widget.
Use this when you want “pinned to top” but don’t want to write a delegate.
Mental model
Before scroll: After scroll (pinned):
┌──────────────┐ ┌──────────────┐
│ other sliver │ │ PinnedHeader │ ← stuck to top
├──────────────┤ ├──────────────┤
│ PinnedHeader │ │ other sliver │ (scrolled under)
├──────────────┤ │ │
│ list items │ │ list items │
│ ... │ │ ... │
The child’s size is never changed. It does not react to shrinkOffset. If you need it to shrink as it pins, use SliverPersistentHeader.
Constructor & key parameters
const PinnedHeaderSliver({Key? key, Widget? child})
One parameter. That is the entire API.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('PinnedHeaderSliver')),
SliverToBoxAdapter(
child: Container(
height: 200,
color: Colors.indigo.shade100,
alignment: Alignment.center,
child: const Text('Hero banner'),
),
),
PinnedHeaderSliver(
child: Container(
height: 48,
color: Colors.amber,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Text('Sticky section label'),
),
),
SliverList.builder(
itemCount: 50,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
],
),
),
);
}
}
Scroll down: the hero banner scrolls away with the rest, but as soon as the amber “Sticky section label” reaches the SliverAppBar, it stops and pins. It does not shrink.
Real-world use cases
- Sticky section headers in settings-style pages (“General”, “Privacy”, “About”).
- A sticky filter bar above search results.
- A sticky date label in a chronological feed (“Today”, “Yesterday”, “Last week”).
- Any “section title that should not disappear while I’m reading its section”.
When to use it
- ✅ You want a pinned header without writing a delegate.
- ✅ The header size is fixed and should not change on collapse.
- ✅ You are using Flutter 3.13 or later.
When NOT to use it
- ❌ The header should collapse/shrink as it pins — use
SliverPersistentHeaderorSliverResizingHeader. - ❌ You need
shouldRebuildcontrol — useSliverPersistentHeaderwith a delegate. - ❌ You need iOS-style floating behaviour — use
SliverFloatingHeader. - ❌ You are on an older Flutter version (< 3.13) — fall back to
SliverPersistentHeader.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
SliverPersistentHeader(pinned: true, delegate: _SimpleDelegate(child)) where the delegate does nothing interesting | You write 20 lines of boilerplate for a 1-line feature. | PinnedHeaderSliver(child: ...). |
Hand-rolled “sticky section” using a Stack and scroll math | Fragile, does not interact correctly with other slivers. | PinnedHeaderSliver. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Use it when the header is a simple fixed-size widget that should pin. | Don’t put scrollable content inside it — it does not scroll. |
Combine multiple PinnedHeaderSlivers in a single scroll for “nested” section headers if needed. | Don’t expect it to shrink on scroll — it will not. |
Common pitfalls
- Class not found. You are on Flutter < 3.13. Either upgrade or switch to
SliverPersistentHeader. - Header pins on top of an existing pinned
SliverAppBar. Two pinned slivers stack up — both are pinned at the top, in order. Make sure that’s what you want. - Header “jitters” during scroll. Your child rebuilds expensively on every frame. Cache it with a
constconstructor or wrap in aRepaintBoundary.
Related widgets
SliverPersistentHeader— use this when the header needs to collapse.SliverResizingHeader— “resize between two intrinsic sizes”.SliverFloatingHeader— “hide on scroll up, show on scroll down”.SliverMainAxisGroup— useful when you want each group in a long scroll to have its own pinned header.
Official docs
SliverResizingHeader
TL;DR: A pinned header that resizes between the intrinsic sizes of a minExtentPrototype and a maxExtentPrototype. No delegate, no pixel values — you just hand it two prototype widgets and one real child.
What is it?
SliverResizingHeader (Flutter 3.13+) is the “I want a pinned collapsing header, but I don’t want to hard-code min and max pixel values” sliver. You pass:
minExtentPrototype— a widget whose intrinsic size defines the collapsed size of the header.maxExtentPrototype— a widget whose intrinsic size defines the expanded size of the header.child— the widget that is actually shown, which will be clamped between those two sizes as the user scrolls.
The prototypes are never drawn. They exist only to be measured.
This is the cleanest way to build a header whose min and max sizes come from real widgets (so they respect theme, padding, and text scale) without writing a SliverPersistentHeaderDelegate.
Mental model
max size ← intrinsic size of maxExtentPrototype
↓ collapses as user scrolls
min size ← intrinsic size of minExtentPrototype
↓ stays pinned at top
The real child widget is drawn, and its size is forced to whatever the current collapse state says it should be. The child is responsible for looking reasonable at both extremes.
Constructor & key parameters
const SliverResizingHeader({
Key? key,
Widget? minExtentPrototype,
Widget? maxExtentPrototype,
Widget? child,
})
| Parameter | What it does |
|---|---|
minExtentPrototype | Widget whose intrinsic main-axis size is the collapsed size. If null, the minimum is 0. |
maxExtentPrototype | Widget whose intrinsic main-axis size is the expanded size. If null, the maximum is whatever child reports. |
child | The widget that is actually drawn; forced to the current resized size. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverResizingHeader(
minExtentPrototype: const SizedBox(height: 56),
maxExtentPrototype: const SizedBox(height: 200),
child: Container(
color: Colors.teal,
alignment: Alignment.center,
child: const Text(
'Resizing header',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
),
SliverList.builder(
itemCount: 50,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
],
),
),
);
}
}
Scroll: the teal band starts at 200 pixels, shrinks smoothly to 56, and pins. You never typed “shrinkOffset” once.
For a more interesting example, let the prototypes be real widgets whose heights come from the theme:
SliverResizingHeader(
minExtentPrototype: const ListTile(title: Text('Collapsed title')),
maxExtentPrototype: Column(children: const [
ListTile(title: Text('Expanded title')),
ListTile(title: Text('Subtitle')),
ListTile(title: Text('Extra info')),
]),
child: ColoredBox(
color: Colors.deepPurple.shade100,
child: const Center(child: Text('Real content here')),
),
)
Now the min and max sizes track the user’s text scale settings automatically.
Real-world use cases
- Search bar that shrinks into a pinned toolbar as the user scrolls.
- Hero header with multiple lines of text that collapse to one line.
- Settings group header that starts as a rich block and collapses to a tiny label.
When to use it
- ✅ You want a resizing pinned header but prefer “here is what min and max look like” over “here are two numbers”.
- ✅ Min and max should follow theme / text scale.
- ✅ You are on Flutter 3.13 or later.
When NOT to use it
- ❌ You need a custom interpolation inside the header (color lerp, icon swap, etc.) — use
SliverPersistentHeaderso you can readshrinkOffset. - ❌ Your header doesn’t resize at all — use
PinnedHeaderSliver. - ❌ You want floating (reappear on scroll-down) — use
SliverFloatingHeaderorSliverPersistentHeader(floating: true).
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
SliverPersistentHeader with a delegate that does nothing but interpolate size | The delegate is pure boilerplate; you already know the two endpoints. | SliverResizingHeader with prototypes. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Use widgets close to the real thing as prototypes so the measured sizes match reality. | Don’t include animations, controllers, or focus nodes in prototypes — they are measured, not drawn. |
Use null prototypes for “no minimum” / “no maximum” when appropriate. | Don’t put scrollable content inside child — the size is forced by the sliver, it cannot scroll independently. |
Common pitfalls
- Child looks wrong at collapsed size. The
childis forced to the min size; if it can’t lay out at that size, it clips or overflows. Design the child to be flexible, or compose it withAnimatedBuilderif the visual needs to change drastically. - Prototypes show up on screen. They shouldn’t — they are laid out offstage. If they appear, you wrapped them in a different widget by mistake.
- The header doesn’t pin.
SliverResizingHeaderis always pinned. If it seems to scroll away, the issue is elsewhere (the scroll view, or a wrapping sliver).
Related widgets
SliverPersistentHeader— more control via a delegate.PinnedHeaderSliver— fixed-size pin, no resize.SliverFloatingHeader— “reappear on scroll-down”.SliverAppBar— Material-styled equivalent with a lot more features.
Official docs
SliverFloatingHeader
TL;DR: A header that animates into view when the user scrolls forward (toward the top of the content) and disappears when the user scrolls backward. No delegate, no pinned flag — just a single child and the float behaviour you get from SliverAppBar(floating: true, snap: true), available as a standalone sliver.
What is it?
SliverFloatingHeader (Flutter 3.13+) is the “hide on scroll-up, snap back on scroll-down” sliver, without the Material app bar baggage. You give it a single child and it does the scroll-direction-aware show/hide animation for you.
It is the lightweight answer to “I just want the SliverAppBar(floating: true, snap: true) behaviour around my own custom widget”.
Mental model
- User scrolls up (content goes up, page goes to further content): the header slides out of view.
- User scrolls down (content goes down, page goes to earlier content): the header slides back in.
- The animation runs when the scroll gesture ends, snapping the header into a fully-visible or fully-hidden position.
The snapMode parameter controls whether the animation overlays the content (default) or pushes the content down.
Constructor & key parameters
const SliverFloatingHeader({
Key? key,
AnimationStyle? animationStyle,
FloatingHeaderSnapMode? snapMode,
required Widget child,
})
| Parameter | What it does |
|---|---|
child | The widget to show/hide. Required. |
animationStyle | Overrides the default animation duration (300 ms) and curve (Curves.easeInOut). |
snapMode | FloatingHeaderSnapMode.overlay (default) or .scroll. In overlay mode the header floats on top of the content as it animates in; in scroll mode it pushes the content down. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverFloatingHeader(
child: Container(
height: 56,
color: Colors.indigo,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Text(
'Filter bar',
style: TextStyle(color: Colors.white, fontSize: 18),
),
),
),
SliverList.builder(
itemCount: 60,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
],
),
),
);
}
}
Scroll up to hide the indigo filter bar; scroll down (even slightly) and it snaps back into view.
Real-world use cases
- A filter bar above search results that hides while reading and snaps in when the user wants to refine.
- A “compose” or “new post” action bar that hides when the user is browsing and returns when they scroll back.
- A breadcrumb / tab bar that should be visible when navigating but out of the way when reading.
When to use it
- ✅ You want “hide on scroll, show on reverse scroll” without building a custom
SliverAppBarwrapper. - ✅ The header is a single widget that never needs to collapse or resize — it is either fully shown or fully hidden.
- ✅ You are on Flutter 3.13 or later.
When NOT to use it
- ❌ You want the header to be always visible — use
PinnedHeaderSliver. - ❌ You want the header to collapse/resize — use
SliverResizingHeaderorSliverPersistentHeader. - ❌ You want Material app bar styling — use
SliverAppBar(floating: true, snap: true). - ❌ You want the header to be partially visible — it will snap to fully hidden or fully shown.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Hand-rolled visibility toggle tied to a ScrollController listener | Jumpy, doesn’t feel native, hard to get the snap right. | SliverFloatingHeader. |
SliverAppBar(floating: true, snap: true) wrapping a non-toolbar widget | You inherit a Material app bar shape you don’t need. | SliverFloatingHeader with any child. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Use it for non-toolbar floating content — filter bars, breadcrumbs, action rows. | Don’t put collapsing content inside — it will not shrink, only slide. |
Pass an AnimationStyle to match your app’s motion vocabulary. | Don’t set a very slow animation — the user expects this to feel instant. |
| Combine with other pinned/normal slivers freely. | Don’t wrap it in another floating wrapper — you’ll get double animations. |
Common pitfalls
- Header stays put and doesn’t hide. You are in a scroll view that has
NeverScrollableScrollPhysicsor no scroll extent. There must be something to scroll for the direction-aware behaviour to activate. - Animation feels choppy. The child is expensive to rebuild. Wrap it in
constorRepaintBoundary. - Header flickers on small scroll gestures. That is the snap threshold. Use
animationStylewith a slightly slower curve. - Header shows up at the wrong position in
NestedScrollView.SliverFloatingHeaderinside a tab body needs the overlap absorber/injector pair, same as any sliver there.
Related widgets
SliverAppBar— has the same behaviour withfloating: true, snap: true, plus Material styling.PinnedHeaderSliver— for “always visible, never hidden”.SliverResizingHeader— for “resize instead of hide”.SliverPersistentHeader— for custom floating behaviour via a delegate.
Official docs
Part 30
Layout, Spacing & Safety
Padding, safe areas, and cross-axis constraints for slivers.
SliverPadding
TL;DR: Adds space around another sliver — the sliver version of Padding. Takes a sliver: child (not a child: child).
What is it?
SliverPadding wraps one sliver and adds empty space on its four sides. It is how you get margins around a SliverList, a SliverGrid, or a SliverAppBar’s flexible space without wrapping everything in a SliverToBoxAdapter + Padding.
Under the hood it is a one-field widget: padding. But the important detail is that its child parameter is named sliver:, not child:. If you see a sliver constructor with sliver:, that means the inside slot must itself be a sliver — no boxes allowed.
Mental model
SliverPadding(padding: EdgeInsets.all(16), sliver: mySliver)
→ mySliver is drawn inside a 16-pixel margin on all sides
→ the top-left 16 pixels are empty,
→ the main-axis extent is mySliver's extent + 16 (top) + 16 (bottom)
→ the cross-axis extent is shrunk by 16 on left + right
Constructor & key parameters
const SliverPadding({
Key? key,
required EdgeInsetsGeometry padding,
Widget? sliver,
})
| Parameter | What it does |
|---|---|
padding | The space to add around the inner sliver. Any EdgeInsetsGeometry. |
sliver | The inner sliver. Despite being typed Widget?, it must be a sliver at runtime. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverPadding')),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
sliver: SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => Card(
child: ListTile(title: Text('Row $i')),
),
),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: 15,
itemBuilder: (_, i) => Container(
color: Colors.teal.shade200,
alignment: Alignment.center,
child: Text('$i'),
),
),
),
],
),
),
);
}
}
The list has 16 px of horizontal margin and 8 px between it and its neighbours. The grid has 16 px of margin on all four sides. No SliverToBoxAdapter in sight.
Real-world use cases
- Adding screen-edge margin to a list or grid.
- Adding breathing room between two adjacent slivers (use symmetric vertical padding).
- Applying a safe-area-like inset manually.
- Wrapping a sliver with responsive padding from
MediaQuery.
When to use it
- ✅ You want margin or padding around a sliver without wrapping it in a box.
- ✅ You want different padding on top/bottom vs left/right.
- ✅ You want padding that scrolls with the content (the padding lives in the sliver tree, so it moves with the scroll).
When NOT to use it
- ❌ You want to avoid system UI (status bar, notch) — use
SliverSafeAreainstead; it readsMediaQuery.paddingfor you. - ❌ You want spacing between list items — set
mainAxisSpacingon a grid or useSliverList.separated. - ❌ You want to pad a box widget — use regular
Paddinginside aSliverToBoxAdapter.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Wrapping each list item in Padding for outer margins | Duplicates the padding into every item; inconsistent if items come from different builders. | SliverPadding once around the list. |
Putting the list inside SliverToBoxAdapter(child: Padding(...)) | Defeats laziness (the box adapter is not lazy). | SliverPadding with the real sliver inside. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Use for the outer margin of a list/grid. | Don’t nest SliverPadding inside SliverPadding — merge the paddings into one. |
| Use for consistent spacing between slivers (vertical padding on the outer slivers stacks up). | Don’t use it to separate individual items — use mainAxisSpacing or SliverList.separated. |
Common pitfalls
- “The argument type ‘Container’ can’t be assigned to ‘Widget’”. That is what you see when you pass a box to the
sliver:parameter. Wrap the box inSliverToBoxAdapter. - Padding visually looks wrong near the screen edge. Your
SliverPaddingis inside another widget that also adds padding. Remove one. - “SliverPadding got a box child”. Same as above — the
sliver:argument must be a sliver.
Related widgets
SliverSafeArea— likeSliverPaddingbut reads system insets.Padding— the box version.SliverToBoxAdapter— for mixing padded boxes with other slivers.EdgeInsetsGeometry,EdgeInsets,EdgeInsetsDirectional— the padding value types.
Official docs
SliverSafeArea
TL;DR: The sliver version of SafeArea. Wraps another sliver and adds padding equal to the system insets (status bar, notch, home indicator), so your content doesn’t collide with hardware UI.
What is it?
SliverSafeArea reads MediaQuery.padding — the space reserved for status bars, notches, punch holes, home indicators, rounded display corners — and adds that padding around its sliver child. If you want your list to not be hidden behind the notch on the left in landscape, or not tucked under the home indicator at the bottom, this is the sliver.
It is implemented as a SliverPadding + a MediaQuery.removePadding so that its descendants do not add the same padding a second time.
Mental model
MediaQuery.padding = EdgeInsets(left: 0, top: 44, right: 0, bottom: 34) // iPhone with notch
SliverSafeArea(
top: true,
bottom: true,
sliver: myList,
)
→ myList gets EdgeInsets.only(top: 44, bottom: 34) added around it
You can disable individual sides with left: false, top: false, right: false, bottom: false — useful when an earlier sliver (like a SliverAppBar) already consumes one of the edges.
Constructor & key parameters
const SliverSafeArea({
Key? key,
bool left = true,
bool top = true,
bool right = true,
bool bottom = true,
EdgeInsets minimum = EdgeInsets.zero,
required Widget sliver,
})
| Parameter | What it does |
|---|---|
left, top, right, bottom | Whether to respect the system inset on that side. All default true. |
minimum | Minimum padding to use on each side if the system inset is smaller. Useful for “at least 16 px even on devices with no notch”. |
sliver | The inner sliver to pad. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
// Important: set extendBodyBehindAppBar so the system insets
// are actually in MediaQuery when our SliverSafeArea reads them.
extendBodyBehindAppBar: true,
body: CustomScrollView(
slivers: <Widget>[
// A SliverAppBar already consumes the top inset, so top: false.
const SliverAppBar(title: Text('SliverSafeArea'), pinned: true),
SliverSafeArea(
top: false,
minimum: const EdgeInsets.all(16),
sliver: SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => Card(
child: ListTile(title: Text('Row $i')),
),
),
),
],
),
),
);
}
}
On a notched device in landscape, the list no longer slides under the notch. The minimum: EdgeInsets.all(16) guarantees at least 16 px on every side even on flat rectangular devices.
Real-world use cases
- Making sure the bottom of a scroll view is above the iOS home indicator.
- Avoiding the status bar area when you don’t have a
SliverAppBarat the top. - Keeping content off the camera notch in landscape orientation.
- Combining with a
CustomScrollViewinsideScaffold(body:)where the scaffold doesn’t automatically apply safe area.
When to use it
- ✅ You have a sliver that would otherwise be hidden by system UI.
- ✅ You want an “at least this much” floor using
minimum. - ✅ The padding must respect orientation changes and notched devices.
When NOT to use it
- ❌ A
Scaffoldalready handles the safe area for you (which it does by default forbody). Don’t double it up — you’ll get extra padding. - ❌ You need padding that is not tied to system insets — use
SliverPadding. - ❌ The sliver inside is a
SliverAppBar— app bars handle their own top inset.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Wrapping the whole scroll view in SafeArea | Works but then the app bar doesn’t reach the status bar — it looks wrong on Material design. | SliverSafeArea around only the content that needs it. |
Manually adding MediaQuery.padding to SliverPadding | You forget to re-compute it when orientation changes. | SliverSafeArea does it automatically. |
Dos and don’ts
| Do | Don’t |
|---|---|
Disable the sides that other slivers already handle (top: false under a SliverAppBar). | Don’t wrap a sliver in SliverSafeArea AND inside a regular SafeArea — doubled padding. |
Use minimum to enforce visual design on non-notched devices. | Don’t use it for non-system-related padding — use SliverPadding. |
Trust MediaQuery.padding, not hard-coded numbers. | Don’t hard-code top: 44 for iPhone notches — heights differ across devices. |
Common pitfalls
- The safe area adds nothing. Your
Scaffoldhas already consumed the system insets (the default). Either disable that (extendBody: true,extendBodyBehindAppBar: true) or drop theSliverSafeArea. - Content is double-padded. You wrapped things in both
SafeAreaandSliverSafeArea. Pick one. SliverSafeArea got a box childerror. You passed a non-sliver tosliver:. Wrap inSliverToBoxAdapteror use a real sliver.
Related widgets
SafeArea— box version.SliverPadding— manual padding without system insets.MediaQuery.padding— the raw data the safe area reads.
Official docs
SliverConstrainedCrossAxis
TL;DR: Limits the cross-axis size of an inner sliver to a maximum. In a vertical scroll, that means limiting the list/grid width. Useful for “max 640px wide content” layouts on tablets and desktops.
What is it?
By default, a sliver takes up the full cross-axis extent of its viewport — in a normal vertical scroll, that means 100% of the screen width. On desktop and tablet that is often the wrong thing: reading line-length recommendations say a paragraph of text should be at most ~640–720 px wide. SliverConstrainedCrossAxis lets you cap the cross-axis size of a single sliver.
The inner sliver is given a cross-axis extent equal to min(viewportCrossAxisExtent, maxExtent). It sits on the leading edge by default; if you want it centred, wrap it in a SliverCrossAxisGroup with flexible gutters.
Mental model
Viewport: 1200px wide (desktop)
SliverConstrainedCrossAxis(maxExtent: 640, sliver: myList)
→ myList is given 640px of cross-axis extent
→ The remaining 560px is empty gutter on the trailing side
Constructor & key parameters
const SliverConstrainedCrossAxis({
Key? key,
required double maxExtent,
required Widget sliver,
})
| Parameter | What it does |
|---|---|
maxExtent | Maximum cross-axis extent, in logical pixels. Must be non-negative. |
sliver | The sliver to constrain. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Constrained width')),
SliverConstrainedCrossAxis(
maxExtent: 640,
sliver: SliverList.builder(
itemCount: 40,
itemBuilder: (_, i) => Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(title: Text('Row $i')),
),
),
),
],
),
),
);
}
}
Run this on a desktop window sized to 1400 px wide: the list sits on the left, capped at 640 px. To center it, wrap the constrained sliver in a SliverCrossAxisGroup with flexible expanders on each side (see the SliverCrossAxisGroup page).
Centered variant
SliverCrossAxisGroup(
slivers: <Widget>[
const SliverCrossAxisExpanded(flex: 1, sliver: SliverToBoxAdapter(child: SizedBox())),
SliverConstrainedCrossAxis(
maxExtent: 640,
sliver: SliverList.builder(
itemCount: 40,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
),
const SliverCrossAxisExpanded(flex: 1, sliver: SliverToBoxAdapter(child: SizedBox())),
],
)
This puts the list in the middle with empty gutters on both sides.
Real-world use cases
- Capping article width for readable line lengths on wide screens.
- Limiting a form to 480 px so inputs don’t stretch across the screen.
- Reserving side space on tablet for an
EndDraweror side info panel. - Building “narrow mobile-style feed” layouts inside a wide window.
When to use it
- ✅ You have a wide screen but want content to stay narrow.
- ✅ You want a single sliver to have a different width from its siblings.
- ✅ You want the narrow area to share scroll with wider slivers (a full-width app bar above a narrow list is fine).
When NOT to use it
- ❌ Every sliver in the scroll should have the same width — set padding on the host
CustomScrollViewinstead (via wrapping inCenter+ConstrainedBox). - ❌ You want to split the cross-axis between multiple slivers (sidebar + content) — use
SliverCrossAxisGroup. - ❌ You want margin, not a hard cap — use
SliverPadding.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Wrapping the entire scroll in Center(child: SizedBox(width: 640, child: CustomScrollView(...))) | Makes the scroll view have fixed width, loses full-bleed headers. | SliverConstrainedCrossAxis around the content slivers only. |
Adding huge SliverPadding on a wide window | Works but feels awkward and does not react to resize. | SliverConstrainedCrossAxis (and centre with SliverCrossAxisGroup if needed). |
Dos and don’ts
| Do | Don’t |
|---|---|
Use it with SliverCrossAxisGroup + SliverCrossAxisExpanded if you want the constrained sliver centred. | Don’t nest SliverConstrainedCrossAxis inside itself — only the innermost takes effect. |
Pick a maxExtent based on readability guidelines (~640–720 for text). | Don’t set maxExtent smaller than child content — the child clips or scrolls horizontally. |
Common pitfalls
- Content is left-aligned, I wanted it centred. Wrap in
SliverCrossAxisGroupwith expanders as shown above. - Error: “must be a descendant of SliverCrossAxisGroup”. You used
SliverCrossAxisExpandedwithout aSliverCrossAxisGroupparent. Put the parent back. - The list still fills the width. The
maxExtentis maximum, not target. On narrow screens the content uses whatever the viewport gives it.
Related widgets
SliverCrossAxisGroup— split the cross-axis into multiple slivers.SliverCrossAxisExpanded— flex factor for one of those slivers.SliverPadding— soft padding instead of a hard cap.
Official docs
Part 40
Composition
Grouping slivers along the main and cross axes.
SliverMainAxisGroup
TL;DR: Groups several slivers into a single “super-sliver” that lays out its children one after another along the scroll axis. Pinned slivers inside the group are pinned only within the group’s scroll range — once the group scrolls away, the pins go away with it.
What is it?
SliverMainAxisGroup (Flutter 3.13+) bundles a list of slivers so the host viewport treats them as one unit. Inside the group, slivers stack vertically exactly like they would in a CustomScrollView. The special power is how pinning scope works: a pinned SliverAppBar inside a SliverMainAxisGroup pins only while the group is on screen. When the user scrolls past the entire group, the pinned app bar also scrolls away.
This is exactly what you need for “sectioned lists where each section has its own sticky header”.
Mental model
CustomScrollView
├── SliverMainAxisGroup ← a "section"
│ ├── SliverPersistentHeader (pinned: true) ← pins within this section only
│ ├── SliverList (section items)
│ └── SliverToBoxAdapter (section footer)
├── SliverMainAxisGroup ← next section, independent pinning
│ ├── SliverPersistentHeader (pinned: true)
│ └── SliverList
Result: while scrolling through section 1, section 1’s header is pinned. As soon as section 2’s header arrives, section 1’s header un-pins and scrolls away, and section 2’s header pins in its place.
Without SliverMainAxisGroup, a pinned header pins at the top of the entire viewport, regardless of which section the user is in. With it, pinning becomes scoped to the group.
Constructor & key parameters
const SliverMainAxisGroup({
Key? key,
required List<Widget> slivers,
})
One parameter: slivers, a list of slivers to group.
Minimal example: sectioned list with per-section sticky headers
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final Map<String, List<String>> sections = <String, List<String>>{
'Fruits': <String>['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'],
'Vegetables': <String>['Artichoke', 'Beetroot', 'Carrot', 'Daikon'],
'Grains': <String>['Barley', 'Millet', 'Oats', 'Quinoa', 'Rice'],
};
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Sectioned list'), pinned: true),
for (final MapEntry<String, List<String>> section in sections.entries)
SliverMainAxisGroup(
slivers: <Widget>[
PinnedHeaderSliver(
child: Container(
color: Colors.indigo,
height: 40,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
section.key,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
),
),
SliverList.builder(
itemCount: section.value.length,
itemBuilder: (_, i) => ListTile(title: Text(section.value[i])),
),
],
),
],
),
),
);
}
}
Scroll: the outer SliverAppBar stays pinned as always, but the section headers (“Fruits”, “Vegetables”, “Grains”) swap in and out of the pinned position as each section comes into view. This is the iOS-style section list.
Real-world use cases
- Contact lists grouped by first letter, each letter’s header sticking while you’re in that letter.
- Timelines grouped by day, each day’s date label sticking while reading that day.
- Category feeds (“Featured”, “Popular”, “New”) with per-category pinned labels.
- Settings screens with titled groups that stick while scrolling within them.
When to use it
- ✅ You need pinned headers scoped to sections, not to the whole scroll.
- ✅ You want to group slivers logically so siblings can refer to “this section”.
- ✅ You are on Flutter 3.13 or later.
When NOT to use it
- ❌ You only need one pinned header at the top of the whole list — use
SliverAppBarorPinnedHeaderSliverdirectly. - ❌ You want to split the cross axis (sidebar + content) — use
SliverCrossAxisGroup. - ❌ Your sections are just data, no visual grouping —
SliverListwith a builder that renders section headers inline works fine.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
| Hand-rolled section headers that sometimes overlap each other at the boundary | Pinning logic is scoped to the whole viewport, so multiple headers stack at top. | SliverMainAxisGroup per section with a pinned header inside each. |
Nested CustomScrollViews | Breaks shared scroll. | SliverMainAxisGroup to keep the one-scroll feel. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Put the pinned header as the first sliver in each group. | Don’t put a pinned header in the middle of the group — pinning starts where the header begins. |
Use a PinnedHeaderSliver or SliverPersistentHeader(pinned: true) inside each group. | Don’t mix groups and standalone pinned headers expecting them to interact — they don’t. |
Common pitfalls
- Headers don’t “push” each other out correctly. Each group is independent. If you want one header to push the previous out when it reaches the top, that happens naturally because the old group’s layout extent runs out at the same time the new group’s header arrives. Make sure each section has content before the next group starts.
- Class not found. You are on Flutter < 3.13. Upgrade.
- Performance on many groups.
SliverMainAxisGroupis lightweight, but if you have hundreds of groups, consider flattening them into a single sliver with per-item logic.
Related widgets
SliverCrossAxisGroup— same idea, cross axis.PinnedHeaderSliver— common occupant of a group’s first slot.SliverPersistentHeader— more-customisable alternative for the section header.
Official docs
SliverCrossAxisGroup
TL;DR: Lays out several slivers side by side along the cross axis (horizontally, in a vertical scroll). Lets you build a “sidebar + main content” layout where both sides scroll together as one. Slivers inside must declare their cross-axis width via SliverConstrainedCrossAxis or SliverCrossAxisExpanded.
What is it?
SliverCrossAxisGroup (Flutter 3.13+) bundles multiple slivers into a horizontal row (in a vertical scroll) or vertical column (in a horizontal scroll). Each child gets part of the cross-axis extent — either a fixed width (SliverConstrainedCrossAxis) or a flex share of the leftover space (SliverCrossAxisExpanded).
This is how you build:
- A sidebar next to content, sharing one scroll.
- A two-column layout where both columns are their own sliver.
- Responsive “newspaper” layouts on wide screens.
Mental model
┌─────────────────────────────────────────┐
│ viewport (cross axis) │
│ ┌─────┬─────────────────────┬─────────┐ │
│ │ 240 │ flex: 2 │ flex: 1 │ │
│ │ │ │ │ │
│ │ sb │ main list │ extras │ │
│ │ │ │ │ │
│ └─────┴─────────────────────┴─────────┘ │
└─────────────────────────────────────────┘
↑ all scroll together vertically
The layout order is: first, fixed-width slivers (wrapped in SliverConstrainedCrossAxis) take their slots. Then, flex slivers (wrapped in SliverCrossAxisExpanded) split the remaining space proportionally to their flex value.
The children scroll together. They share one scroll position. The main axis extent of the group is the maximum main axis extent of any child (so they effectively line up at the top and bottom).
Constructor & key parameters
const SliverCrossAxisGroup({
Key? key,
required List<Widget> slivers,
})
One parameter. But each child of slivers must be wrapped in either SliverConstrainedCrossAxis (fixed cross-axis width) or SliverCrossAxisExpanded (flex). Otherwise the layout is ambiguous.
Minimal example: sidebar + content sharing one scroll
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Cross-axis layout'), pinned: true),
SliverCrossAxisGroup(
slivers: <Widget>[
// Fixed 240px sidebar
SliverConstrainedCrossAxis(
maxExtent: 240,
sliver: SliverList.builder(
itemCount: 20,
itemBuilder: (_, i) => Container(
color: Colors.indigo.shade50,
padding: const EdgeInsets.all(12),
child: Text('Sidebar $i'),
),
),
),
// Main content flexes into the remaining width
SliverCrossAxisExpanded(
flex: 1,
sliver: SliverList.builder(
itemCount: 50,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
),
],
),
],
),
),
);
}
}
You get a 240 px sidebar on the left and a main list on the right. Both scroll together.
Real-world use cases
- Desktop / tablet layouts with a fixed sidebar and main content.
- Magazine layouts with a main column and a pull-quotes column.
- Split-pane reading apps where the left pane is chapters and the right is text.
- A “navigator” column next to a long form, both sharing one scroll.
When to use it
- ✅ You want multiple slivers laid out side by side sharing one scroll.
- ✅ The layout is responsive and you want one flexible and one fixed column.
- ✅ You are on Flutter 3.13 or later.
When NOT to use it
- ❌ You want two independent scrolls (each column scrolls separately) — use a
Rowof twoCustomScrollViews instead. - ❌ You just want a narrow column centred on a wide screen — use
SliverConstrainedCrossAxisalone. - ❌ You want different main-axis behaviour per column (one scrolls, one is fixed) — use a
Rowcontaining a fixed widget and a scroll view.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Row(children: [Column, Expanded(child: ListView)]) | Two scroll positions; the column doesn’t scroll with the list. | SliverCrossAxisGroup with both sides as slivers. |
Fixed sidebar stacked over a ListView with margin | Content under the sidebar is visually odd and doesn’t scroll. | SliverCrossAxisGroup with a real sidebar sliver. |
Dos and don’ts
| Do | Don’t |
|---|---|
Wrap every child in SliverConstrainedCrossAxis or SliverCrossAxisExpanded. | Don’t put a raw sliver directly in slivers: — the layout won’t know its width. |
| Combine fixed and flex children for responsive layouts. | Don’t put a scroll view inside the group — the children must be slivers, not other scrolls. |
Use an empty SliverToBoxAdapter(child: SizedBox()) wrapped in an expander to create gutters. | Don’t try to use crossAxisAlignment — there is no such parameter. |
Common pitfalls
- “Must be a descendant of SliverCrossAxisGroup”. You used
SliverCrossAxisExpandedoutside aSliverCrossAxisGroup. Wrap it properly. - Children don’t line up at the top. They do — the group enforces that. If one side looks offset, check padding inside the child.
- Both sides have different scroll extents and the shorter one ends early. That is expected — the group’s scroll extent is the max of its children. The shorter side just shows blank space at the bottom.
- The sidebar is empty. You used
SliverCrossAxisExpandedwithflex: 0.flexmust be > 0; useSliverConstrainedCrossAxisfor a fixed size instead.
Related widgets
SliverCrossAxisExpanded— flex-factor parent-data widget for a flex sliver in the group.SliverConstrainedCrossAxis— fixed-width sliver in the group.SliverMainAxisGroup— main-axis equivalent.
Official docs
SliverCrossAxisExpanded
TL;DR: A ParentDataWidget that marks a sliver inside a SliverCrossAxisGroup as “take a flex share of the leftover cross-axis space”. The sliver version of Expanded.
What is it?
SliverCrossAxisExpanded is not a layout container — it is a marker. You wrap a sliver with it, and the marker tells its immediate parent (SliverCrossAxisGroup) that the wrapped sliver should flex into leftover cross-axis space.
It works exactly like Expanded in a Row: each flex child gets a share of the leftover space proportional to its flex value. The flex share is computed after the fixed-width children (wrapped in SliverConstrainedCrossAxis) have been sized.
Mental model
SliverCrossAxisGroup(cross-axis extent: 1000)
├── SliverConstrainedCrossAxis(maxExtent: 200, sliver: sidebar) ← fixed: 200
├── SliverCrossAxisExpanded(flex: 2, sliver: main) ← gets (1000-200) * 2/3 = 533
└── SliverCrossAxisExpanded(flex: 1, sliver: extras) ← gets (1000-200) * 1/3 = 267
It is the same math as Row + Expanded.
Constructor & key parameters
const SliverCrossAxisExpanded({
Key? key,
required int flex,
required Widget sliver,
})
| Parameter | What it does |
|---|---|
flex | Positive integer. The flex weight. Must be > 0. |
sliver | The sliver to flex. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Flex columns'), pinned: true),
SliverCrossAxisGroup(
slivers: <Widget>[
SliverCrossAxisExpanded(
flex: 2,
sliver: SliverList.builder(
itemCount: 40,
itemBuilder: (_, i) => Container(
color: Colors.indigo.shade50,
padding: const EdgeInsets.all(12),
child: Text('Main $i'),
),
),
),
SliverCrossAxisExpanded(
flex: 1,
sliver: SliverList.builder(
itemCount: 20,
itemBuilder: (_, i) => Container(
color: Colors.amber.shade50,
padding: const EdgeInsets.all(12),
child: Text('Extras $i'),
),
),
),
],
),
],
),
),
);
}
}
Main column gets 2/3 of the width; extras column gets 1/3. Both scroll together under the same SliverAppBar.
Real-world use cases
- Two-column article layouts where one column is wider.
- Three-column dashboards.
- Any “sidebar + main + secondary” layout.
- Adding gutters between columns by wrapping an empty sliver in a small-flex expander.
When to use it
- ✅ You are inside a
SliverCrossAxisGroupand want a child to take a share of leftover space. - ✅ Your columns should scale with the viewport width.
- ✅ You need the
Row+Expandedpattern inside a scroll view.
When NOT to use it
- ❌ You are not inside a
SliverCrossAxisGroup— the parent-data setup will fail with a cast error. - ❌ You want a fixed-width column — use
SliverConstrainedCrossAxis. - ❌ You need independent scroll positions — use a plain
Row.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Row(children: [Expanded(flex: 2, child: ListView), Expanded(flex: 1, child: ListView)]) | Two separate scroll positions, doesn’t scroll as one. | SliverCrossAxisGroup with SliverCrossAxisExpanded. |
Dos and don’ts
| Do | Don’t |
|---|---|
Only use it as a direct child of SliverCrossAxisGroup. | Don’t wrap a box widget — it must contain a sliver. |
| Use integer flex values (2, 3, 1). | Don’t use 0 — flex must be > 0. |
Combine with SliverConstrainedCrossAxis for mixed fixed/flex layouts. | Don’t nest flex in flex — compute the layout once at the group level. |
Common pitfalls
- Cast error: “type ‘_X’ is not a subtype of type ‘SliverPhysicalContainerParentData’”. You used
SliverCrossAxisExpandedoutside aSliverCrossAxisGroup. Wrap it. flexmust be > 0 assertion. You passed0. Use a fixed-width sliver instead.- The column is too narrow. The total cross-axis extent is divided by the sum of flexes and then proportioned. If another flex column has a much larger flex value, yours gets less than expected.
Related widgets
SliverCrossAxisGroup— the required parent.SliverConstrainedCrossAxis— fixed-size sibling.Expanded— theRow/Columnbox equivalent.
Official docs
Part 50
Effects, Visibility & Decoration
Fading, hiding, decorating, and disabling pointer events on sliver subtrees.
SliverOpacity
TL;DR: The sliver version of Opacity. Paints a sliver child with a fractional opacity between 0.0 (invisible) and 1.0 (fully opaque). The sliver keeps taking up space regardless.
What is it?
SliverOpacity wraps one sliver and draws it translucently. The wrapped sliver is still laid out normally — it still eats the same amount of scroll extent — but its paint pass is scaled by opacity.
Opacity values 0.0 and 1.0 hit a fast path. Any other value (say, 0.3) requires Flutter to paint the sliver into an intermediate buffer and then blend it. That blend is not free; on large slivers it can cost frames. For static on/off visibility, prefer SliverVisibility. For animated opacity, use SliverAnimatedOpacity or SliverFadeTransition.
Mental model
opacity: 0.0 → fully transparent, layout untouched, expensive offscreen buffer still allocated
opacity: 0.5 → half transparent, expensive offscreen buffer
opacity: 1.0 → fully opaque, fast path, no extra buffer
Constructor & key parameters
const SliverOpacity({
Key? key,
required double opacity,
bool alwaysIncludeSemantics = false,
Widget? sliver,
})
| Parameter | What it does |
|---|---|
opacity | Between 0.0 and 1.0, inclusive. |
alwaysIncludeSemantics | If true, accessibility tools still see the sliver even when its opacity is 0.0. |
sliver | The sliver child. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverOpacity')),
SliverOpacity(
opacity: 0.3,
sliver: SliverList.builder(
itemCount: 20,
itemBuilder: (_, i) => ListTile(title: Text('Dim row $i')),
),
),
],
),
),
);
}
}
The list is 30% opaque, visibly greyed out, but still scrollable and still takes up its full layout space.
Real-world use cases
- “Disabled” sections of a screen that are still visible but visually muted.
- Fading in newly-loaded content when it first appears (pair with a state-based opacity change).
- Watermark-style dimmed overlays on existing content.
When to use it
- ✅ You want a static fractional opacity on a sliver.
- ✅ The visual effect matters but performance of the surrounding list is not a concern.
When NOT to use it
- ❌ You only need to fully show or fully hide a sliver — use
SliverVisibility. It is cheaper. - ❌ You want opacity to animate — use
SliverAnimatedOpacity(implicit) orSliverFadeTransition(explicit controller). - ❌ You want to hide a sliver from layout entirely — use
SliverOffstageor remove it from the tree.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Opacity wrapping a SliverList inside SliverToBoxAdapter | Breaks laziness and breaks sliver layout. | SliverOpacity wrapping the sliver directly. |
Dos and don’ts
| Do | Don’t |
|---|---|
Use alwaysIncludeSemantics: true when fading content that is still meant to be read by screen readers. | Don’t use a fractional opacity just to disable input — combine with SliverIgnorePointer for that. |
Prefer 1.0 or 0.0 whenever possible (fast path). | Don’t animate opacity by calling setState every frame on a SliverOpacity — use SliverAnimatedOpacity. |
Common pitfalls
- Performance drop when a large list is wrapped. Every frame allocates an intermediate buffer the size of the painted list. Either shrink the scope or use visibility instead.
- Content still responds to taps when “invisible”. Opacity only affects paint, not hit testing. Wrap in
SliverIgnorePointertoo if that matters. - Accessibility reads the hidden content. That is the default (since the tree is still there). Set
alwaysIncludeSemanticsexplicitly if you want control.
Related widgets
SliverAnimatedOpacity— implicit animation.SliverFadeTransition— explicitAnimation<double>.SliverVisibility— on/off without the fractional cost.SliverOffstage— remove from paint but keep in the tree.Opacity— box version.
Official docs
SliverAnimatedOpacity
TL;DR: The implicit-animation version of SliverOpacity. Pass a new opacity value and Flutter tweens from the old value to the new one over a given duration. The sliver equivalent of AnimatedOpacity.
What is it?
SliverAnimatedOpacity is an ImplicitlyAnimatedWidget. You give it an opacity and a duration. Whenever you change the opacity (via setState), Flutter automatically animates from the previous value to the new one. You don’t manage an AnimationController yourself.
Internally, it uses a SliverFadeTransition driven by its own controller.
Mental model
setState(() => _opacity = 1.0);
// → over 300ms, the sliver fades from whatever it was to 1.0
One line change, free animation. Use this for “fade in/out on condition”.
Constructor & key parameters
const SliverAnimatedOpacity({
Key? key,
Widget? sliver,
required double opacity,
Curve curve = Curves.linear,
required Duration duration,
VoidCallback? onEnd,
bool alwaysIncludeSemantics = false,
})
| Parameter | What it does |
|---|---|
opacity | Target opacity. Must be in [0.0, 1.0]. |
duration | How long the implicit animation takes. |
curve | The easing curve. Defaults to Curves.linear. |
onEnd | Called when the animation reaches its new value. |
alwaysIncludeSemantics | Same as SliverOpacity — always expose accessibility info. |
sliver | The sliver child. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: FadeScreen());
}
class FadeScreen extends StatefulWidget {
const FadeScreen({super.key});
@override
State<FadeScreen> createState() => _FadeScreenState();
}
class _FadeScreenState extends State<FadeScreen> {
bool _shown = true;
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _shown = !_shown),
child: const Icon(Icons.swap_horiz),
),
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Animated opacity'), pinned: true),
SliverAnimatedOpacity(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
opacity: _shown ? 1.0 : 0.2,
sliver: SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
),
],
),
);
}
}
Tap the button, the list fades to 20% over 300 ms; tap again, it fades back. No controller.
Real-world use cases
- Fade out a section when a toggle is turned off without removing it from the layout.
- Fade in newly-loaded results under a loading header.
- Dim a form section while another is being edited.
When to use it
- ✅ You want “fade when this boolean flips” without writing an
AnimationController. - ✅ The animation is triggered by state changes, not a running controller.
When NOT to use it
- ❌ You already have an
AnimationControllerdriving other things — useSliverFadeTransitionand share the controller. - ❌ You want static opacity — use
SliverOpacity. - ❌ You want to hide the sliver completely — use
SliverVisibility; fading to0.0still allocates the offscreen buffer.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
AnimationController + setState to fade a sliver | Manual lifecycle, dispose logic, feels heavy. | SliverAnimatedOpacity with a duration. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Pick a duration that matches your app’s motion language (200–400 ms is common). | Don’t set duration: Duration.zero — use SliverOpacity for instant changes. |
Pair with SliverIgnorePointer if the “invisible” state should also ignore taps. | Don’t expect the animation to survive a widget rebuild with a different Key. Implicit animations animate between builds of the same widget instance. |
Common pitfalls
- Animation runs on the first build. Yes — from the default initial value (0.0 or whatever you pass) to the first target. If you don’t want that initial animation, use a slightly different widget structure or
TickerProviderStateMixinand an explicit controller. - Repeated rebuilds cause the animation to restart. Happens when the parent rebuilds with a new
Key. Keep the widget’s identity stable across rebuilds.
Related widgets
SliverOpacity— static.SliverFadeTransition— explicit controller.AnimatedOpacity— box version.
Official docs
SliverFadeTransition
TL;DR: The sliver version of FadeTransition. You give it an existing Animation<double> (usually driven by an AnimationController) and it fades the wrapped sliver according to that animation’s current value. Explicit-control sibling of SliverAnimatedOpacity.
What is it?
SliverFadeTransition listens to an Animation<double> you own and rebuilds the sliver’s opacity whenever the animation ticks. Use this when you already have an AnimationController (for entrance transitions, coordinated sequences, route animations) and want a sliver to fade along with it.
It is the widget that SliverAnimatedOpacity uses internally.
Mental model
AnimationController (ticks from 0.0 to 1.0 over some duration)
↓ drives
SliverFadeTransition(opacity: controller)
↓ every tick rebuilds
The wrapped sliver at opacity = controller.value
Constructor & key parameters
const SliverFadeTransition({
Key? key,
required Animation<double> opacity,
bool alwaysIncludeSemantics = false,
Widget? sliver,
})
| Parameter | What it does |
|---|---|
opacity | The Animation<double> that produces the opacity value each frame. Can be clamped with CurvedAnimation. |
alwaysIncludeSemantics | Same as SliverOpacity. |
sliver | The sliver child. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: FadeInScreen());
}
class FadeInScreen extends StatefulWidget {
const FadeInScreen({super.key});
@override
State<FadeInScreen> createState() => _FadeInScreenState();
}
class _FadeInScreenState extends State<FadeInScreen> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
)..forward();
late final Animation<double> _fade =
CurvedAnimation(parent: _controller, curve: Curves.easeOut);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Fade in'), pinned: true),
SliverFadeTransition(
opacity: _fade,
sliver: SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
),
],
),
);
}
}
On first frame the list is invisible; over 600 ms it fades in. The controller is the single source of truth — if you wanted to coordinate the fade with a slide or a scale, you’d attach them to the same controller.
Real-world use cases
- Entrance transitions for a whole screen section.
- Shared-element route animations where the incoming sliver fades in as the previous fades out.
- Pulsing “loading” indicators synchronized with other animations.
- Chaining multiple fades with
Intervalon the same controller for a staggered entrance.
When to use it
- ✅ You already own an
AnimationControllerand want the sliver to react to it. - ✅ You need coordinated animations across multiple widgets.
- ✅ You want more control than
SliverAnimatedOpacityoffers (e.g., reverse on scroll, staggered intervals).
When NOT to use it
- ❌ You just want “fade in when visible, fade out when hidden” — use
SliverAnimatedOpacity. Less boilerplate. - ❌ You want static opacity — use
SliverOpacity. - ❌ You want a completely different transition (slide, scale) — use the corresponding Material/Animated widgets.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
FadeTransition around a SliverToBoxAdapter | Breaks the sliver protocol; the child can’t be a real sliver. | SliverFadeTransition directly. |
Dos and don’ts
| Do | Don’t |
|---|---|
Dispose the controller in dispose(). | Don’t forget to — it’s a Ticker, it will leak otherwise. |
Use CurvedAnimation to apply an easing curve. | Don’t apply a curve by manually computing values — let the animation object do it. |
Common pitfalls
- Controller leak. Not disposing leaks the ticker and prints warnings in debug mode.
- Animation runs once and freezes. You called
forward()once and forgot to reverse or repeat. Callreverse()/repeat()/reset()as needed. - No visual effect. The
Animation<double>is always returning the same value. Check that_controller.forward()was called.
Related widgets
SliverAnimatedOpacity— implicit.SliverOpacity— static.FadeTransition— box version.
Official docs
SliverVisibility
TL;DR: The sliver version of Visibility. Shows or hides a sliver entirely, with fine-grained control over whether to keep the state, size, animations, semantics, and interactivity of the hidden subtree. Cheaper than SliverOpacity(opacity: 0) for fully-hidden content.
What is it?
SliverVisibility toggles between showing a sliver and replacing it with a stand-in sliver (by default, an empty SliverToBoxAdapter, which reports zero geometry). Unlike opacity, hidden mode is free — no offscreen buffer, no compositing.
You can also choose to maintain certain aspects of the hidden sliver. The five maintain* flags let you keep things that would otherwise disappear:
maintainState— keep the subtree alive (otherwise disposed).maintainAnimation— keep animations ticking (requiresmaintainState).maintainSize— keep taking up layout space (requiresmaintainAnimation).maintainSemantics— keep accessibility info (requiresmaintainSize).maintainInteractivity— still receive taps (requiresmaintainSize).
Each flag requires the previous one. This chain exists because maintaining semantics without size doesn’t make sense, and so on.
Mental model
visible: true → the sliver is shown normally
visible: false → replaced by replacementSliver (empty by default)
maintainState: true → the subtree stays alive in the background
maintainSize: true → takes up layout space even when invisible
Constructor & key parameters
const SliverVisibility({
Key? key,
required Widget sliver,
Widget replacementSliver = const SliverToBoxAdapter(),
bool visible = true,
bool maintainState = false,
bool maintainAnimation = false,
bool maintainSize = false,
bool maintainSemantics = false,
bool maintainInteractivity = false,
})
const SliverVisibility.maintain({
Key? key,
required Widget sliver,
Widget replacementSliver = const SliverToBoxAdapter(),
bool visible = true,
})
The .maintain constructor sets all five flags to true — use it when you want “just like SliverOpacity(opacity: 0) but without the cost”.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: ToggleScreen());
}
class ToggleScreen extends StatefulWidget {
const ToggleScreen({super.key});
@override
State<ToggleScreen> createState() => _ToggleScreenState();
}
class _ToggleScreenState extends State<ToggleScreen> {
bool _visible = true;
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _visible = !_visible),
child: const Icon(Icons.visibility),
),
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverVisibility'), pinned: true),
SliverVisibility(
visible: _visible,
sliver: SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Footer — always visible'),
),
),
],
),
);
}
}
Tap the button: the list disappears entirely and the footer slides up into its place. Tap again: the list comes back, rebuilt from scratch (because maintainState is false by default).
Real-world use cases
- Hiding a whole section based on a feature flag.
- Hiding results until the user runs a query.
- Hiding “admin” slivers for non-admin users.
- Temporarily removing a slow-to-build sliver during a transition, then restoring it afterwards.
When to use it
- ✅ You want to fully hide a sliver and reclaim its layout space.
- ✅ You want on/off behaviour, not animated opacity.
- ✅ You need the fine-grained maintain flags (e.g., keep animations ticking behind the scenes).
When NOT to use it
- ❌ You want a cross-fade animation — use
SliverAnimatedOpacityand pair with a state flag. - ❌ You want the sliver to stay in the tree for offstage purposes (e.g., pre-warming) — use
SliverOffstage. - ❌ You need a partial opacity — use
SliverOpacity. - ❌ The sliver can just be omitted entirely with an
if— that is simpler.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
SliverOpacity(opacity: visible ? 1.0 : 0.0) | Pays compositing cost while invisible. | SliverVisibility(visible: visible). |
Conditionally omitting the sliver with if | Fine unless you need to preserve state / animations. | SliverVisibility with maintainState: true. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Use the plain constructor when you don’t need to maintain anything. | Don’t enable maintainState casually — it keeps the subtree in memory. |
Use .maintain when you want “invisible but exactly the same shape”. | Don’t set maintainSemantics without maintainSize (the assert will fail). |
Pass a custom replacementSliver if you want something other than zero-sized. | Don’t use both visibility and opacity on the same sliver — pick one. |
Common pitfalls
- State disappears when hidden. That is the default. Use
maintainState: trueto keep it. - Layout shifts when toggling. Enable
maintainSize: trueso the hidden sliver still occupies its space. - Assertion failure: “Cannot maintain semantics if size is not maintained”. You enabled a higher-level flag without its prerequisite. Walk up the chain.
Related widgets
SliverOpacity— fractional visibility.SliverAnimatedOpacity— animated fade.SliverOffstage— invisible but laid out; keeps animations running.Visibility— box version.
Official docs
SliverOffstage
TL;DR: Lays a sliver out as if it were in the tree, but doesn’t paint it, doesn’t hit-test it, and doesn’t take any room in the parent. Useful for pre-warming expensive slivers or keeping animations running in the background. The sliver version of Offstage.
What is it?
SliverOffstage is the “in the tree but invisible, and taking no space” sliver. Its subtree still builds and runs (so animations tick and resources load), but the sliver reports a SliverGeometry of zero — no layout, no paint, no scroll contribution, no hit testing.
This is a useful trick for things like: a hidden tab’s content that should keep animating, a carousel pre-warming the next slide, or a measurement step you want done but not displayed.
Mental model
offstage: false → sliver is drawn and scrolled through as usual
offstage: true → the subtree still builds, but:
- no scroll extent contributed
- no paint
- no hit testing
- animations keep running
Compared to SliverVisibility:
SliverVisibility(maintainState: true)keeps the widget alive and takes layout space.SliverVisibility(visible: false)removes the widget entirely.SliverOffstagekeeps the widget alive AND removes it from layout — somethingSliverVisibilityalone cannot do in one step.
Constructor & key parameters
const SliverOffstage({
Key? key,
bool offstage = true,
Widget? sliver,
})
| Parameter | What it does |
|---|---|
offstage | When true, the sliver is hidden from layout and paint but still built. When false, it is shown normally. Defaults true. |
sliver | The sliver child. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: OffstageScreen());
}
class OffstageScreen extends StatefulWidget {
const OffstageScreen({super.key});
@override
State<OffstageScreen> createState() => _OffstageScreenState();
}
class _OffstageScreenState extends State<OffstageScreen> {
bool _hidden = true;
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _hidden = !_hidden),
child: const Icon(Icons.layers),
),
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverOffstage'), pinned: true),
SliverOffstage(
offstage: _hidden,
sliver: SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => ListTile(title: Text('Hidden row $i')),
),
),
SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => ListTile(title: Text('Visible row $i')),
),
],
),
);
}
}
When the offstage sliver is hidden, the “Visible” list starts right under the app bar. Toggle it off: the offstage list slides in on top, pushing the visible list down. Both lists build at all times; offstage just hides its layout contribution.
Real-world use cases
- Pre-warming a sliver you know will appear soon, so its first frame is smooth.
- Keeping an animation running in the background of a screen it’s not currently shown on.
- Measuring content (rare — usually a render-layer concern).
When to use it
- ✅ You want the subtree built but not visible.
- ✅ You want animations, streams, and listeners to keep running.
- ✅ You don’t want the hidden sliver to occupy any scroll space.
When NOT to use it
- ❌ You want the sliver gone for good — use
SliverVisibilityor omit it. - ❌ You want it invisible but still taking space — use
SliverVisibility(visible: false, maintainSize: true). - ❌ You want it as a cheap way to hide something — the subtree is still built every rebuild. That is not “cheap”.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Wrapping content in Offstage inside a SliverToBoxAdapter | Breaks sliver laziness. | SliverOffstage directly. |
| Keeping a widget always-visible just so its animation runs | Wastes pixels. | SliverOffstage keeps it running without drawing. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Use sparingly — the subtree is still built and uses memory. | Don’t use it as a substitute for SliverVisibility when you don’t care about background animations. |
Common pitfalls
- Performance drops from an “invisible” section. Offstage still builds. If that is expensive, consider whether you really need the subtree alive.
- Animation still runs when I don’t want it to. That’s the point of offstage. Switch to
SliverVisibilitywithoutmaintainAnimation. - The offstage sliver shows at the top of the screen when toggled on. That is layout behaviour — offstage has zero layout, so when you toggle to visible, it appears at its natural position in the scroll.
Related widgets
SliverVisibility— can maintain state without running animations.SliverOpacity— fade instead of hide.Offstage— box version.
Official docs
SliverIgnorePointer
TL;DR: Makes a sliver invisible to hit testing — taps, drags, and other pointer events pass straight through. The sliver still lays out and still paints as normal. The sliver version of IgnorePointer.
What is it?
SliverIgnorePointer is a “disable input” wrapper. It does not change visual appearance or layout. It just tells Flutter “don’t let pointer events land on anything inside this sliver”. Whatever is below the sliver in the hit-test order will receive events instead.
Pair this with SliverOpacity, SliverAnimatedOpacity, or a colour overlay to build a full “disabled” state.
Mental model
ignoring: true → pointers go through, no taps, no drags, no gestures
ignoring: false → normal behaviour
Use ignoring as a boolean you toggle from state. It does not animate.
Constructor & key parameters
const SliverIgnorePointer({
Key? key,
bool ignoring = true,
Widget? sliver,
})
| Parameter | What it does |
|---|---|
ignoring | If true, pointer events are ignored. Defaults to true. |
sliver | The sliver child. |
There is also a deprecated ignoringSemantics parameter — don’t use it.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: DisableScreen());
}
class DisableScreen extends StatefulWidget {
const DisableScreen({super.key});
@override
State<DisableScreen> createState() => _DisableScreenState();
}
class _DisableScreenState extends State<DisableScreen> {
bool _disabled = true;
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () => setState(() => _disabled = !_disabled),
child: Icon(_disabled ? Icons.lock : Icons.lock_open),
),
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverIgnorePointer'), pinned: true),
SliverIgnorePointer(
ignoring: _disabled,
sliver: SliverOpacity(
opacity: _disabled ? 0.4 : 1.0,
sliver: SliverList.builder(
itemCount: 30,
itemBuilder: (_, i) => ListTile(
title: Text('Row $i'),
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Tapped $i')),
),
),
),
),
),
],
),
);
}
}
When disabled, the list is dim (from SliverOpacity) AND taps do nothing (from SliverIgnorePointer). Toggle to enabled and the list becomes both bright and tappable again.
Real-world use cases
- Disabling a form section while another is being validated.
- Making a “loading overlay” non-interactive so users can’t tap through to the list behind it.
- Disabling an entire section based on a feature flag.
- Preventing taps on a fading-out sliver during a transition.
When to use it
- ✅ You want input to be disabled without changing layout.
- ✅ You want to combine disabling with opacity/decoration to build a full “disabled look”.
- ✅ You want events to pass through to something behind the sliver.
When NOT to use it
- ❌ You want the sliver gone from layout — use
SliverVisibilityorSliverOffstage. - ❌ You want it to absorb events (block them but not let them through) — use
SliverAbsorbPointer… which does not exist as a sliver. You can wrap inAbsorbPointerinside aSliverToBoxAdapter, but that only works for box children. For slivers, ignoring is the only option at the sliver level. - ❌ You want to disable a single item in a list — handle that at the item level.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Wrapping a sliver in IgnorePointer inside SliverToBoxAdapter | Breaks sliver layout. | SliverIgnorePointer directly. |
Manually checking a “disabled” flag in every onTap in a list | Duplicated code everywhere. | One SliverIgnorePointer wrapper. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Pair with opacity / colour to make the disabled state look disabled. | Don’t rely on ignoring: true alone to communicate disabled-ness; users need visual feedback too. |
| Toggle with a state boolean. | Don’t use it to absorb events for hit-test purposes — use AbsorbPointer at the box level where possible. |
Common pitfalls
- Looks the same as enabled. Right —
SliverIgnorePointeronly affects hit testing. Combine with opacity or a dim overlay. - Events still land on the sliver’s children somehow. They won’t if
ignoring: true. If you see something responding, check for an ancestorAbsorbPointeror another event listener above the sliver. - Accessibility tools still announce the disabled items. That is expected; you may also need to wrap in
ExcludeSemanticsorSliverSemantics(excludeSemantics: true).
Related widgets
SliverOpacity— pair with it for dimmed + disabled.SliverVisibility— hide entirely.IgnorePointer— box version.AbsorbPointer— box version that absorbs instead of passing through.
Official docs
DecoratedSliver
TL;DR: Paints a Decoration (usually a BoxDecoration) behind or in front of a sliver. The sliver version of DecoratedBox. Useful for backgrounds, borders, gradients, and shadows that should scroll with the sliver they decorate.
Note the name: DecoratedSliver, not
SliverDecoratedBox. It is the one exception to theSliver*prefix rule in the widgets library.
What is it?
DecoratedSliver (Flutter 3.13+) wraps a sliver and paints a Decoration either behind it (default) or in front of it. The decoration covers the full painted area of the wrapped sliver — not just its children individually, but the whole visible region.
This is the right tool for:
- A background colour or gradient that scrolls with a section.
- A border around a section that moves with the scroll.
- A shadow cast by a section onto the one below it.
Mental model
DecoratedSliver(decoration: BoxDecoration(color: Colors.indigo), sliver: mySliver)
→ painted area of mySliver is filled with indigo
→ mySliver's children are drawn on top of that fill
With position: DecorationPosition.foreground, the decoration is drawn over the sliver’s content — useful for tints, overlays, or a border that appears above items.
Constructor & key parameters
const DecoratedSliver({
Key? key,
required Decoration decoration,
DecorationPosition position = DecorationPosition.background,
Widget? sliver,
})
| Parameter | What it does |
|---|---|
decoration | The Decoration to paint. Usually a BoxDecoration. |
position | DecorationPosition.background (default) or DecorationPosition.foreground. |
sliver | The sliver to decorate. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('DecoratedSliver'), pinned: true),
DecoratedSliver(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Colors.indigo.shade50, Colors.indigo.shade200],
),
),
sliver: SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList.builder(
itemCount: 20,
itemBuilder: (_, i) => Card(
child: ListTile(title: Text('Row $i')),
),
),
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 40),
),
DecoratedSliver(
decoration: BoxDecoration(
color: Colors.amber.shade50,
border: Border.all(color: Colors.amber.shade700, width: 2),
borderRadius: BorderRadius.circular(12),
),
sliver: SliverList.builder(
itemCount: 10,
itemBuilder: (_, i) => ListTile(title: Text('Row $i in a box')),
),
),
],
),
),
);
}
}
The first section has a smooth indigo gradient background; the second has a bordered amber background. Both scroll with their content.
Real-world use cases
- Section backgrounds that differentiate grouped content visually.
- Gradient banners behind a
SliverMainAxisGroup. - Borders around a sliver group that should remain perfectly aligned with its content as it scrolls.
- Gold-badge foreground overlays on a “featured” section.
When to use it
- ✅ You want a decoration that scrolls with the sliver it wraps.
- ✅ You want a border, gradient, or shadow that covers a whole sliver region, not each item individually.
- ✅ You are on Flutter 3.13 or later.
When NOT to use it
- ❌ You want decoration on each list item — put the decoration on the item itself (e.g., a
Card). - ❌ You want a fixed background behind the whole scroll view — use
Stackwith a background behind theCustomScrollView. - ❌ You want to decorate a box — use
DecoratedBox.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Wrapping the whole scroll view in a DecoratedBox for a background that only belongs to one section | The background covers everything, not just the section. | DecoratedSliver around the specific sliver. |
Drawing a border with Stack + CustomPaint over a list section | Brittle, doesn’t scroll naturally. | DecoratedSliver with BoxDecoration.border. |
Dos and don’ts
| Do | Don’t |
|---|---|
Use BoxDecoration with borderRadius for rounded group backgrounds. | Don’t put a shadow inside the decoration and expect it to cast outside the sliver bounds — slivers clip. |
Combine with SliverPadding to keep the decoration inset from the screen edges. | Don’t use DecorationPosition.foreground for essential content; the foreground paints over your list items. |
Common pitfalls
- Decoration disappears under the app bar. That is correct — the sliver’s painted area doesn’t extend under siblings.
- Shadows are clipped. Slivers don’t extend their paint area beyond their layout box. If you need a shadow that falls outside, draw it elsewhere.
- Class not found. Flutter < 3.13. Upgrade or fall back to decorated
Containers inside each item.
Related widgets
DecoratedBox— box equivalent.Container— convenience widget that wrapsDecoratedBox.SliverPadding— pair for insets.
Official docs
Part 60
Overlap
The absorber/injector pair that glues NestedScrollView together.
SliverOverlapAbsorber
TL;DR: Wraps a sliver in the outer header of a NestedScrollView and tells that scroll view “this much of me is pinned on top — make sure the inner body accounts for it”. Pairs with a SliverOverlapInjector on the inner side that applies the reported amount as top padding.
If you have a NestedScrollView with a pinned SliverAppBar and the first item of each tab hides under the app bar, you are missing this pair. Adding them is the fix.
What is it?
SliverOverlapAbsorber is half of the two-piece bridge that lets a NestedScrollView’s outer and inner scroll positions stay visually aligned. It does two things:
- Wraps a sliver in the outer header so the outer scroll sees it normally.
- Reports the “pinned amount” (how much of that sliver is still painting after consuming its scroll extent) to a
SliverOverlapAbsorberHandle.
The matching SliverOverlapInjector inside each tab reads the handle and adds that many pixels of invisible padding at the top of the tab’s content, so the first row of the tab is never hidden under the pinned outer header.
Why you need it
A NestedScrollView has two scroll positions. The outer one owns the collapsing header; the inner one owns the tab body. They are coordinated so the user feels one scroll, but they are not the same scroll. When the outer SliverAppBar is pinned, it draws over the top of the inner body’s viewport. Without a bridge, the inner body doesn’t know the top N pixels of its viewport are obscured — so its first list item is hidden underneath.
SliverOverlapAbsorber measures how much the outer header is “obstructing” and the injector fixes up the inner body’s padding to match.
Constructor & key parameters
const SliverOverlapAbsorber({
Key? key,
required SliverOverlapAbsorberHandle handle,
Widget? sliver,
})
| Parameter | What it does |
|---|---|
handle | A SliverOverlapAbsorberHandle. For a NestedScrollView, you get this from NestedScrollView.sliverOverlapAbsorberHandleFor(context). |
sliver | The outer sliver being absorbed — typically a SliverAppBar. |
A handle can be attached to one absorber at a time. If you see an assertion about multiple writers, you have two absorbers sharing a handle.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: ProfileScreen());
}
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title: const Text('Profile'),
pinned: true,
expandedHeight: 200,
forceElevated: innerBoxIsScrolled,
bottom: const TabBar(
tabs: <Widget>[Tab(text: 'Posts'), Tab(text: 'Likes')],
),
),
),
];
},
body: const TabBarView(
children: <Widget>[_Feed('Post'), _Feed('Like')],
),
),
),
);
}
}
class _Feed extends StatelessWidget {
const _Feed(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Builder(
builder: (BuildContext context) {
return CustomScrollView(
key: PageStorageKey<String>(label),
slivers: <Widget>[
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
SliverList.builder(
itemCount: 40,
itemBuilder: (_, i) => ListTile(title: Text('$label $i')),
),
],
);
},
);
}
}
This is the canonical recipe. Memorise it: absorber in the header, injector in each tab, same handle fetched from NestedScrollView.sliverOverlapAbsorberHandleFor(context).
Real-world use cases
- Every
NestedScrollViewwith a pinnedSliverAppBar. - Any tabbed screen where the outer header obscures the top of the inner body.
When to use it
- ✅ You are inside
NestedScrollView.headerSliverBuilder. - ✅ The wrapped sliver is pinned or has
maxScrollObstructionExtent > 0. - ✅ You have a matching
SliverOverlapInjectoron the inner side.
When NOT to use it
- ❌ You are in a plain
CustomScrollView— this class is specifically forNestedScrollView. - ❌ The header sliver is not pinned and doesn’t obstruct anything — there is nothing to absorb.
- ❌ You are wrapping multiple slivers in one absorber — use one absorber per pinned sliver, each with its own handle. (In practice, wrap just the
SliverAppBar.)
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
| Manually adding top padding inside each tab’s first item | Brittle; padding doesn’t update when the app bar collapses. | SliverOverlapAbsorber + SliverOverlapInjector. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Always pair with a matching injector inside each tab. | Don’t share one handle across multiple absorbers — assertion fails. |
Fetch the handle with NestedScrollView.sliverOverlapAbsorberHandleFor(context) from inside the builder. | Don’t construct a new SliverOverlapAbsorberHandle() and pass it manually — the NestedScrollView won’t know about it. |
Common pitfalls
- Handle returns null / wrong handle. You called
sliverOverlapAbsorberHandleFor(context)from a context that is not inside theNestedScrollView. Wrap your inner builder in aBuilderso it has the right context. - First tab item hidden under the header. You forgot the injector inside the tab.
- Works on tab A but not tab B. Both tabs need their own injector. Double-check both.
- Assertion: “A SliverOverlapAbsorberHandle cannot be passed to multiple RenderSliverOverlapAbsorber objects”. You created two absorbers with the same handle. Either use one absorber or fetch separate handles.
Related widgets
SliverOverlapInjector— the receiver on the inner side.NestedScrollView— the owner of the handle.SliverAppBar— the typical absorbed sliver.
Official docs
- https://api.flutter.dev/flutter/widgets/SliverOverlapAbsorber-class.html
- https://api.flutter.dev/flutter/widgets/SliverOverlapAbsorberHandle-class.html
SliverOverlapInjector
TL;DR: The “receiver” half of the NestedScrollView overlap bridge. You put this as the first sliver inside each tab’s CustomScrollView in a NestedScrollView, and it adds invisible top space equal to whatever the outer SliverOverlapAbsorber reported. Paired with the absorber on the outer side.
What is it?
SliverOverlapInjector reads a SliverOverlapAbsorberHandle and, during layout, reports a SliverGeometry whose scrollExtent and layoutExtent equal the handle’s current absorbed overlap. Visually, it is a sliver that takes up the same amount of vertical space as the outer pinned header occupies — so the first real content inside the tab begins just below the pinned header, not underneath it.
It has no child content of its own. It is pure layout padding driven by the handle.
Mental model
Outer SliverAppBar is pinned; it draws 56px at the top of the viewport.
Absorber in outer header reports: "absorbed 56px"
Handle now stores: 56px
Injector inside tab reads handle: "I should take 56px of scroll"
→ tab's list starts 56px below the top of its viewport
When the outer app bar collapses, the “absorbed” number changes, and the injector follows.
Constructor & key parameters
const SliverOverlapInjector({
Key? key,
required SliverOverlapAbsorberHandle handle,
Widget? sliver,
})
| Parameter | What it does |
|---|---|
handle | The same SliverOverlapAbsorberHandle used by the absorber. Fetch with NestedScrollView.sliverOverlapAbsorberHandleFor(context). |
sliver | Optional. If set, the injector wraps the given sliver. Usually omitted — the injector is placed as a standalone first sliver. |
Minimal example
Inside a NestedScrollView’s tab body, the first sliver should always be a SliverOverlapInjector when the outer header has a pinned component. See SliverOverlapAbsorber for the full end-to-end example.
CustomScrollView(
key: const PageStorageKey<String>('posts'),
slivers: <Widget>[
// THIS — the first sliver in every tab inside NestedScrollView.
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
SliverList.builder(
itemCount: 40,
itemBuilder: (_, i) => ListTile(title: Text('Post $i')),
),
],
)
That single SliverOverlapInjector line at the top of each tab is the entire difference between “first row hides under the app bar” and “first row sits perfectly below it”.
Real-world use cases
- Every tab inside a
NestedScrollViewthat has a pinned outer header.
When to use it
- ✅ You are building a tab’s
CustomScrollViewinside aNestedScrollViewwith a pinned outer header. - ✅ The first sliver of the tab.
- ✅ Exactly one per tab, using the same handle fetched from the
NestedScrollViewcontext.
When NOT to use it
- ❌ The outer header has no pinning at all — nothing to inject, handle reports 0.
- ❌ You are not inside
NestedScrollView— this class only makes sense there. - ❌ You want to add other padding — use
SliverPadding(the injector is not a padding primitive).
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
| Hard-coded top padding on the tab’s first item | Brittle; wrong when the header height changes. | SliverOverlapInjector reads the current value. |
Dos and don’ts
| Do | Don’t |
|---|---|
Place it as the very first sliver in each tab’s CustomScrollView. | Don’t wrap it in SliverPadding — it already is a layout-only sliver. |
Fetch the handle from the tab’s own BuildContext. | Don’t pass a new handle; it must come from the NestedScrollView. |
Common pitfalls
- “First item is still hidden under the app bar.” Either the injector is missing from that tab, or it is using a different handle than the absorber. Fetch both from
NestedScrollView.sliverOverlapAbsorberHandleFor(context)— they must match. - Injector causes a big empty space even when the header is scrolled away. When the header is no longer pinned, the absorbed value is 0 and the injector takes no space. If you see empty space, some other sliver is adding it.
- Works on the first tab but not subsequent tabs. Each tab needs its own injector. You cannot share one.
Related widgets
SliverOverlapAbsorber— the sender.NestedScrollView— the owner.
Official docs
Part 70
Animated & Reorderable
Animating insertions, removals, and drag-to-reorder lists and grids.
SliverAnimatedList
TL;DR: A SliverList that animates items when they are inserted or removed. You control it through a SliverAnimatedListState — either via a GlobalKey or with the static SliverAnimatedList.of(context) helper.
What is it?
SliverAnimatedList is the sliver version of AnimatedList. It does not hold a List<T> internally. Instead, you tell it “the item count started at N”, and then, every time you insert or remove an item, you also call insertItem(index) or removeItem(index, builder) on its state so it can animate the change. You maintain the underlying data list yourself.
That split — data in your state, count in the sliver’s state, animations driven by calls — is what makes the API feel unusual at first. Once you have the rhythm, it becomes natural.
Mental model
your List<T> ─┐
│ when adding:
│ 1. add to your list
│ 2. listKey.currentState.insertItem(newIndex)
│
│ when removing:
│ 1. grab the removed value
│ 2. remove from your list
│ 3. listKey.currentState.removeItem(index, (ctx, anim) => buildTombstone(removedValue, anim))
▼
SliverAnimatedList
│
▼
itemBuilder(ctx, index, animation)
← the animation is 0→1 on insert, 1→0 on removal
The itemBuilder takes an Animation<double> so you can fade, slide, or scale the new/old item.
Constructor & key parameters
const SliverAnimatedList({
Key? key,
required AnimatedItemBuilder itemBuilder,
ChildIndexGetter? findChildIndexCallback,
int initialItemCount = 0,
})
| Parameter | What it does |
|---|---|
itemBuilder | (BuildContext context, int index, Animation<double> animation) => Widget. Builds item at index with the provided animation. |
initialItemCount | The number of items at first build. Must match your data list’s length. |
findChildIndexCallback | Optional; helps recycle widgets when items move. |
You also need a GlobalKey<SliverAnimatedListState> or use SliverAnimatedList.of(context) from an item’s callback.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: AnimatedListScreen());
}
class AnimatedListScreen extends StatefulWidget {
const AnimatedListScreen({super.key});
@override
State<AnimatedListScreen> createState() => _AnimatedListScreenState();
}
class _AnimatedListScreenState extends State<AnimatedListScreen> {
final List<String> _items = <String>['Apple', 'Banana', 'Cherry'];
final GlobalKey<SliverAnimatedListState> _key = GlobalKey<SliverAnimatedListState>();
void _add() {
final int newIndex = _items.length;
_items.add('Fruit #$newIndex');
_key.currentState?.insertItem(newIndex, duration: const Duration(milliseconds: 350));
}
void _removeAt(int index) {
final String removed = _items.removeAt(index);
_key.currentState?.removeItem(
index,
(BuildContext context, Animation<double> animation) {
return SizeTransition(
sizeFactor: animation,
child: FadeTransition(
opacity: animation,
child: ListTile(title: Text(removed)),
),
);
},
duration: const Duration(milliseconds: 350),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _add,
child: const Icon(Icons.add),
),
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverAnimatedList'), pinned: true),
SliverAnimatedList(
key: _key,
initialItemCount: _items.length,
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
return SizeTransition(
sizeFactor: animation,
child: ListTile(
title: Text(_items[index]),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _removeAt(index),
),
),
);
},
),
],
),
);
}
}
Tap the FAB to add items (they slide in). Tap the trash icons to remove them (they slide out). The data list and the sliver state are kept in sync by the pair of calls you make on every change.
Real-world use cases
- Chat messages being added or deleted with a smooth reveal.
- A cart list where items animate out when removed.
- A to-do list with insert/remove animations tied to user actions.
- Live feeds where incoming items gracefully push older ones down.
When to use it
- ✅ You want insert/remove animations on a list that lives in a
CustomScrollView. - ✅ You control the underlying data list and can call
insertItem/removeItemon every change.
When NOT to use it
- ❌ Items never insert or remove — use
SliverList. - ❌ You want to animate only order changes (reorder) — use
SliverReorderableList. - ❌ You want to animate item appearance one time on first build — add an entrance animation inside each item’s build method instead.
- ❌ You cannot reliably call
insertItem/removeItemon every data change (e.g., the data comes from an external stream you don’t control). Consider rebuilding a plainSliverListand accepting no animation.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
AnimatedList inside a SliverToBoxAdapter | Breaks laziness and sliver layout. | SliverAnimatedList directly. |
Hand-rolled fade/slide wrappers per item with AnimatedSize | Brittle, no coordination with removal. | SliverAnimatedList handles insert and remove. |
Dos and don’ts
| Do | Don’t |
|---|---|
Keep your data list and the sliver’s insertItem / removeItem calls in lockstep. | Don’t forget to update your data list before calling insertItem — the builder will ask for the new index immediately. |
Build the removal’s tombstone inside the removeItem callback using the captured value. | Don’t read from the mutated data list inside the remove builder — the item is already gone. |
Common pitfalls
- “RangeError (index): Invalid value” inside the builder. You forgot to update the data list before calling
insertItem. - Removed items snap away with no animation. You forgot to pass a
durationor the builder returned a zero-sized widget. - GlobalKey warning. Each
SliverAnimatedListneeds its ownGlobalKey<SliverAnimatedListState>. Don’t share across instances.
Related widgets
AnimatedList— box version.SliverList— non-animated version.SliverAnimatedGrid— grid equivalent.SliverReorderableList— reorder animations.
Official docs
- https://api.flutter.dev/flutter/widgets/SliverAnimatedList-class.html
- https://api.flutter.dev/flutter/widgets/SliverAnimatedListState-class.html
SliverAnimatedGrid
TL;DR: A SliverGrid that animates items when they are inserted or removed. Same API shape as SliverAnimatedList, but two-dimensional and requires a SliverGridDelegate.
What is it?
SliverAnimatedGrid is the grid version of SliverAnimatedList. You maintain your data list separately and call insertItem(index) or removeItem(index, builder) on the sliver’s state to animate changes. Each item is built with an Animation<double> argument so you can fade, scale, or slide it.
The only differences from SliverAnimatedList are:
- It requires a
gridDelegateto describe cell layout. - Its state type is
SliverAnimatedGridState.
Everything else is identical.
Mental model
your List<T> ─┐
│ mutation calls:
│ insertItem(index)
│ removeItem(index, builder)
▼
SliverAnimatedGrid(gridDelegate, itemBuilder)
│
▼
gridDelegate → shape of each cell
itemBuilder(ctx, index, animation) → cell content
Constructor & key parameters
const SliverAnimatedGrid({
Key? key,
required AnimatedItemBuilder itemBuilder,
required SliverGridDelegate gridDelegate,
ChildIndexGetter? findChildIndexCallback,
int initialItemCount = 0,
})
| Parameter | What it does |
|---|---|
itemBuilder | (BuildContext, int index, Animation<double>) => Widget. |
gridDelegate | Grid layout; same options as SliverGrid. |
initialItemCount | Number of cells at first build. |
findChildIndexCallback | Optional; helps widget recycling. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: AnimatedGridScreen());
}
class AnimatedGridScreen extends StatefulWidget {
const AnimatedGridScreen({super.key});
@override
State<AnimatedGridScreen> createState() => _AnimatedGridScreenState();
}
class _AnimatedGridScreenState extends State<AnimatedGridScreen> {
final List<int> _items = List<int>.generate(8, (int i) => i);
final GlobalKey<SliverAnimatedGridState> _key = GlobalKey<SliverAnimatedGridState>();
void _add() {
final int newIndex = _items.length;
_items.add(newIndex);
_key.currentState?.insertItem(newIndex, duration: const Duration(milliseconds: 350));
}
void _removeAt(int index) {
final int removed = _items.removeAt(index);
_key.currentState?.removeItem(
index,
(BuildContext context, Animation<double> animation) {
return ScaleTransition(
scale: animation,
child: _Cell(value: removed),
);
},
duration: const Duration(milliseconds: 350),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _add,
child: const Icon(Icons.add),
),
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverAnimatedGrid'), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverAnimatedGrid(
key: _key,
initialItemCount: _items.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1,
),
itemBuilder: (BuildContext context, int index, Animation<double> animation) {
return ScaleTransition(
scale: animation,
child: GestureDetector(
onTap: () => _removeAt(index),
child: _Cell(value: _items[index]),
),
);
},
),
),
],
),
);
}
}
class _Cell extends StatelessWidget {
const _Cell({required this.value});
final int value;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.primaries[value % Colors.primaries.length].shade200,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: Text('$value', style: const TextStyle(fontSize: 24)),
);
}
}
Tap a cell to remove it with a scale-out animation. Tap the FAB to add a new one with scale-in.
Real-world use cases
- Emoji pickers where picking an item animates it out.
- Photo grids where deletions scale out.
- Level selectors in games where unlocking an item scales in.
When to use it
- ✅ Items are added/removed from a grid and you want animations.
- ✅ Your data is in your own state; you can call
insertItem/removeItemat mutation time.
When NOT to use it
- ❌ No insert/remove happens — use
SliverGrid. - ❌ You want to reorder cells — there is no
SliverReorderableGrid; use a list or a third-party package. - ❌ You can’t synchronize mutations with animation calls — fall back to
SliverGrid.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
AnimatedGrid inside SliverToBoxAdapter | Breaks lazy sliver layout. | SliverAnimatedGrid. |
Dos and don’ts
| Do | Don’t |
|---|---|
Mirror every data mutation with the corresponding insertItem / removeItem call. | Don’t forget to update the data list before calling insertItem. |
Use ScaleTransition, FadeTransition, or SizeTransition for intuitive item animations. | Don’t pass a zero-duration animation — the transition will snap, not animate. |
Common pitfalls
Same as SliverAnimatedList. Specifically: keep data and state calls in lockstep, and build the tombstone widget from captured values in removeItem.
Related widgets
SliverAnimatedList— 1D counterpart.SliverGrid— non-animated version.AnimatedGrid— box version.
Official docs
SliverReorderableList
TL;DR: A sliver list where the user can drag items to reorder them. You provide the itemBuilder, the itemCount, and the onReorder callback that updates your data. Each item must contain a ReorderableDragStartListener (or ReorderableDelayedDragStartListener) to indicate the drag handle.
What is it?
SliverReorderableList is the sliver version of ReorderableListView. It handles the drag gesture, the item lift animation, the auto-scroll when dragging near edges, and the drop placement. You are responsible for updating your data model when the onReorder callback fires.
Unlike SliverAnimatedList, you do not call methods on its state to trigger animations. The animations come from the drag itself. Your job is just: “when the user drops, move item oldIndex to newIndex in my data list”.
Mental model
User long-presses or taps a drag handle
→ item lifts into a draggable "proxy"
→ user drags; other items slide to make room
→ user drops
→ onReorder(oldIndex, newIndex) fires
→ you update your data list accordingly
The critical detail about newIndex: if the user drops an item after its original position, Flutter gives you a newIndex that is one greater than you might expect, because the item is removed first. The standard idiom is:
onReorder: (int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final T item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
}
Copy this pattern every time.
Constructor & key parameters
const SliverReorderableList({
Key? key,
required IndexedWidgetBuilder itemBuilder,
required int itemCount,
required ReorderCallback onReorder,
ChildIndexGetter? findChildIndexCallback,
void Function(int)? onReorderStart,
void Function(int)? onReorderEnd,
double? itemExtent,
ItemExtentBuilder? itemExtentBuilder,
Widget? prototypeItem,
ReorderItemProxyDecorator? proxyDecorator,
ReorderDragBoundaryProvider? dragBoundaryProvider,
double? autoScrollerVelocityScalar,
})
| Parameter | What it does |
|---|---|
itemBuilder | Builds item at index. Each item must have a unique Key (e.g., ValueKey(id)). |
itemCount | Total item count. |
onReorder | (int oldIndex, int newIndex) -> void. Called when the user drops an item. |
onReorderStart / onReorderEnd | Optional callbacks for the drag lifecycle. |
itemExtent / itemExtentBuilder / prototypeItem | Fix item height (pick at most one). Same meaning as in the non-reorderable lists. |
proxyDecorator | Customises the lifted item’s appearance during drag (shadow, scale, etc.). |
dragBoundaryProvider | Clamps the drag to a bounding region. |
autoScrollerVelocityScalar | How fast the list auto-scrolls when the user drags near an edge. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: ReorderScreen());
}
class ReorderScreen extends StatefulWidget {
const ReorderScreen({super.key});
@override
State<ReorderScreen> createState() => _ReorderScreenState();
}
class _ReorderScreenState extends State<ReorderScreen> {
final List<String> _items = <String>[
'Apples',
'Bananas',
'Cherries',
'Dates',
'Elderberries',
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Drag to reorder'), pinned: true),
SliverReorderableList(
itemCount: _items.length,
onReorder: (int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final String item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
},
itemBuilder: (BuildContext context, int index) {
return Material(
// Use ValueKey so reorder tracking works.
key: ValueKey<String>(_items[index]),
color: index.isEven ? Colors.white : Colors.grey.shade50,
child: ListTile(
title: Text(_items[index]),
// The drag handle triggers the reorder.
trailing: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
),
);
},
),
],
),
);
}
}
Touch and drag the drag_handle icon on the right to reorder items. Note the two things you must do: give each item a unique Key, and wrap the drag handle in a ReorderableDragStartListener.
If you prefer a long-press anywhere on the item to start the drag, wrap the whole item in ReorderableDelayedDragStartListener instead of ReorderableDragStartListener.
Real-world use cases
- To-do lists where the user sets their own order.
- Playlists in a music app.
- Settings tiles that the user can rearrange.
- Custom launcher-style screens.
When to use it
- ✅ The user must reorder items with a drag gesture.
- ✅ You are inside a
CustomScrollView(otherwise useReorderableListView). - ✅ You can give each item a unique, stable
Key.
When NOT to use it
- ❌ Reordering is driven by buttons, not drags — use
SliverListand swap items in state. - ❌ You need to animate insert/remove on top of reorder — combine with
SliverAnimatedList, or use a third-party package. - ❌ Items have no unique identity — reorder tracking will misbehave.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
ReorderableListView inside another scroll view | Only works as top-level; can’t sit next to other slivers. | SliverReorderableList inside a CustomScrollView. |
Hand-rolled Draggable + DragTarget chains | Complex, bug-prone, no auto-scroll. | SliverReorderableList. |
Dos and don’ts
| Do | Don’t |
|---|---|
Give each item a unique Key derived from a stable identifier. | Don’t use the index as a key — keys that change per reorder break the tracking. |
Use ReorderableDragStartListener for explicit drag handles. | Don’t forget the newIndex > oldIndex adjustment — it’s a classic off-by-one bug. |
Use ReorderableDelayedDragStartListener when you want long-press-to-drag on the whole row. | Don’t wrap the entire list in a drag listener — only individual handles. |
Common pitfalls
- Items jump to a wrong position after drop. You forgot the
if (newIndex > oldIndex) newIndex -= 1;adjustment. - Drag starts but nothing follows the finger. You wrapped the wrong widget in
ReorderableDragStartListener, or itsindex:doesn’t match the current item index. - Reorder looks broken after adding/removing items. Your keys are not unique or not stable. Use
ValueKey(id). - Auto-scroll is too slow/fast. Tune
autoScrollerVelocityScalar(default 50). - Assertion about
itemExtent/prototypeItem/itemExtentBuilder. You passed more than one of the three. Pick one.
Related widgets
ReorderableListView— top-level box version.SliverList— non-reorderable equivalent.ReorderableDragStartListener,ReorderableDelayedDragStartListener— the handles.SliverReorderableListState— controller accessible viaGlobalKeyto cancel drags programmatically.
Official docs
Part 80
Pull to Refresh
iOS-style pull-to-refresh as a first-class sliver.
CupertinoSliverRefreshControl
TL;DR: iOS-style pull-to-refresh as a sliver. Place it as the first sliver of a CustomScrollView and provide an onRefresh callback that returns a Future. The user drags down, the indicator appears, your future runs, the indicator retracts.
What is it?
CupertinoSliverRefreshControl is the native-iOS-feeling refresh control. Unlike Android’s RefreshIndicator (which wraps the whole scroll view as a box), the Cupertino refresh control is itself a sliver. It consumes a little scroll space at the top of the CustomScrollView when the user overscrolls.
It does not rely on any Material ancestor and fits inside a CupertinoPageScaffold naturally, but it also works fine inside a Scaffold — it’s just a sliver.
Mental model
User pulls down → overscroll increases
↓
Drag beyond refreshTriggerPullDistance (default 100px)
↓
onRefresh() is called, returning a Future
↓
Sliver holds open at refreshIndicatorExtent (default 60px)
↓
Future resolves
↓
Sliver retracts to 0
It is entirely driven by the user’s drag; there is no “show refresh programmatically” API.
Constructor & key parameters
const CupertinoSliverRefreshControl({
Key? key,
double refreshTriggerPullDistance = 100.0,
double refreshIndicatorExtent = 60.0,
RefreshControlIndicatorBuilder? builder = buildRefreshIndicator,
RefreshCallback? onRefresh,
})
| Parameter | What it does |
|---|---|
refreshTriggerPullDistance | How far the user must pull to trigger a refresh. Must be > 0 and >= refreshIndicatorExtent. |
refreshIndicatorExtent | How much space the sliver holds while refreshing. Set to 0 to retract immediately. |
builder | Builds the indicator. Defaults to the standard iOS activity indicator. |
onRefresh | Returns a Future. The sliver stays open until the future completes. If null, the refresh completes immediately. |
Minimal example
import 'package:flutter/cupertino.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const CupertinoApp(home: RefreshScreen());
}
class RefreshScreen extends StatefulWidget {
const RefreshScreen({super.key});
@override
State<RefreshScreen> createState() => _RefreshScreenState();
}
class _RefreshScreenState extends State<RefreshScreen> {
List<String> _items = List<String>.generate(20, (int i) => 'Item $i');
Future<void> _refresh() async {
await Future<void>.delayed(const Duration(seconds: 2));
setState(() {
_items = List<String>.generate(20, (int i) => 'Refreshed item $i');
});
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
child: CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: <Widget>[
const CupertinoSliverNavigationBar(largeTitle: Text('Feed')),
CupertinoSliverRefreshControl(
onRefresh: _refresh,
),
SliverList.builder(
itemCount: _items.length,
itemBuilder: (_, i) => CupertinoListTile(title: Text(_items[i])),
),
],
),
);
}
}
Pull the list down: the iOS activity indicator appears, _refresh is called, and two seconds later the list updates. Notice the physics: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) — this is required for the control to actually react to drags on short content.
Real-world use cases
- Refreshing a Cupertino feed or list.
- Refreshing tab content in an iOS-first app.
- Apps that want iOS-feeling refresh even on Android.
When to use it
- ✅ You want iOS-style pull-to-refresh inside a
CustomScrollView. - ✅ You can express the refresh as an
asyncfunction returningFuture. - ✅ You want to place the refresh indicator inline with the slivers, not as an outer wrapper.
When NOT to use it
- ❌ You want Material-style pull-to-refresh — use
RefreshIndicatorwrapping theCustomScrollView. (It works fine with slivers, setphysics: AlwaysScrollableScrollPhysics().) - ❌ You need to trigger refresh programmatically — this control only reacts to user drags.
- ❌ You have more than one refresh control in the same scroll — don’t. Only one.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
Wrapping a CupertinoPageScaffold in a RefreshIndicator | Mixes visual languages; Material ring on iOS. | CupertinoSliverRefreshControl. |
Dos and don’ts
| Do | Don’t |
|---|---|
Place it as the first sliver, just after the CupertinoSliverNavigationBar. | Don’t place it below other slivers — it will only trigger when its own position overscrolls. |
Use BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) so short lists can still be pulled. | Don’t use NeverScrollableScrollPhysics — the control never triggers. |
Make onRefresh complete even on failure (wrap in try/finally) — otherwise the indicator stays open forever. | Don’t keep a reference to the state; reach for standard state management for the refreshed data. |
Common pitfalls
- Pull does nothing. Your physics doesn’t allow overscroll on short content. Switch to
BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()). - Indicator stays forever. Your
onRefreshfuture never completes. Add atimeoutor make sure all error paths resolve. - Indicator appears mid-list. You placed the sliver after other slivers. Move it to the top.
- Indicator is Material-styled. Make sure you are using
CupertinoSliverRefreshControl, notRefreshIndicator.
Related widgets
RefreshIndicator— Material equivalent (wraps as a box, works withCustomScrollView).CupertinoSliverNavigationBar— common sibling.CustomScrollView— required host.
Official docs
Part 90
Delegates
The supplier objects: child, grid, and persistent-header delegates.
SliverChildDelegate (abstract base)
TL;DR: The abstract contract every sliver child delegate implements. You don’t use this class directly — you use one of its two concrete subclasses, SliverChildBuilderDelegate (lazy) or SliverChildListDelegate (eager). This page explains the contract so you know what every sliver child delegate can do.
What is it?
SliverChildDelegate is the abstract base class that tells a sliver how to obtain its children. It has five methods/getters that matter:
Widget? build(BuildContext context, int index);
int? get estimatedChildCount;
double? estimateMaxScrollOffset(int firstIndex, int lastIndex, double leadingScrollOffset, double trailingScrollOffset);
void didFinishLayout(int firstIndex, int lastIndex);
bool shouldRebuild(covariant SliverChildDelegate oldDelegate);
int? findIndexByKey(Key key);
Slivers like SliverList, SliverGrid, SliverFixedExtentList, SliverPrototypeExtentList, and SliverVariedExtentList all accept any SliverChildDelegate. That is the power of the abstraction: one contract, many consumers. Write a custom subclass once, and you can plug it into all of them.
Why it is abstract
The base class exists so Flutter can swap in different strategies for supplying children:
- Lazy —
SliverChildBuilderDelegatecallsbuild(context, i)only for visible indices. Theint? childCountis optional; ifnull, the sliver keeps asking the builder until it returnsnull. - Eager —
SliverChildListDelegateholds a concreteList<Widget>and returns them by index. - Custom — you can subclass directly if neither existing strategy fits. Rare.
The five methods in plain words
Widget? build(BuildContext context, int index)
Return the child at index. Return null if you don’t have a child for that index (which effectively signals “end of list” to the sliver).
int? get estimatedChildCount
How many children there are, if known. null means “unknown / infinite”. The sliver uses this to size its scrollbar thumb accurately.
double? estimateMaxScrollOffset(...)
An optional hint to the sliver about “given what you’ve built so far, what is a good estimate of the total scroll extent?”. The default returns null, which lets the sliver extrapolate from existing measurements. Override only if you can give a precise answer cheaply.
void didFinishLayout(int firstIndex, int lastIndex)
Called after the sliver finishes one layout pass. Useful for custom telemetry (“which items were in view after scroll?”). Most delegates ignore it.
bool shouldRebuild(SliverChildDelegate oldDelegate)
Return true if the new delegate has different content than the old one, false otherwise. Lets the sliver skip unnecessary work.
int? findIndexByKey(Key key)
Given the Key of a child that used to be at some index, find its current index. This is how Flutter recycles existing element state when items are inserted or removed in the middle of a list. Override this whenever items can move.
Writing your own delegate (rare)
You usually don’t need to. Here is what it looks like, in case you do:
class MyDelegate extends SliverChildDelegate {
MyDelegate({required this.items});
final List<String> items;
@override
Widget? build(BuildContext context, int index) {
if (index < 0 || index >= items.length) return null;
return ListTile(title: Text(items[index]));
}
@override
int get estimatedChildCount => items.length;
@override
bool shouldRebuild(MyDelegate oldDelegate) => oldDelegate.items != items;
}
Then use it like any other delegate:
SliverList(delegate: MyDelegate(items: _items))
There is almost no reason to do this — SliverChildBuilderDelegate is already a thin wrapper around the same idea and handles the boilerplate (RepaintBoundary, AutomaticKeepAlive, IndexedSemantics).
When to care about this page at all
- ✅ You are writing a custom sliver and need to understand what a delegate promises.
- ✅ You are debugging a “list doesn’t update” problem and want to understand
shouldRebuild. - ✅ You want to know why
SliverList,SliverGrid, andSliverFixedExtentListall take the same kind of argument.
When to skip it
- ❌ You just want to use
SliverList.builderorSliverGrid.builder— skip this page and readSliverChildBuilderDelegateinstead.
Related
SliverChildBuilderDelegate— the lazy concrete subclass.SliverChildListDelegate— the eager concrete subclass.- Chapter 06. Delegates explained — the big-picture tutorial.
Official docs
SliverChildBuilderDelegate
TL;DR: The lazy concrete SliverChildDelegate. Takes a builder callback and, optionally, a childCount. Used by virtually every real-world sliver list and grid. This is the one you will use 95% of the time.
What is it?
SliverChildBuilderDelegate calls your builder function only for children that are visible in the viewport (plus a small cache). It never builds children you can’t see. The result is “infinite-ish” list performance: a 10,000-item list launches instantly.
It also wraps each child in three helpful wrappers by default:
AutomaticKeepAlive— lets items opt into keep-alive viaAutomaticKeepAliveClientMixin.RepaintBoundary— a redraw of one item doesn’t redraw its siblings.IndexedSemantics— accessibility tools can identify each item’s position in the list.
You can disable any of these via constructor flags.
Mental model
Viewport needs children for indices 42..58
→ sliver calls builder(context, 42), builder(context, 43), ...
→ sliver DOES NOT call builder(context, 0..41) or builder(context, 59..childCount-1)
→ when the user scrolls, old indices are disposed, new ones built
Constructor & key parameters
const SliverChildBuilderDelegate(
NullableIndexedWidgetBuilder builder, {
ChildIndexGetter? findChildIndexCallback,
int? childCount,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
SemanticIndexCallback semanticIndexCallback = /* identity */,
int semanticIndexOffset = 0,
})
| Parameter | What it does |
|---|---|
builder | (BuildContext, int) => Widget?. Return the child at the given index. Return null to signal “no more items” (only when childCount is null). |
childCount | Total number of items if known; null means infinite. Always pass this when you can — it improves scrollbar sizing and prevents infinite builds. |
findChildIndexCallback | (Key key) => int?. Maps a child’s key back to its current index. Needed when items insert or reorder mid-list so Flutter can reuse element state. |
addAutomaticKeepAlives | Wrap each child in AutomaticKeepAlive. Defaults true. |
addRepaintBoundaries | Wrap each child in RepaintBoundary. Defaults true. |
addSemanticIndexes | Wrap each child in IndexedSemantics. Defaults true. |
semanticIndexCallback, semanticIndexOffset | Customise how the accessibility index is computed (for lists with separators, or multiple delegates in one scroll). |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverChildBuilderDelegate')),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(
leading: CircleAvatar(child: Text('$index')),
title: Text('Row $index'),
);
},
childCount: 1000,
),
),
],
),
),
);
}
}
1000 rows; only the visible ones (plus cache) are ever built. Memory usage is proportional to the viewport size, not the list length.
Real-world use cases
- Every
SliverList.buildercall wraps aSliverChildBuilderDelegateunder the hood. - Every
SliverGrid.buildercall does the same. - The default choice for any list longer than ~20 items.
When to use it
- ✅ Your list has more than ~20 items.
- ✅ The list is infinite or its length varies.
- ✅ You want laziness (you almost always do).
When NOT to use it
- ❌ You have a short, fixed list of children — use
SliverChildListDelegate. Simpler.
Dos and don’ts
| Do | Don’t |
|---|---|
Always pass childCount when you know it. | Don’t pass null childCount and a builder that never returns null — you’ll build forever. |
Pass findChildIndexCallback when items can insert or reorder mid-list. | Don’t rely on the index for identity — use keys derived from your data. |
Use semanticIndexOffset when multiple delegates share one scroll view’s semantics. | Don’t disable addRepaintBoundaries unless items are extremely cheap to repaint. |
Common pitfalls
- “Range error” or “null” at edge indices. Your builder returns a widget for out-of-range indices. Always guard with
if (index >= items.length) return null;when childCount is null. - State disappears when scrolled away. That is the design — disposed items are rebuilt fresh. Lift state up or use
AutomaticKeepAliveClientMixinon individual items. - Items “jump” when inserting at the top. Pass a
findChildIndexCallbackso existing elements find their new index. - Infinite build loop. Your builder returns a non-null zero-sized widget forever and
childCountis null. Provide achildCountor returnnullat the end.
Related
SliverChildDelegate— the abstract base.SliverChildListDelegate— eager alternative.SliverList,SliverGrid— common consumers.
Official docs
SliverChildListDelegate
TL;DR: The eager concrete SliverChildDelegate. Takes a List<Widget> and returns them by index. Use only for short, fixed lists where you would otherwise write out the children by hand.
What is it?
SliverChildListDelegate is the “I already have all my children ready” delegate. You pass a List<Widget> and the delegate returns them at the right indices. It does not lazy-build; all the widgets in the list exist immediately.
There are two constructors:
SliverChildListDelegate(List<Widget> children, ...)— default. Tracks child keys in a map for reorder support.SliverChildListDelegate.fixed(List<Widget> children, ...)— constant version. Use when the list never changes order or contents. Allowsconstconstruction, which is cheaper.
Mental model
children: [A, B, C, D]
delegate.build(ctx, 0) → A
delegate.build(ctx, 1) → B
...
All four widgets already exist in memory. No laziness, no builder callbacks.
Constructor & key parameters
SliverChildListDelegate(
List<Widget> children, {
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
SemanticIndexCallback semanticIndexCallback = /* identity */,
int semanticIndexOffset = 0,
})
const SliverChildListDelegate.fixed(
List<Widget> children, {
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
SemanticIndexCallback semanticIndexCallback = /* identity */,
int semanticIndexOffset = 0,
})
| Parameter | What it does |
|---|---|
children | The eager list of child widgets. |
| Other flags | Same meaning as on SliverChildBuilderDelegate. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverChildListDelegate')),
SliverList(
delegate: SliverChildListDelegate(<Widget>[
const ListTile(title: Text('Apples')),
const ListTile(title: Text('Bananas')),
const ListTile(title: Text('Cherries')),
const ListTile(title: Text('Dates')),
]),
),
],
),
),
);
}
}
Four fixed tiles. Perfect use case — you would have written them by hand in a Column anyway, but now they live inside a real sliver.
Real-world use cases
- A settings screen with a handful of fixed entries, one per delegate.
- The “about” section with a few lines of static text.
- A short list of action tiles at the top of a longer builder list.
- Any list where
List.generatefeels wrong.
When to use it
- ✅ The list has fewer than ~20 items.
- ✅ All items exist up front; no lazy generation.
- ✅ You would otherwise hand-code every item anyway.
When NOT to use it
- ❌ The list is long — use
SliverChildBuilderDelegate. Eager building of 500+ items will slow down your screen startup. - ❌ Items come from dynamic data that is regenerated often — the delegate holds references to every widget at once.
Dos and don’ts
| Do | Don’t |
|---|---|
Use SliverChildListDelegate.fixed for constant lists to enable const. | Don’t use it for long lists. You lose laziness. |
Put Keys on items if you need reorder support. | Don’t pass the same list instance expecting diffing — the delegate compares by reference. |
Common pitfalls
- List doesn’t update when I mutate it. Widgets in Flutter are immutable from the framework’s perspective. Create a new
List<Widget>instead of mutating the old one. - Memory usage higher than expected. Because all children exist in memory. Switch to the builder delegate.
Related
SliverChildBuilderDelegate— lazy alternative.SliverChildDelegate— the abstract base.
Official docs
SliverGridDelegate (abstract base)
TL;DR: The abstract contract that describes how a SliverGrid should size and position its tiles. You don’t use this class directly — you use one of its two concrete subclasses. This page explains the contract so you understand both.
What is it?
SliverGridDelegate has exactly two methods:
SliverGridLayout getLayout(SliverConstraints constraints);
bool shouldRelayout(covariant SliverGridDelegate oldDelegate);
That is the entire surface area. Given the current SliverConstraints (which carries the viewport’s cross-axis extent and scroll direction), the delegate returns a SliverGridLayout — an object that tells the grid how many tiles there are per row, how wide and tall each tile is, and where they sit.
Two built-in subclasses cover 99% of use cases:
SliverGridDelegateWithFixedCrossAxisCount— “exactly N columns”.SliverGridDelegateWithMaxCrossAxisExtent— “as many columns as fit, each at most this wide”.
Both produce a SliverGridRegularTileLayout, which is the standard equally-sized, equally-spaced grid.
Why it is abstract
A grid could lay its tiles out in any pattern — equal sizes, Pinterest-style staggered, hexagonal, diamond. Flutter’s built-in delegates do equal grids; if you want something else, you subclass SliverGridDelegate, implement getLayout, and return a custom SliverGridLayout.
In practice this is rare. For staggered layouts, use the flutter_staggered_grid_view package instead of rolling your own.
What getLayout returns
A SliverGridLayout has methods like:
int getMinChildIndexForScrollOffset(double scrollOffset)— which tile is the first one visible at this scroll?int getMaxChildIndexForScrollOffset(double scrollOffset)— which tile is the last one visible?SliverGridGeometry getGeometryForChildIndex(int index)— where and how big is this tile?double computeMaxScrollOffset(int childCount)— total scroll length.
These methods are what the grid sliver calls internally. You almost never touch them directly; the concrete delegates provide them via SliverGridRegularTileLayout.
When to care about this page at all
- ✅ You want to understand why
SliverGridtakes both agridDelegateand adelegate— they describe where and what, respectively. - ✅ You are debugging unexpected grid sizing.
- ✅ You are writing a truly custom grid layout (rare).
When to skip it
- ❌ You just want to build a normal grid — jump straight to
SliverGridDelegateWithFixedCrossAxisCountorSliverGridDelegateWithMaxCrossAxisExtent.
Related
SliverGridDelegateWithFixedCrossAxisCount— fixed columns.SliverGridDelegateWithMaxCrossAxisExtent— responsive columns.SliverGrid— the consumer.- Chapter 06. Delegates explained.
Official docs
SliverGridDelegateWithFixedCrossAxisCount
TL;DR: Grid layout with a fixed number of columns (or rows, in horizontal scroll). Tiles are equally sized and equally spaced. The default choice for “give me a 2×N / 3×N / 4×N grid”.
What is it?
This is the “I know exactly how many columns I want” delegate. Pass crossAxisCount: 3 and you get three equally-sized tiles per row. Pass crossAxisCount: 2 and you get two. The tile width is computed from the viewport width divided by the count, minus any cross-axis spacing.
The tile’s height is computed via one of two properties:
childAspectRatio(default1.0) — tile height = tile width /childAspectRatio.1.0gives square tiles.mainAxisExtent— explicit tile height in pixels, ignoringchildAspectRatio.
You pick one. mainAxisExtent is usually easier to reason about (“tiles are 120 tall regardless of width”), while childAspectRatio is useful for “tiles always look like 3:4 photos”.
Constructor & key parameters
const SliverGridDelegateWithFixedCrossAxisCount({
required int crossAxisCount,
double mainAxisSpacing = 0.0,
double crossAxisSpacing = 0.0,
double childAspectRatio = 1.0,
double? mainAxisExtent,
})
| Parameter | What it does |
|---|---|
crossAxisCount | Number of columns (vertical scroll) or rows (horizontal). Must be > 0. |
mainAxisSpacing | Space between tiles along the scroll axis. |
crossAxisSpacing | Space between tiles across the scroll axis. |
childAspectRatio | crossAxisExtent / mainAxisExtent of each tile. Defaults 1.0 (square). |
mainAxisExtent | Optional explicit height (or width in horizontal scrolls). Overrides childAspectRatio. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('3-column grid')),
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
mainAxisExtent: 120, // explicit tile height
),
itemCount: 60,
itemBuilder: (_, i) => Container(
decoration: BoxDecoration(
color: Colors.primaries[i % Colors.primaries.length].shade200,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: Text('$i'),
),
),
),
],
),
),
);
}
}
Always 3 columns, 120 px tall tiles, 8 px gaps.
Real-world use cases
- Photo grids with a fixed column count.
- Dashboards with 2×N / 4×N tile layouts.
- Emoji pickers, colour pickers, category grids.
- Any design that specifies “exactly 3 columns”.
When to use it
- ✅ The design specifies a fixed column count.
- ✅ You don’t care about responsive reflow to 4 or 6 columns on tablets/desktops.
- ✅ Tile dimensions follow from the column count.
When NOT to use it
- ❌ You want responsive reflow — use
SliverGridDelegateWithMaxCrossAxisExtentinstead. - ❌ Tiles have wildly different sizes — this delegate enforces equal sizing.
Dos and don’ts
| Do | Don’t |
|---|---|
Use mainAxisExtent when you want a specific pixel height. | Don’t fight childAspectRatio to achieve a specific height — use mainAxisExtent. |
Combine with SliverPadding for outer margins. | Don’t add per-item margins to fake spacing — use mainAxisSpacing and crossAxisSpacing. |
Common pitfalls
- Tiles are tall and narrow on landscape/tablet. The column count is fixed; the tile width grows with the viewport, and the height follows
childAspectRatio. UsemainAxisExtentto lock the height. - Tiles look cramped near the edges. You forgot the outer
SliverPadding. - Different tile sizes ignored. All tiles are forced to the delegate-computed size. Use a staggered grid package if you need variation.
Related
SliverGridDelegateWithMaxCrossAxisExtent— responsive alternative.SliverGridDelegate— abstract base.SliverGrid— the consumer.
Official docs
SliverGridDelegateWithMaxCrossAxisExtent
TL;DR: Responsive grid layout. “Fit as many columns as possible, but no tile may be wider than maxCrossAxisExtent.” On a phone you get 2 columns, on a tablet 4, on a desktop 6 — all without any media query code.
What is it?
This is the grid delegate for apps that want to look good on every device width. Instead of specifying a column count, you specify a maximum tile width. The delegate does the division: “viewport is 800 px wide, max is 180 px → 5 columns at 160 px each (with spacing)”. The columns automatically reflow when the window resizes.
Like the fixed-count delegate, it produces equally-sized, equally-spaced tiles. The height is controlled by childAspectRatio or the optional mainAxisExtent.
Mental model
viewport width = 1000
maxCrossAxisExtent = 200
→ ceil(1000 / 200) = 5 columns
→ each column is 1000 / 5 = 200 (minus spacing) wide
If the viewport narrows to 500, the grid automatically becomes 3 columns of ~166 px each. No code change; no rebuild logic.
Constructor & key parameters
const SliverGridDelegateWithMaxCrossAxisExtent({
required double maxCrossAxisExtent,
double mainAxisSpacing = 0.0,
double crossAxisSpacing = 0.0,
double childAspectRatio = 1.0,
double? mainAxisExtent,
})
| Parameter | What it does |
|---|---|
maxCrossAxisExtent | Maximum allowed tile width. Delegate picks the smallest number of columns needed so each tile is at most this wide. Must be > 0. |
mainAxisSpacing, crossAxisSpacing | Gaps between tiles. |
childAspectRatio | Tile width / height ratio. Defaults 1.0. |
mainAxisExtent | Optional explicit height, overriding childAspectRatio. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('Responsive grid')),
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1,
),
itemCount: 60,
itemBuilder: (_, i) => Container(
decoration: BoxDecoration(
color: Colors.teal.shade200,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: Text('$i'),
),
),
),
],
),
),
);
}
}
Resize the window in a desktop build: the column count changes as you drag. Same on a tablet in portrait vs landscape.
Real-world use cases
- Product catalogues that should look right on phone / tablet / desktop.
- Photo galleries that adapt to window size.
- Dashboards with tiles that flow differently depending on device.
- Any design described as “tiles around 200 dp wide”.
When to use it
- ✅ You need responsive column counts.
- ✅ You care more about “approximate tile size” than “exact column count”.
- ✅ Your app targets multiple device sizes.
When NOT to use it
- ❌ The design says “exactly 3 columns” — use
SliverGridDelegateWithFixedCrossAxisCount. - ❌ You need Pinterest-style variable tile sizes — use a staggered grid package.
Dos and don’ts
| Do | Don’t |
|---|---|
Pick maxCrossAxisExtent based on the design’s intended tile size (not too small — the grid will pack too many columns on wide screens). | Don’t set maxCrossAxisExtent to a huge value like 2000 — the grid will always have 1 column. |
Combine with childAspectRatio or mainAxisExtent for tile height control. | Don’t forget outer spacing via SliverPadding. |
Common pitfalls
- Only 1 column on every screen. Your
maxCrossAxisExtentis larger than the viewport. Lower it. - Tiles too small on wide screens. The delegate makes the tiles at most
maxCrossAxisExtentwide. If you want them exactly that size, accept the empty gutter or wrap the whole grid inSliverConstrainedCrossAxis. - Tiles uneven at the edge of the screen. They aren’t — each tile is
usable / columns. Check your spacing math.
Related
SliverGridDelegateWithFixedCrossAxisCount— fixed alternative.SliverGridDelegate— abstract base.SliverGrid— the consumer.
Official docs
SliverPersistentHeaderDelegate (abstract)
TL;DR: The abstract contract for a custom collapsing/pinning/floating header. You subclass it to describe minExtent, maxExtent, and a build(shrinkOffset) method. Pair with a SliverPersistentHeader widget. This is the lowest-level header API in Flutter.
What is it?
SliverPersistentHeaderDelegate is what powers SliverAppBar, SliverPersistentHeader, and any custom header you write. The delegate answers three questions:
- How small can I be? →
minExtent - How big can I be? →
maxExtent - What do I look like at a given
shrinkOffset? →build(context, shrinkOffset, overlapsContent)
shrinkOffset is a number in [0, maxExtent - minExtent]. When it is 0, the header is fully expanded. When it equals maxExtent - minExtent, the header is fully collapsed. You use that number to interpolate colours, sizes, opacities, and layouts.
The abstract shape
abstract class SliverPersistentHeaderDelegate {
const SliverPersistentHeaderDelegate();
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent);
double get minExtent;
double get maxExtent;
TickerProvider? get vsync => null;
FloatingHeaderSnapConfiguration? get snapConfiguration => null;
OverScrollHeaderStretchConfiguration? get stretchConfiguration => null;
PersistentHeaderShowOnScreenConfiguration? get showOnScreenConfiguration => null;
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);
}
| Member | What it does |
|---|---|
build | Called whenever the header needs to be drawn. Receives current shrinkOffset and a flag overlapsContent telling you whether other slivers are painting behind your sliver (common inside NestedScrollView). |
minExtent | The collapsed height. Must be <= maxExtent. Must be constant across the delegate’s lifetime. |
maxExtent | The expanded height. Must be >= minExtent. |
vsync | Required when the header is floating and has a snap or show-on-screen config. |
snapConfiguration | Tunes how a floating header snaps in/out of view. |
stretchConfiguration | Tunes overscroll-stretch behaviour. |
showOnScreenConfiguration | Tunes Scrollable.ensureVisible behaviour for focus traversal. |
shouldRebuild | Return true when a new delegate has different inputs than the old one — otherwise the header won’t update. |
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverPersistentHeader(
pinned: true,
delegate: _FadeHeader(title: 'Shrinking header'),
),
SliverList.builder(
itemCount: 50,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
],
),
),
);
}
}
class _FadeHeader extends SliverPersistentHeaderDelegate {
_FadeHeader({required this.title});
final String title;
@override
double get minExtent => 72;
@override
double get maxExtent => 220;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
final double t = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0);
return Container(
color: Color.lerp(Colors.indigo, Colors.deepPurple.shade900, t),
alignment: Alignment.lerp(Alignment.bottomLeft, Alignment.centerLeft, t),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: 32 - 14 * t,
fontWeight: FontWeight.w600,
),
),
);
}
@override
bool shouldRebuild(_FadeHeader oldDelegate) => oldDelegate.title != title;
}
The header starts at 220 px, collapses to 72 px, darkens as it shrinks, and centres its title when pinned.
shouldRebuild — the most common bug
If your header’s delegate depends on any state (a title, a colour, a theme), shouldRebuild must return true when that state changes. Returning false unconditionally is a frequent mistake; it makes the header look stale after every state change.
@override
bool shouldRebuild(_MyHeader oldDelegate) =>
oldDelegate.title != title || oldDelegate.color != color;
Real-world use cases
- Custom-shaped section headers with unique interpolation.
- Headers that swap content as they collapse (a row of icons becomes a single label).
- Gradient headers that crossfade between palettes.
- Inline sticky labels that do more than just stay put.
When to use it
- ✅ You need collapsing/pinning behaviour that
SliverAppBarorSliverResizingHeadercan’t express. - ✅ You need fine-grained access to
shrinkOffsetto interpolate visual properties.
When NOT to use it
- ❌ You want a normal Material app bar — use
SliverAppBar. - ❌ You want a resizing header whose extremes are two widget prototypes — use
SliverResizingHeader. - ❌ You want a fixed-size pinned widget — use
PinnedHeaderSliver.
Dos and don’ts
| Do | Don’t |
|---|---|
Always override shouldRebuild correctly. | Don’t return false from it if the delegate can change. |
Keep minExtent and maxExtent constant for the life of a delegate instance. | Don’t compute them from shrinkOffset or from state that changes — create a new delegate instead. |
Use Tween/lerp inside build to interpolate. | Don’t allocate heavy objects on every frame — pre-compute outside build when possible. |
Common pitfalls
- Header doesn’t update when data changes.
shouldRebuildreturnedfalse. Fix the comparison. - Assertion: minExtent > maxExtent. Swap them.
- Floating snap doesn’t work. You need a non-null
vsyncand asnapConfiguration. SeeFloatingHeaderSnapConfiguration.
Related
SliverPersistentHeader— the widget that hosts the delegate.SliverAppBar— a Material-styled delegate consumer.- Chapter 06. Delegates explained.
Official docs
Part 95
Advanced & Rarely-Seen
Layout builders, semantics, and ensuring accessibility across long scroll ranges.
SliverLayoutBuilder
TL;DR: The sliver version of LayoutBuilder. Its builder callback receives the current SliverConstraints, so you can return a different sliver based on the scroll offset, remaining paint extent, cross-axis extent, or axis direction. Useful for “build a smaller sliver once the user has scrolled past X” style logic.
What is it?
SliverLayoutBuilder defers building its child sliver until layout time. At that point, it calls the builder function with the SliverConstraints the viewport handed it, and you return whichever sliver makes sense right now. The returned sliver is used for this layout pass.
This is the escape hatch when you need to know the current scroll state inside the sliver tree without running your own scroll listener.
Mental model
Viewport hands down SliverConstraints
→ SliverLayoutBuilder.builder(context, constraints) runs
→ returns a sliver based on the constraints
→ that sliver is laid out as if it were directly in the CustomScrollView
The builder runs every layout pass. That is cheap, but not free — keep the work inside it small.
Constructor & key parameters
const SliverLayoutBuilder({
Key? key,
required Widget Function(BuildContext context, SliverConstraints constraints) builder,
})
| Parameter | What it does |
|---|---|
builder | Receives the current SliverConstraints and returns a sliver widget. |
The SliverConstraints argument exposes:
axisDirection,growthDirectionscrollOffsetprecedingScrollExtentremainingPaintExtent,remainingCacheExtentviewportMainAxisExtentcrossAxisExtentoverlap
You rarely use more than two or three of these per builder.
Minimal example
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show SliverConstraints;
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverLayoutBuilder'), pinned: true),
// Show different banners depending on the remaining viewport space.
SliverLayoutBuilder(
builder: (BuildContext context, SliverConstraints constraints) {
final bool wide = constraints.crossAxisExtent > 600;
return SliverToBoxAdapter(
child: Container(
height: wide ? 160 : 80,
color: wide ? Colors.indigo.shade200 : Colors.indigo.shade100,
alignment: Alignment.center,
child: Text(
wide ? 'Desktop banner' : 'Mobile banner',
style: const TextStyle(fontSize: 20),
),
),
);
},
),
SliverList.builder(
itemCount: 50,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
],
),
),
);
}
}
On a narrow window you get the 80 px mobile banner; resize to 600+ px and you get the 160 px desktop banner. No MediaQuery needed — the sliver asks its own constraints.
Real-world use cases
- Swapping a compact vs expanded layout based on current cross-axis extent.
- Showing a “keep scrolling” hint that disappears once the user has scrolled past a certain offset.
- Rendering a loading placeholder until
constraints.remainingPaintExtentis above some threshold. - Building a dev-tools overlay that reports live sliver constraints for debugging.
When to use it
- ✅ You need the current
SliverConstraintsinside the sliver tree. - ✅ You want to return different slivers depending on scroll state without writing a scroll listener.
- ✅ The decision is cheap to make every layout pass.
When NOT to use it
- ❌ You just need the viewport width — use
MediaQueryorLayoutBuilderoutside the sliver tree. - ❌ The decision depends on something besides the constraints (state, a Future) — make the decision in the parent and pass the result in.
- ❌ You want to animate a transition between the two slivers — there is no crossfade; each layout pass returns one sliver.
What it replaces well
| Old approach | Why it hurts | Replace with |
|---|---|---|
A scroll listener that calls setState to swap slivers | Expensive rebuilds of the whole screen. | SliverLayoutBuilder; only the sub-sliver rebuilds. |
Dos and don’ts
| Do | Don’t |
|---|---|
| Keep the builder cheap — it runs every layout. | Don’t do network requests or compute large data inside the builder. |
Return a const sliver when possible. | Don’t allocate heavy objects per pass. |
Common pitfalls
- Infinite layout loop. The builder returns a sliver whose layout changes the constraints, which makes the builder return a different sliver, and so on. Break the loop by making the decision depend on incoming constraints only, not on what the child would return.
- Flickering. The builder returns a different sliver on every tiny scroll change. Pick a hysteresis threshold.
SliverConstraintsnot imported. Addimport 'package:flutter/rendering.dart' show SliverConstraints;if your IDE doesn’t auto-import it.
Related
LayoutBuilder— box version.SliverPersistentHeader— the delegate’sshrinkOffsetis a more focused version of the same idea for headers.
Official docs
SliverSemantics
TL;DR: The sliver version of Semantics. Annotates its sliver subtree with accessibility metadata — labels, roles, hints, actions — that screen readers and other assistive technologies read out.
What is it?
SliverSemantics attaches semantic properties to a sliver subtree. It is the plumbing that makes screen readers describe your list, grid, or header correctly. For example:
- Mark a list section as a “list” container.
- Provide a human-readable label for a collapsing header.
- Exclude decorative subtrees from the semantics tree.
- Provide actions (tap, scroll, increase, decrease) to be used by assistive tools.
It is the same API surface as Semantics — the difference is that the child is a sliver, not a box, so it fits inside a CustomScrollView.
Mental model
slivers:
├── SliverAppBar(...)
├── SliverSemantics(
│ label: 'Search results',
│ container: true,
│ sliver: SliverList(...),
│ )
└── SliverToBoxAdapter(...)
Screen readers now announce the list as “Search results” instead of a generic, unnamed collection.
Constructor & key parameters
SliverSemantics has many parameters — all of them mirror Semantics. You rarely set more than a handful:
SliverSemantics({
Key? key,
required Widget sliver,
bool container = false,
bool explicitChildNodes = false,
bool excludeSemantics = false,
bool blockUserActions = false,
// ... many more: label, value, role, actions, properties ...
})
const SliverSemantics.fromProperties({
Key? key,
Widget? child,
bool container = false,
bool explicitChildNodes = false,
bool excludeSemantics = false,
bool blockUserActions = false,
Locale? localeForSubtree,
required SemanticsProperties properties,
})
The most common handful:
| Parameter | What it does |
|---|---|
sliver | The sliver subtree being annotated. |
container | If true, creates a new semantics node (wraps children in a named container). |
label | Human-readable description read by screen readers. |
hint | Extra hint like “Double-tap to open”. |
excludeSemantics | If true, strips the sliver subtree from the semantics tree entirely. Good for decorative sections. |
blockUserActions | Disables user actions (tap, scroll) inside the subtree for assistive tools. |
onTap, onLongPress, etc. | Semantic action callbacks. |
The .fromProperties constructor takes a pre-built SemanticsProperties object. Use it when you are generating the properties dynamically.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverSemantics')),
SliverSemantics(
container: true,
label: 'Top stories',
sliver: SliverList.builder(
itemCount: 5,
itemBuilder: (_, i) => ListTile(title: Text('Story $i')),
),
),
SliverSemantics(
container: true,
label: 'Recommended reading',
sliver: SliverList.builder(
itemCount: 5,
itemBuilder: (_, i) => ListTile(title: Text('Recommendation $i')),
),
),
SliverSemantics(
excludeSemantics: true,
sliver: SliverToBoxAdapter(
child: Image.network('https://picsum.photos/300/100'),
),
),
],
),
),
);
}
}
Screen readers will announce the two labelled sections by name, skip over the decorative image entirely, and read list items in order.
Real-world use cases
- Grouping a feed into named sections for screen readers.
- Marking decorative images or banners as excluded from semantics.
- Providing
hinttext on custom sliver headers that are tappable. - Making a complex collapsing header announce its state clearly.
When to use it
- ✅ You care about accessibility.
- ✅ The default semantics tree does not convey intent (e.g., your list is unnamed).
- ✅ You want to exclude decorative slivers from assistive tools.
When NOT to use it
- ❌ Your sliver’s children already provide good semantics (Material widgets mostly do).
- ❌ You are setting
container: trueon every sliver — don’t over-group; it hurts navigation.
Dos and don’ts
| Do | Don’t |
|---|---|
Set container: true when you want a named group around a section. | Don’t wrap individual list items — wrap the section. |
Use excludeSemantics: true for purely decorative slivers. | Don’t hide important content from screen readers. |
| Test with TalkBack (Android) and VoiceOver (iOS) before shipping. | Don’t assume the semantics tree is correct without testing. |
Common pitfalls
- Semantics doesn’t show up. You forgot
container: truewhen setting alabel. Labels without containers attach to existing semantic nodes, which may be ambiguous. - Duplicate announcements. Multiple wrappers each create a semantic node. Use one wrapper per logical group.
- Accessibility tools traverse weirdly. Use the
SemanticsDebuggeroverlay during development to visualise the semantics tree.
Related
Semantics— box version, same properties.SliverEnsureSemantics— forces a sliver to stay in the semantics tree even when scrolled out of cache.MergeSemantics,ExcludeSemantics— helpers used inside the subtree.
Official docs
SliverEnsureSemantics
TL;DR: Forces its sliver child to remain in the semantics tree even when it is scrolled out of the current viewport and cache extent. Useful so that assistive tools like screen readers can still discover and navigate to off-screen content.
What is it?
Normally, slivers that are far off-screen are not painted and are not included in the semantics tree — they don’t exist as far as accessibility is concerned. For typical lists this is fine: a screen reader only reads what is currently visible.
But sometimes you want a specific sliver (a heading, a fixed section, a pinned action) to always be discoverable by assistive tools, even when it has scrolled out of view. SliverEnsureSemantics does that. It tells the viewport “always include this sliver in the semantics tree”, without forcing it to be laid out or painted.
This class was added in Flutter 3.16.
Mental model
Normal sliver at scroll offset 3000, viewport at 500:
→ not painted
→ not in semantics tree
Wrapped in SliverEnsureSemantics:
→ not painted
→ BUT included in the semantics tree
→ screen reader can still find it
Constructor & key parameters
const SliverEnsureSemantics({
Key? key,
required Widget sliver,
})
One parameter — the sliver to keep semantics-visible.
Minimal example
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverAppBar(title: Text('SliverEnsureSemantics'), pinned: true),
SliverEnsureSemantics(
sliver: SliverToBoxAdapter(
child: Semantics(
header: true,
label: 'Results heading',
child: Container(
color: Colors.indigo.shade100,
padding: const EdgeInsets.all(16),
child: const Text('Results', style: TextStyle(fontSize: 20)),
),
),
),
),
SliverFixedExtentList.builder(
itemExtent: 56,
itemCount: 1000,
itemBuilder: (_, i) => ListTile(title: Text('Row $i')),
),
],
),
),
);
}
}
Even after scrolling past row 500, a screen reader can still navigate back to the “Results” heading — it remains a reachable landmark.
An important caveat
The Flutter docs warn: when SliverEnsureSemantics is placed after lazy slivers (like SliverList), the scroll extent reported to assistive tools may be inaccurate, because the lazy slivers cannot report their total extent precisely before items are built. As a result, assistive tools might fail to scroll to the wrapped content.
The recommendation is: for content that needs SliverEnsureSemantics, use slivers whose extent is known up front:
SliverFixedExtentListSliverVariedExtentList(with a precomputeditemExtentBuilder)SliverPrototypeExtentListSliverGridwith a delegate that knows its extent (SliverGridDelegateWithFixedCrossAxisCount,SliverGridDelegateWithMaxCrossAxisExtent)
The example above uses SliverFixedExtentList for exactly this reason.
Real-world use cases
- Landmark headings in long documents (“Introduction”, “References”) that screen readers should always be able to navigate to.
- Section labels in a long feed that should be reachable via the screen-reader rotor.
- Critical actions (like “Add to cart”) that should be discoverable even before the user scrolls to them.
When to use it
- ✅ You want specific slivers to be reachable by assistive tools regardless of scroll position.
- ✅ The slivers before the ensured one have known extents.
- ✅ You are on Flutter 3.16 or later.
When NOT to use it
- ❌ You want the sliver to be painted off-screen — that is not what this does.
- ❌ The content isn’t important for assistive navigation — wasted work.
- ❌ Slivers before the ensured one are lazy with unknown extents — the scroll-to-landmark behaviour breaks.
Dos and don’ts
| Do | Don’t |
|---|---|
Pair with extent-known slivers (SliverFixedExtentList, etc.) above the ensured sliver. | Don’t wrap every sliver — only the ones that need landmark semantics. |
Add proper Semantics properties to the wrapped content (label, header, etc.). | Don’t use this for visual effects — it has none. |
Common pitfalls
- Assistive tool scrolls to the wrong place. There is a lazy sliver with unknown extent between the viewport and the ensured sliver. Replace it with an extent-known variant.
- Nothing changes visibly. That is expected;
SliverEnsureSemanticsonly affects the semantics tree. - Class not found. Flutter < 3.16. Upgrade or use plain
Semanticsinside the sliver tree with the knowledge that off-screen content isn’t reachable.
Related
SliverSemantics— the general semantic annotation.Semantics— box-level annotation.
Official docs
Part 99
Reference
Cheat sheet and glossary — keep these open while building.
Cheat sheet — which sliver should I use?
A one-page decision guide. Pair this with the Glossary for jargon.
”I have a list / grid / tree. Which producer?”
| Need | Sliver |
|---|---|
| A vertical list where items have different heights | SliverList |
| A vertical list where every item has the same height (known in pixels) | SliverFixedExtentList |
| A list where height varies but you can compute it from index without building | SliverVariedExtentList |
| A list where every item should match a prototype widget’s size | SliverPrototypeExtentList |
| A grid of tiles (responsive or fixed columns) | SliverGrid |
| A single non-sliver widget (banner, hero, footer) | SliverToBoxAdapter |
Full-screen pages inside a CustomScrollView | SliverFillViewport |
| ”Fill leftover space” for empty / loading states | SliverFillRemaining |
| An expandable tree view | TreeSliver |
”I want a header. Which one?”
| Need | Sliver |
|---|---|
| Material app bar that collapses / pins / floats | SliverAppBar |
| iOS-style large-title nav bar | CupertinoSliverNavigationBar |
| Pinned fixed-size widget (no collapse) | PinnedHeaderSliver |
| Pinned header that resizes between two prototype widget sizes | SliverResizingHeader |
| Header that hides on scroll-up and snaps in on scroll-down | SliverFloatingHeader |
| Custom collapse/pin/float with your own interpolation | SliverPersistentHeader + custom delegate |
| Section-scoped pinned headers in a long list | SliverMainAxisGroup + pinned header inside each group |
”I want to compose multiple slivers.”
| Need | Sliver |
|---|---|
| Stack slivers along the scroll axis so pinning is scoped to a section | SliverMainAxisGroup |
| Place slivers side by side along the cross axis | SliverCrossAxisGroup |
| Flex one column inside a cross-axis group | SliverCrossAxisExpanded |
| Fix a column’s cross-axis width | SliverConstrainedCrossAxis |
”I want to control layout, padding, safety.”
| Need | Sliver |
|---|---|
| Add margin around a sliver | SliverPadding |
| Avoid system UI (status bar, notch) | SliverSafeArea |
| Cap a sliver’s cross-axis width on wide screens | SliverConstrainedCrossAxis |
”I want to fade / hide / show / decorate.”
| Need | Sliver |
|---|---|
| Static partial opacity | SliverOpacity |
| Opacity animated by a state change | SliverAnimatedOpacity |
Opacity driven by an AnimationController | SliverFadeTransition |
| Toggle visibility (on/off, cheap) | SliverVisibility |
| Keep in tree but hide from layout (animations still run) | SliverOffstage |
| Disable taps but keep visible | SliverIgnorePointer |
| Background / border / gradient behind a sliver | DecoratedSliver |
”I want animated insert / remove / reorder.”
| Need | Sliver |
|---|---|
| Animate items in/out of a list | SliverAnimatedList |
| Animate items in/out of a grid | SliverAnimatedGrid |
| Drag-to-reorder | SliverReorderableList |
”I need pull-to-refresh.”
| Need | Sliver / Widget |
|---|---|
| Material pull-to-refresh | Wrap CustomScrollView in RefreshIndicator with physics: AlwaysScrollableScrollPhysics() |
| iOS-style pull-to-refresh | CupertinoSliverRefreshControl as the first sliver |
”I’m using NestedScrollView and the first item hides under the app bar.”
Add the overlap bridge:
- In the header:
SliverOverlapAbsorber(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar(...)) - As the first sliver of each tab:
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context))
See SliverOverlapAbsorber and SliverOverlapInjector.
”Do I even need slivers?”
Walk the decision tree in chapter 05. Quick version:
- One flat list or grid →
ListView/GridView, no slivers. - Mixed-behaviour scroll (header + list + grid, or pinning/collapsing) →
CustomScrollView+ slivers. - Collapsing header + tabs with independent scroll positions →
NestedScrollView.
Sliver-by-layer summary
Host scroll view CustomScrollView, NestedScrollView
├── Producers Sliver{,Fixed,Varied,Prototype}ExtentList,
│ SliverGrid, SliverToBoxAdapter,
│ SliverFill{Viewport,Remaining}, TreeSliver
├── Headers SliverAppBar, CupertinoSliverNavigationBar,
│ SliverPersistentHeader, PinnedHeaderSliver,
│ SliverResizingHeader, SliverFloatingHeader
├── Layout SliverPadding, SliverSafeArea,
│ SliverConstrainedCrossAxis
├── Composition SliverMainAxisGroup, SliverCrossAxisGroup,
│ SliverCrossAxisExpanded
├── Effects Sliver{,Animated}Opacity, SliverFadeTransition,
│ SliverVisibility, SliverOffstage,
│ SliverIgnorePointer, DecoratedSliver
├── Overlap SliverOverlapAbsorber, SliverOverlapInjector
├── Animated SliverAnimatedList, SliverAnimatedGrid,
│ SliverReorderableList
├── Refresh CupertinoSliverRefreshControl
├── Delegates SliverChildBuilderDelegate, SliverChildListDelegate,
│ SliverGridDelegateWith{FixedCrossAxisCount,
│ MaxCrossAxisExtent}, SliverPersistentHeaderDelegate
└── Advanced SliverLayoutBuilder, SliverSemantics,
SliverEnsureSemantics
Five one-line rules to live by
- Inside
CustomScrollView.slivers, only slivers live. UseSliverToBoxAdapterfor a single box; useSliverList/SliverGridfor many. - Uniform-height lists deserve
SliverFixedExtentList. It’s measurably faster thanSliverListfor long lists. - Pinned
SliverAppBarinNestedScrollViewneeds the absorber/injector pair. Always. Memorise the recipe. shouldRebuildis your best friend and worst enemy in aSliverPersistentHeaderDelegate. Returntruewhen your data changes.- Don’t use
shrinkWrap: trueto nest scrolls. That is the universal flag that says: “this should have been aCustomScrollViewwith slivers”.
Glossary of sliver terms
Plain-language definitions of every sliver-adjacent term you will encounter in the Flutter docs, error messages, and this book.
Core concepts
Viewport — The rectangular window on screen through which scrollable content is seen. Implemented by RenderViewport. Hosts slivers and is driven by a ScrollPosition.
Sliver — A scrollable “behaviour” that knows how to lay itself out one slice at a time as it enters and leaves the viewport. Slivers live only inside a viewport and speak SliverConstraints, not BoxConstraints.
Box — Every other widget in Flutter. Boxes have BoxConstraints (min/max width and height) and produce fixed rectangles.
ScrollView — A widget that creates a Viewport. The common ones are ListView, GridView, SingleChildScrollView, CustomScrollView, and NestedScrollView. Only CustomScrollView and NestedScrollView let you write slivers directly.
ScrollPosition — The live scroll state for one Scrollable. Exposed via ScrollController.position. Contains pixels, maxScrollExtent, viewportDimension, etc.
ScrollController — An object that attaches to a scroll view and lets you read its position or drive it (jumpTo, animateTo). Must be created in initState and disposed in dispose.
ScrollPhysics — The “feel” of the scroll. BouncingScrollPhysics (iOS), ClampingScrollPhysics (Android), AlwaysScrollableScrollPhysics (overscrollable even on short content), NeverScrollableScrollPhysics (frozen).
Layout math
SliverConstraints — The data the viewport hands to a sliver during layout. Includes axisDirection, scrollOffset, remainingPaintExtent, viewportMainAxisExtent, crossAxisExtent, overlap, and friends.
SliverGeometry — The data a sliver returns to the viewport during layout. Includes scrollExtent, paintExtent, layoutExtent, maxPaintExtent, paintOrigin, hitTestExtent, visible, and hasVisualOverflow.
scrollOffset — How far the user has scrolled into a sliver’s local coordinate space. Measured in logical pixels.
scrollExtent — How tall (or wide) a sliver would be if fully laid out, ignoring scrolling. Used for scrollbar sizing.
paintExtent — How much of the viewport a sliver paints right now. Can be smaller than layoutExtent (e.g., for pinned headers).
layoutExtent — How much of the scroll a sliver consumes. The next sliver starts at precedingScrollExtent + layoutExtent.
maxPaintExtent — The largest paint extent this sliver could ever have; used to size the scrollbar thumb correctly.
paintOrigin — Offset from the top of the sliver’s scroll position to where painting actually begins. Non-zero values are how pinning and floating decouple visual and scroll positions.
cacheExtent — Extra pixels of layout work performed around the visible window so that items just off-screen are already built when the user scrolls into them. Default is 250 logical pixels.
remainingPaintExtent — How much visible space is still available for the current sliver. The sliver should never report a paintExtent larger than this.
overlap — How much of the current sliver’s paint area overlaps with pinned content above it. Used by SliverOverlapAbsorber/SliverOverlapInjector inside NestedScrollView.
precedingScrollExtent — The total scrollExtent of all slivers before this one. Useful when a sliver wants to know “where am I in the overall scroll”.
AxisDirection — down, up, right, left. The direction items are added as scrolling proceeds.
GrowthDirection — Whether items are being added forward or backward from the center. Usually forward; you rarely change it.
hitTestExtent — How much of the sliver’s area responds to pointer events. Defaults to paintExtent.
Delegates
SliverChildDelegate — Abstract base that tells a sliver how to get its children. Concrete subclasses: SliverChildBuilderDelegate (lazy) and SliverChildListDelegate (eager).
SliverChildBuilderDelegate — The lazy delegate. Calls a builder on demand. Use for anything more than ~20 items.
SliverChildListDelegate — The eager delegate. Holds a List<Widget>. Use only for short, fixed lists.
SliverGridDelegate — Abstract base for grid layout. Concrete subclasses: SliverGridDelegateWithFixedCrossAxisCount and SliverGridDelegateWithMaxCrossAxisExtent.
SliverPersistentHeaderDelegate — Abstract base for custom collapsing / pinning / floating headers. You override minExtent, maxExtent, build(shrinkOffset), and shouldRebuild.
shouldRebuild — A method on every delegate that returns true when the new delegate has different content than the old one. Return true whenever your data has changed, or the sliver won’t update.
findChildIndexCallback — An optional hook on SliverChildBuilderDelegate that maps a Key back to its current index. Needed when items insert or move mid-list.
Header-specific
shrinkOffset — Inside a SliverPersistentHeaderDelegate.build, the number of pixels by which the header has shrunk from maxExtent toward minExtent. 0 when fully expanded, maxExtent - minExtent when fully collapsed.
overlapsContent — A flag passed to SliverPersistentHeaderDelegate.build. true when other slivers are painting behind this one (common inside NestedScrollView).
Pinned — A header that stays glued to the top of the viewport after its scroll extent has been consumed. Achieved by reporting a paintExtent larger than its remaining layoutExtent.
Floating — A header that reappears as soon as the user scrolls down, even if the list is not at the top.
Snap — A floating header whose reappearance snaps to its fully-open size in one step.
Stretch — A header that grows beyond its maxExtent during overscroll (iOS-style bouncing), often used for parallax hero images.
SliverOverlapAbsorberHandle — A ChangeNotifier owned by NestedScrollView that carries the “absorbed overlap” between the outer SliverOverlapAbsorber and the inner SliverOverlapInjector.
Accessibility
Semantics tree — A parallel tree of nodes describing the UI to assistive tools (screen readers, voice control). Populated from widgets by Semantics / SliverSemantics / default widget behaviour.
SemanticsProperties — A bag of properties (label, hint, button, slider, actions…) passed to Semantics.fromProperties or SliverSemantics.fromProperties.
IndexedSemantics — A wrapper that tags a child with a stable position index, so assistive tools can read “row 3 of 100”. SliverChildBuilderDelegate adds this automatically.
AutomaticKeepAlive / KeepAlive — Widgets that let a list item opt into being kept in memory even when scrolled offscreen. Used via AutomaticKeepAliveClientMixin.wantKeepAlive.
Rendering primitives (you rarely touch these)
RenderSliver — The render-object base class for every sliver. If you ever write a custom sliver, you subclass this.
RenderViewport — The render object behind Viewport, responsible for asking each sliver its geometry and compositing them.
RenderSliverMultiBoxAdaptor — Base class for slivers that manage many box children lazily. RenderSliverList, RenderSliverGrid, RenderSliverFixedExtentList, etc. all extend it.
SliverGridLayout — Returned by a SliverGridDelegate.getLayout. Describes where each tile sits and how big it is.
SliverGridRegularTileLayout — The concrete SliverGridLayout used by both built-in grid delegates for equal tile sizing.
SliverPhysicalParentData / SliverLogicalParentData — Parent-data classes that slivers attach to their children during layout. Only matters if you write a custom multi-child sliver.
Common error phrases
“A RenderBox object was given an infinite size during layout” — You put a box directly in a slivers: list or a sliver inside a box parent. See chapter 07, pitfall #1.
“RenderViewport does not support returning intrinsic dimensions” — A parent is asking the scroll view for an intrinsic size. See chapter 07, pitfall #2.
“A SliverOverlapAbsorberHandle cannot be passed to multiple RenderSliverOverlapAbsorber objects” — Two absorbers are sharing one handle. Use one absorber or fetch separate handles.
“type ‘X’ is not a subtype of type ‘SliverPhysicalContainerParentData’” — You used SliverCrossAxisExpanded outside a SliverCrossAxisGroup.
Version hints
SliverVariedExtentList,SliverMainAxisGroup,SliverCrossAxisGroup,SliverCrossAxisExpanded,SliverConstrainedCrossAxis,SliverResizingHeader,SliverFloatingHeader,PinnedHeaderSliver,DecoratedSliver,TreeSliver— all Flutter 3.13+.SliverEnsureSemantics,SliverSemantics— Flutter 3.16+.- Everything else is available in any recent stable Flutter.
This book is cut against Flutter 3.41.6 stable (Dart 3.11.4).