← Back to blog
Flutter Internals · Part 5 of 7
September 24, 202610 min read

Slivers & CustomScrollView: Flutter's Scrolling Engine

FlutterDartInternals

Slivers & CustomScrollView

This is Part 5 of the Flutter Internals series. You've used ListView and GridView since day one. But the moment you want a collapsing app bar above a list above a grid, all scrolling as one, the simple scroll widgets fall apart — and you discover the engine beneath them: slivers.

"Sliver" sounds exotic and the API looks intimidating (SliverGeometry, SliverConstraints, delegates). It isn't, once you have the core picture. And you're well-prepared: a sliver is just a different layout protocol, one level below the box layout you know — much like the render objects we'll meet next. Let's demystify Flutter's scrolling engine.

Builds on the series so far; pairs with Fundamentals: layouts. Flutter 3.38 / Dart 3.12.


The big picture: a viewport and its slivers

Analogy — the conveyor belt. Imagine a single conveyor belt behind a window (the screen). The belt is longer than the window, so only part of it shows at once; scrolling moves the belt. Now imagine the belt is made of segments bolted end to end: a banner segment, then a hundred list-row segments, then a grid segment. They all move together as one belt.

  • The window + belt mechanism is the Viewport.
  • Each segment is a sliver.
  • The thing that arranges segments on one belt is CustomScrollView.

The key reframe: a normal ListView is a viewport with one sliver inside it (a SliverList). CustomScrollView is a viewport that lets you place many slivers on the same belt, so they scroll as a single coordinated unit. That's the whole reason slivers exist — multiple scroll effects sharing one viewport.

CustomScrollView(
  slivers: [
    SliverAppBar(title: Text('Feed'), expandedHeight: 200, pinned: true),
    SliverList(delegate: SliverChildBuilderDelegate(
      (context, i) => ListTile(title: Text('Item $i')),
      childCount: 50,
    )),
    SliverGrid.count(crossAxisCount: 2, children: gridTiles),
  ],
)

App bar, list, and grid — one belt, one scroll.


What makes a sliver different from a box

Every normal widget you know is a box: layout asks "given these width/height constraints, what size are you?" A Container, a Text, a Row — all boxes. (See Fundamentals: constraints.)

A sliver plays a different game because a scrollable segment needs to answer scroll-aware questions:

A box thinks in 2D size (width × height). A sliver thinks in 1D scroll extent: "How far do I stretch along the scroll axis? How much of me is currently visible? Where does the next sliver start?" It's layout aware of scroll position and what's on screen.

This is why you can't drop a Container directly into CustomScrollView.slivers — a box doesn't speak the sliver protocol. You wrap it:

// Adapt a normal box widget into a sliver:
SliverToBoxAdapter(child: MyBanner()),
// Or fill the remaining viewport:
SliverFillRemaining(child: EmptyState()),

The sliver protocol (one level below box constraints)

Here's the part that looks scary and is actually simple. Just as boxes exchange BoxConstraintsSize, slivers exchange SliverConstraintsSliverGeometry:

The viewport hands each sliver a SliverConstraints describing the current scroll situation:

| SliverConstraints field | Means | | --- | --- | | scrollOffset | how far this sliver has been scrolled past the leading edge | | remainingPaintExtent | how much visible space is left in the viewport | | crossAxisExtent | the width (for a vertical scroll) | | axisDirection | scroll direction |

The sliver replies with a SliverGeometry describing how it laid out:

| SliverGeometry field | Means | | --- | --- | | scrollExtent | total length this sliver occupies on the belt | | paintExtent | how much of it is visible right now | | maxPaintExtent | its full visible size when fully on screen | | layoutExtent | how much space it consumes for the next sliver's placement |

// Conceptually, every sliver answers:
SliverGeometry performLayout(SliverConstraints c) {
  // "Given I'm scrolled by c.scrollOffset with c.remainingPaintExtent left,
  //  here's my total scrollExtent and how much (paintExtent) shows now."
}

