Flutter CustomScrollView and Slivers: The Complete Guide with Patterns and Code

Master Flutter’s CustomScrollView and slivers with patterns, code, and performance tips to build fast, flexible, sticky, and collapsible scroll UIs.

ASOasis
8 min read
Flutter CustomScrollView and Slivers: The Complete Guide with Patterns and Code

Image used for representation purposes only.

Why Slivers Matter

Flutter’s scrolling story is more than ListView and GridView. Under the hood, both are built with slivers—composable scrollable pieces that the viewport measures and paints on demand. Working directly with slivers inside a CustomScrollView gives you fine‑grained control over headers that collapse, sticky sections, mixed list/grid layouts, and high‑performance, memory‑efficient scrolling.

This guide walks through the mental model, essential slivers, common UI patterns, performance tips, and advanced tricks to ship silky‑smooth scroll experiences.

The Mental Model: Viewport, Constraints, Geometry

  • Viewport: Lays out slivers along a scroll axis (typically vertical). It asks each sliver, “How much space can you occupy given the current scroll offset?”
  • Sliver constraints: Include axis direction, scroll offset, remaining paint extent, and overlap. Slivers respond with geometry describing their painted size, cache extent, and whether they are done.
  • On‑demand work: Only visible (and near‑visible) children are built and painted, which is why large lists remain smooth when you use builder delegates.

You rarely implement render slivers yourself. Instead, compose higher‑level sliver widgets in a CustomScrollView.

CustomScrollView 101

A CustomScrollView is a ScrollView that accepts a list of slivers.

class SliverBasicsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        cacheExtent: 800.0, // prebuild a bit more for fast flings
        slivers: [
          SliverAppBar(
            pinned: true,
            expandedHeight: 160,
            flexibleSpace: const FlexibleSpaceBar(
              title: Text('Slivers Guide'),
            ),
          ),
          const SliverPadding(
            padding: EdgeInsets.all(8),
            sliver: SliverToBoxAdapter(
              child: Text('Intro section with static content'),
            ),
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => ListTile(title: Text('Item #$index')),
              childCount: 20,
            ),
          ),
          SliverGrid(
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 2,
              mainAxisSpacing: 8,
              crossAxisSpacing: 8,
              childAspectRatio: 1.1,
            ),
            delegate: SliverChildBuilderDelegate(
              (context, index) => Container(color: Colors.blue[100 * ((index % 8) + 1)]),
              childCount: 8,
            ),
          ),
          const SliverToBoxAdapter(child: SizedBox(height: 80)),
          SliverFillRemaining(
            hasScrollBody: false,
            child: Center(
              child: Text('Footer that fills leftover space'),
            ),
          ),
        ],
      ),
    );
  }
}

Essential Slivers You’ll Use All the Time

  • SliverAppBar: Collapsing headers with options like pinned, floating, snap, and stretch.
  • SliverList and SliverFixedExtentList: Vertical lists; FixedExtent is faster when rows share a fixed height.
  • SliverGrid: Grids with fixed column counts or max cross‑axis extents.
  • SliverToBoxAdapter: Wrap a single box widget (e.g., a banner) as a sliver.
  • SliverPadding and SliverSafeArea: Insets around slivers and safe‑area handling.
  • SliverFillRemaining: Expand a child to occupy leftover viewport space (great for empty states or footers).
  • SliverPersistentHeader: Build headers that can pin or float while responding to scroll.
  • SliverVisibility, SliverOpacity, SliverIgnorePointer: Control visibility and interaction without breaking sliver layout.
  • SliverAnimatedList: Animate insertions/removals in long, dynamic lists.

Collapsing, Pinned, Floating App Bars

SliverAppBar(
  pinned: true,
  floating: false,
  snap: false,
  stretch: true,
  expandedHeight: 200,
  flexibleSpace: FlexibleSpaceBar(
    title: const Text('Catalog'),
    background: Image.asset('assets/header.jpg', fit: BoxFit.cover),
  ),
)
  • pinned keeps the toolbar visible when collapsed.
  • floating makes the app bar reappear as you scroll up a bit; snap pairs with floating to animate fully into view.
  • stretch enables iOS‑style elastic stretching with FlexibleSpaceBar.

Fast, Memory‑Friendly Lists

SliverList(
  delegate: SliverChildBuilderDelegate(
    (context, index) => ProductTile(product: products[index]),
    childCount: products.length,
    addAutomaticKeepAlives: false,
    addRepaintBoundaries: true,
    addSemanticIndexes: true,
  ),
)
  • Prefer builder delegates over children: […] to avoid building everything upfront.
  • Disable addAutomaticKeepAlives if items manage their own state or don’t need it.
  • Use SliverFixedExtentList when every row height is identical for better layout performance.