That's the entire secret. A SliverAppBar collapsing is just its paintExtent shrinking as scrollOffset grows. A pinned header is a sliver that keeps a non-zero paintExtent at the top even when scrolled. Everything fancy is this protocol responding to scroll offset.


Laziness: why sliver lists scale

A crucial performance property ties back to the efficiency rules: sliver lists build children lazily, only for what's visible (plus a small cache).

// Builds ONLY the rows currently on screen — not all 100,000:
SliverList(
  delegate: SliverChildBuilderDelegate(
    (context, index) => RowTile(data[index]),
    childCount: data.length,
  ),
)

SliverChildBuilderDelegate (the engine behind ListView.builder) calls your builder on demand as the viewport reveals items, and disposes off-screen ones. That's how Flutter scrolls a million-row list smoothly — it only ever has a screen's worth of Elements/RenderObjects alive. Using SliverList with a fixed children: list (not the builder delegate) loses this — it builds them all.


The sliver toolbox

You'll compose most screens from these:

| Sliver | Role | | --- | --- | | SliverAppBar | Collapsing/floating/pinned header — the showcase sliver | | SliverList | Lazy linear list (use a builder delegate) | | SliverGrid | Lazy grid | | SliverToBoxAdapter | Wrap a single normal (box) widget as a sliver | | SliverFillRemaining | Fill whatever viewport space is left | | SliverPadding | Add padding around a sliver (you can't use box Padding) | | SliverPersistentHeader | A custom header that can pin/float with full control | | SliverList.separated | Lazy list with separators |

SliverAppBar, the star

SliverAppBar(
  expandedHeight: 240,            // tall when expanded
  pinned: true,                   // keep the toolbar visible when collapsed
  floating: false,                // don't reappear on slight upward scroll
  flexibleSpace: FlexibleSpaceBar(
    title: const Text('Profile'),
    background: Image.network(url, fit: BoxFit.cover),
  ),
)

The pinned/floating/snap flags are just presets for how the sliver's paintExtent behaves as you scroll — exactly the protocol above. pinned: true = "never let paintExtent drop below the toolbar height."


A complete coordinated scroll

CustomScrollView(
  slivers: [
    // 1. Collapsing header
    const SliverAppBar(
      expandedHeight: 200, pinned: true,
      flexibleSpace: FlexibleSpaceBar(title: Text('Shop')),
    ),
    // 2. A one-off banner (a box, adapted)
    SliverToBoxAdapter(child: PromoBanner()),
    // 3. Padding around the next sliver
    SliverPadding(
      padding: const EdgeInsets.all(8),
      sliver: SliverGrid(
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
        delegate: SliverChildBuilderDelegate(
          (context, i) => ProductCard(products[i]),
          childCount: products.length, // lazy
        ),
      ),
    ),
    // 4. Fill any leftover space
    const SliverFillRemaining(hasScrollBody: false, child: Footer()),
  ],
)

All four slivers share one viewport and scroll together — impossible to coordinate with nested ListViews, trivial with slivers.

Anti-pattern reminder: don't nest scrollables to fake this (a ListView inside a Column inside a ListView) — it causes unbounded-height errors and double scrollbars. When multiple scrolling regions must move as one, that's a single CustomScrollView with multiple slivers.


Practice Challenges

Challenge 1 — Why a CustomScrollView? You need a collapsing image header above a long list, both scrolling together. Why can't you just stack an Image and a ListView in a Column?

Show solution

They'd be separate layout regions — the Image wouldn't collapse with the scroll and a ListView in a Column needs bounded height (error) or scrolls independently. A CustomScrollView with a SliverAppBar + SliverList puts both on one viewport/belt, so they scroll and collapse as a coordinated unit.

Challenge 2 — Adapt a box. You want a single Card banner between two slivers. Which sliver wraps it?

Show solution

SliverToBoxAdapter(child: Card(...)) — it adapts a normal box widget so it speaks the sliver protocol and can sit among other slivers.

Challenge 3 — Keep it lazy. A SliverList with children: [for (...) Tile()] is janky on a huge dataset. Fix it.

Show solution

Use a builder delegate so children build on demand:

SliverList(delegate: SliverChildBuilderDelegate(
  (context, i) => Tile(data[i]), childCount: data.length));

A fixed children: list builds everything up front; the builder delegate builds only what's visible (efficiency).

Challenge 4 — Pinned vs floating. In SliverConstraints/SliverGeometry terms, what does pinned: true do to a SliverAppBar?

Show solution

It keeps the app bar's paintExtent from dropping below the toolbar height even as scrollOffset grows — so the toolbar stays visible (pinned) at the top instead of scrolling fully away. It's a constraint on the sliver's geometry response to scroll offset.

Challenge 5 — Padding a sliver. Why can't you wrap a sliver in a normal Padding widget, and what do you use?

Show solution

Padding is a box widget and doesn't speak the sliver protocol, so it can't go in slivers: or wrap a sliver. Use SliverPadding, which adds padding while remaining a sliver.


Questions to test yourself

Q1 (basic). What is a sliver, in one sentence?

Show answer

A scrollable segment of a viewport that lays out using a scroll-aware protocol — a portion of the "conveyor belt" that a CustomScrollView places alongside other slivers so they scroll as one.

Q2 (basic). How does CustomScrollView differ from ListView?

Show answer

ListView is a viewport with one sliver (a SliverList). CustomScrollView lets you place multiple slivers (app bar, list, grid…) in one viewport so they scroll together — for coordinated, multi-effect scrolling.

Q3 (intermediate). How does sliver layout differ from box layout?

Show answer

Boxes exchange BoxConstraintsSize (2D width × height). Slivers exchange SliverConstraintsSliverGeometry (1D, scroll-aware): how far they extend along the scroll axis, how much is currently visible (paintExtent), and where the next sliver starts — layout that knows the scroll position.

Q4 (intermediate). Why do sliver lists scale to huge datasets?

Show answer

With a SliverChildBuilderDelegate, children are built lazily — only the visible ones (plus a small cache) exist as Elements/RenderObjects at a time, and off-screen ones are disposed. So memory/work stay roughly constant regardless of list length.

Q5 (advanced). Explain a collapsing SliverAppBar purely in protocol terms.

Show answer

As scrollOffset increases, the app bar sliver reduces its paintExtent (visible size) from expandedHeight down toward the toolbar height — collapsing. pinned: true clamps paintExtent at the toolbar height so it never disappears; floating lets paintExtent grow again on a slight upward scroll. It's all the sliver responding to scroll offset via its SliverGeometry.

Q6 (advanced). Why is nesting scrollables to combine regions an anti-pattern, and what replaces it?

Show answer

Nested scrollables (e.g. a ListView inside a Column inside a ListView) cause unbounded-height errors, conflicting scroll physics, and double scrollbars, and they don't coordinate. The correct approach is a single CustomScrollView with multiple slivers, which shares one viewport so all regions scroll as a unit.


Wrapping up

  • A sliver is a scrollable segment of a viewport; CustomScrollView places many slivers on one "belt" so they scroll together (a plain ListView is a viewport with one sliver).
  • Slivers use a scroll-aware protocolSliverConstraintsSliverGeometry (scroll extent, paintExtent = what's visible) — one level below box Size.
  • Lazy building via SliverChildBuilderDelegate keeps huge lists smooth (only visible children exist).
  • Toolbox: SliverAppBar (collapsing/pinned/floating = paintExtent behavior), SliverList/SliverGrid, SliverToBoxAdapter, SliverPadding, SliverFillRemaining, SliverPersistentHeader.
  • For multiple regions that must move as one, use one CustomScrollView — never nested scrollables.

In Part 6, the finale before the question bank, we go one level below widgets entirely to where size and pixels are decided: RenderObject — the layout/paint protocol and writing your own.