Responsive Grids

SliverLayoutBuilder(
  builder: (context, constraints) {
    final crossAxisCount = constraints.crossAxisExtent ~/ 220; // simple breakpoint
    return SliverGrid(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: crossAxisCount.clamp(1, 6),
        mainAxisSpacing: 12,
        crossAxisSpacing: 12,
        childAspectRatio: 0.85,
      ),
      delegate: SliverChildBuilderDelegate(
        (context, index) => ProductCard(item: catalog[index]),
        childCount: catalog.length,
      ),
    );
  },
)

Sticky Section Headers with SliverPersistentHeader

class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
  _StickyHeaderDelegate(this.title);
  final String title;

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    final t = (shrinkOffset / maxExtent).clamp(0.0, 1.0);
    return Container(
      color: Color.lerp(Colors.white, Colors.grey.shade200, t),
      padding: const EdgeInsets.symmetric(horizontal: 16),
      alignment: Alignment.centerLeft,
      child: Text(title, style: Theme.of(context).textTheme.titleMedium),
    );
  }

  @override
  double get maxExtent => 48.0;
  @override
  double get minExtent => 48.0;
  @override
  bool shouldRebuild(covariant _StickyHeaderDelegate old) => old.title != title;
}

// Usage
SliverPersistentHeader(
  pinned: true,
  delegate: _StickyHeaderDelegate('Recommended for you'),
)

Empty States That Fill the Screen

SliverFillRemaining(
  hasScrollBody: false,
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: const [
      Icon(Icons.inbox, size: 48),
      SizedBox(height: 12),
      Text('No messages yet'),
    ],
  ),
)

Animate Insertions with SliverAnimatedList

final GlobalKey<SliverAnimatedListState> listKey = GlobalKey();

SliverAnimatedList(
  key: listKey,
  initialItemCount: items.length,
  itemBuilder: (context, index, animation) {
    return SizeTransition(
      sizeFactor: animation,
      child: MessageTile(message: items[index]),
    );
  },
)

void insertAtTop(Message m) {
  items.insert(0, m);
  listKey.currentState?.insertItem(0);
}

Composing Real‑World Layouts

1) Collapsing Header with Pinned TabBar

For complex pages that combine a collapsible header and tabbed inner lists, prefer NestedScrollView with overlap handling:

class CollapsingTabsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (context, innerBoxIsScrolled) => [
            SliverOverlapAbsorber(
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              sliver: SliverAppBar(
                pinned: true,
                expandedHeight: 200,
                flexibleSpace: const FlexibleSpaceBar(title: Text('Library')),
                bottom: const TabBar(tabs: [
                  Tab(text: 'Books'), Tab(text: 'Authors'), Tab(text: 'Genres'),
                ]),
              ),
            ),
          ],
          body: TabBarView(children: [
            _TabList(category: 'Books'),
            _TabList(category: 'Authors'),
            _TabList(category: 'Genres'),
          ]),
        ),
      ),
    );
  }
}

class _TabList extends StatelessWidget {
  const _TabList({required this.category});
  final String category;
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      top: false, // TabBar already accounts for top padding
      bottom: false,
      child: Builder(
        builder: (context) => CustomScrollView(
          key: PageStorageKey(category),
          slivers: [
            SliverOverlapInjector(
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
            ),
            SliverPadding(
              padding: const EdgeInsets.all(8),
              sliver: SliverList(
                delegate: SliverChildBuilderDelegate(
                  (context, index) => ListTile(title: Text('$category #$index')),
                  childCount: 50,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Key points:

  • SliverOverlapAbsorber/Injector prevent double padding and content jump beneath the app bar.
  • PageStorageKey preserves scroll position per tab.

2) Mixed Feed: Articles, Ads, and a Product Grid

CustomScrollView(
  slivers: [
    const SliverToBoxAdapter(child: HeroBanner()),
    SliverPersistentHeader(
      pinned: true,
      delegate: _StickyHeaderDelegate('Top Stories'),
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ArticleTile(article: articles[index]),
        childCount: articles.length,
      ),
    ),
    SliverPersistentHeader(
      pinned: true,
      delegate: _StickyHeaderDelegate('Deals for you'),
    ),
    SliverGrid(
      gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 240,
        mainAxisSpacing: 12,
        crossAxisSpacing: 12,
        childAspectRatio: 0.9,
      ),
      delegate: SliverChildBuilderDelegate(
        (context, index) => ProductCard(item: deals[index]),
        childCount: deals.length,
      ),
    ),
    const SliverToBoxAdapter(child: SizedBox(height: 32)),
  ],
)

3) Chat Screen with “Scroll to Bottom”

class ChatView extends StatelessWidget {
  ChatView({super.key});
  final controller = ScrollController();

  void scrollToBottom() => controller.animateTo(
        controller.position.minScrollExtent,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        reverse: true, // newest at bottom
        controller: controller,
        slivers: [
          SliverPadding(
            padding: const EdgeInsets.all(8),
            sliver: SliverList(
              delegate: SliverChildBuilderDelegate(
                (context, index) => MessageBubble(message: messages[index]),
                childCount: messages.length,
              ),
            ),
          ),
          SliverFillRemaining(hasScrollBody: false, child: const SizedBox()),
        ],
      ),
    );
  }
}

Performance Playbook

  • Prefer slivers over nesting scrollables. Avoid ListView(shrinkWrap: true) inside another scrollable; convert the inner list to a sliver (SliverList) or split sections with SliverToBoxAdapter.
  • Use builder delegates with accurate childCount. Avoid building large children lists eagerly.
  • Choose fixed‑extent lists when row heights are uniform.
  • cacheExtent: Increase modestly to prebuild off‑screen items for fast flings. Very large values can increase memory.
  • Images: Use sized placeholders and caching (e.g., precacheImage or a caching package) to avoid jank.
  • Keep‑alive only when needed. For stateful rows that must persist, add AutomaticKeepAliveClientMixin. Otherwise, set addAutomaticKeepAlives to false on the delegate.
  • Repaint boundaries: Keep addRepaintBoundaries: true (default) to isolate heavy items; wrap complex rows in RepaintBoundary if they composite a lot.
  • Semantic indexes: Keep enabled for accessibility unless you have custom semantics.

Interaction, State, and Restoration

  • ScrollController: Inspect and control position (jumpTo/animateTo) and listen for user scrolls.
  • NotificationListener: React to scroll progress for effects (e.g., lazy analytics or changing app‑bar opacity).
  • PageStorageKey: Preserve per‑tab or per‑route scroll offsets automatically.
  • RestorableScrollController (or manually storing offset): Persist position across app kills if you use state restoration.

Example: progressive header fade based on scroll position

class FadingTitle extends StatefulWidget {
  const FadingTitle({super.key});
  @override
  State<FadingTitle> createState() => _FadingTitleState();
}

class _FadingTitleState extends State<FadingTitle> {
  final controller = ScrollController();
  double t = 0;

  @override
  void initState() {
    super.initState();
    controller.addListener(() {
      final d = controller.offset.clamp(0, 120);
      setState(() => t = d / 120);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Stack(children: [
      CustomScrollView(
        controller: controller,
        slivers: [
          SliverToBoxAdapter(child: SizedBox(height: 240, child: HeroBanner())),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, i) => ListTile(title: Text('Row $i')),
              childCount: 60,
            ),
          ),
        ],
      ),
      Positioned(
        top: 0,
        left: 0,
        right: 0,
        child: AppBar(
          title: Opacity(
            opacity: t,
            child: const Text('Discover'),
          ),
          backgroundColor: Colors.white.withOpacity(t * 0.9),
          elevation: t > 0.9 ? 2 : 0,
        ),
      ),
    ]);
  }
}

Debugging and Gotchas

  • Content hidden behind app bars? Use SliverOverlapAbsorber/Injector in NestedScrollView or SliverSafeArea.
  • Jank while flinging? Check heavy images, large shaders, and long rebuilds. Profile with the Performance page in DevTools.
  • SliverPersistentHeader not updating? Ensure shouldRebuild reflects changes in your delegate.
  • Mixed scroll directions? Keep inner/outer scrollables aligned; avoid horizontal ListView inside a vertical sliver without explicit heights.
  • Reordering? Use ReorderableListView.builder; for a pure sliver stack, consider a custom approach or SliverReorderableList (community packages exist).

Quick Reference: When to Use What

  • You need a simple list or grid: ListView/GridView (they’re sliver‑based under the hood).
  • You need headers that pin/collapse or multiple sections: CustomScrollView with SliverAppBar + SliverPersistentHeader.
  • You have mixed layouts (list + grid + promos): Compose slivers.
  • You need tabs with a collapsing header: NestedScrollView with SliverOverlapAbsorber/Injector.
  • You need an empty state to fill the screen: SliverFillRemaining(hasScrollBody: false).

Wrap‑Up

Mastering CustomScrollView and slivers unlocks the most flexible, high‑performance scroll UIs in Flutter. Start by composing the essentials—SliverAppBar, SliverList, SliverGrid, and SliverToBoxAdapter—then layer in SliverPersistentHeader for sticky sections, SliverFillRemaining for polish, and NestedScrollView for advanced tabbed layouts. With careful performance tuning and state management, your scroll experiences will feel native and effortless.

Related Posts