Flutter Scrolling and Slivers: ListView to CustomScrollView (Complete Guide)

Coding Liquids blog cover featuring Sagnik Bhattacharya for the complete Flutter scrolling and slivers guide, with ListView, CustomScrollView, SliverAppBar, and scroll physics visuals.
Coding Liquids blog cover featuring Sagnik Bhattacharya for the complete Flutter scrolling and slivers guide.

Almost every real Flutter screen scrolls. Get scrolling right and the app feels native; get it wrong and you hit the two most-Googled Flutter errors — RenderFlex overflow and unbounded height — within an hour. This guide takes you from the everyday ListView all the way to CustomScrollView and slivers, explaining the mental model that ties them together so you always know which scrolling widget to reach for.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Every snippet below is paste-ready against current stable Flutter. Drop each one into a Scaffold body to see it render. If layout fundamentals are still shaky, read the Flutter Layout Widgets guide first — scrolling sits directly on top of the constraints model it explains.

Follow me on Instagram@sagnikteaches

We will start with the scrolling mental model — viewports and slivers — because it makes everything else click. Then we cover the convenience widgets (SingleChildScrollView, ListView, GridView), move to CustomScrollView and the sliver family, and finish with ScrollController, NestedScrollView, pull-to-refresh, performance, and the errors everyone hits.

Connect on LinkedInSagnik Bhattacharya

This is one of three Flutter pillar guides published together. The companion Flutter Animations guide and the Dart Language for Flutter guide round out the UI and language foundations you will lean on throughout.

Subscribe on YouTube@codingliquids

The mental model: viewports and slivers

Every scrollable in Flutter is built from two parts. A Scrollable handles the gesture and tracks the scroll offset; a Viewport is the window you see through, and it lays out its children lazily. The children inside a viewport are not ordinary box widgets — they are slivers. A sliver is a scrollable slice that negotiates its geometry with the viewport as the offset changes, so it can build only the part currently visible.

You rarely write that machinery by hand. ListView, GridView, and SingleChildScrollView are convenience widgets that wrap a Scrollable and a Viewport for you. The moment you need to combine several scrolling pieces in one gesture — a header that collapses, then a grid, then a list — you drop down to CustomScrollView and supply the slivers yourself. Hold onto one rule: a viewport gives its slivers unbounded space in the scroll direction. That single fact explains most scrolling errors later in this guide.

SingleChildScrollView: make any content scroll

SingleChildScrollView is the simplest scroller. It takes one child and lets it scroll when it would otherwise overflow. It is perfect for a form or a settings page that is occasionally taller than the screen — but because it builds its entire child immediately, it is the wrong tool for long or dynamic lists.

SingleChildScrollView(
  padding: const EdgeInsets.all(16),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: const [
      Text('A long form', style: TextStyle(fontSize: 24)),
      SizedBox(height: 12),
      TextField(decoration: InputDecoration(labelText: 'Name')),
      SizedBox(height: 12),
      TextField(decoration: InputDecoration(labelText: 'Email')),
      // ...more fields than fit on screen
    ],
  ),
)

A classic trap is wrapping a Column inside a SingleChildScrollView and then adding an Expanded child. Expanded needs a bounded height to flex into, but the scroll view offers unbounded height — so it throws. Inside a scroll view, give children explicit sizes instead of asking them to expand.

ListView: the everyday scroller

ListView is what you will use most. The default constructor takes a children list and is fine for a handful of fixed items. For anything long or data-driven, use ListView.builder, which builds items lazily as they scroll into view.

// Fixed, short list
ListView(
  children: const [
    ListTile(leading: Icon(Icons.home), title: Text('Home')),
    ListTile(leading: Icon(Icons.search), title: Text('Search')),
    ListTile(leading: Icon(Icons.person), title: Text('Profile')),
  ],
)

// Long or dynamic list — lazy and efficient
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    final item = items[index];
    return ListTile(
      title: Text(item.title),
      subtitle: Text(item.subtitle),
    );
  },
)

Use ListView.separated when you want dividers or spacing between rows without baking them into each item — it builds an extra widget between every pair of children.

ListView.separated(
  itemCount: items.length,
  separatorBuilder: (context, index) => const Divider(height: 1),
  itemBuilder: (context, index) => ListTile(title: Text(items[index])),
)

To scroll sideways instead of down, set scrollDirection: Axis.horizontal and give each child a width. A horizontal list inside a vertical one needs a bounded height, so wrap it in a SizedBox.

SizedBox(
  height: 140,
  child: ListView.builder(
    scrollDirection: Axis.horizontal,
    itemCount: cards.length,
    itemBuilder: (context, index) => Container(
      width: 120,
      margin: const EdgeInsets.all(8),
      color: Colors.amber.shade100,
      child: Center(child: Text(cards[index])),
    ),
  ),
)

GridView: rows and columns that scroll

GridView.builder is the lazy grid. The gridDelegate decides how many columns you get. Use SliverGridDelegateWithFixedCrossAxisCount for a fixed column count, or SliverGridDelegateWithMaxCrossAxisExtent to let the grid pick the count from a maximum tile width — the responsive choice.

GridView.builder(
  padding: const EdgeInsets.all(12),
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 180,
    mainAxisSpacing: 12,
    crossAxisSpacing: 12,
    childAspectRatio: 3 / 4,
  ),
  itemCount: products.length,
  itemBuilder: (context, index) => Card(
    child: Center(child: Text(products[index].name)),
  ),
)

The delegate names give the game away: a grid is a sliver under the hood, which is exactly why you can drop a SliverGrid straight into a CustomScrollView next.

The Complete Flutter Guide course thumbnail

Build scroll-heavy screens the right way

The Complete Flutter Guide walks you through lists, grids, and collapsing headers in real apps, with Riverpod and REST APIs.

Enrol now

CustomScrollView and slivers

CustomScrollView is the general-purpose scroller. Instead of one kind of child, it takes a slivers list, and scrolls them together as a single surface. This is how you build a screen that has a collapsing app bar, then a grid, then a list — all sharing one scroll gesture.

CustomScrollView(
  slivers: [
    const SliverAppBar(
      title: Text('Explore'),
      floating: true,
    ),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ListTile(title: Text('Item $index')),
        childCount: 20,
      ),
    ),
    SliverGrid(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
      delegate: SliverChildBuilderDelegate(
        (context, index) => Card(child: Center(child: Text('$index'))),
        childCount: 12,
      ),
    ),
  ],
)

Two shortcuts make slivers easier to read. SliverList.builder and SliverGrid.builder mirror their box-widget cousins, and SliverToBoxAdapter lets you drop a single ordinary widget — a banner, a heading — into the sliver list.

CustomScrollView(
  slivers: [
    const SliverToBoxAdapter(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Text('Featured', style: TextStyle(fontSize: 22)),
      ),
    ),
    SliverList.builder(
      itemCount: 30,
      itemBuilder: (context, index) => ListTile(title: Text('Row $index')),
    ),
  ],
)

SliverAppBar: collapsing and pinned headers

SliverAppBar is the reason most people learn slivers. Give it an expandedHeight and a FlexibleSpaceBar, and it expands to show a banner that collapses into a normal toolbar as you scroll. The behaviour flags are the part worth memorising:

  • pinned: true — the collapsed toolbar stays on screen at the top.
  • floating: true — the bar reappears as soon as you scroll up, without scrolling all the way back.
  • snap: true — with floating, the bar animates fully open or closed rather than half-way.
CustomScrollView(
  slivers: [
    SliverAppBar(
      expandedHeight: 220,
      pinned: true,
      flexibleSpace: FlexibleSpaceBar(
        title: const Text('Mountains'),
        background: Image.network(
          'https://picsum.photos/800/400',
          fit: BoxFit.cover,
        ),
      ),
    ),
    SliverList.builder(
      itemCount: 30,
      itemBuilder: (context, index) => ListTile(title: Text('Trail $index')),
    ),
  ],
)

SliverFillRemaining and SliverPersistentHeader

Two more slivers cover common needs. SliverFillRemaining fills whatever viewport space is left after the other slivers — ideal for an empty state or a centred message under a short list. SliverPersistentHeader builds a header that can stick (pinned) or hide on scroll (floating), useful for sticky section labels or a search bar that clings to the top.

CustomScrollView(
  slivers: [
    SliverList.builder(
      itemCount: 3,
      itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
    ),
    const SliverFillRemaining(
      hasScrollBody: false,
      child: Center(child: Text('Nothing more to load')),
    ),
  ],
)

SliverPersistentHeader needs a SliverPersistentHeaderDelegate where you implement build, maxExtent, minExtent, and shouldRebuild. It is more code than a SliverAppBar, so reach for it only when you genuinely need a custom sticky section that an app bar cannot express.

The Complete Flutter Guide course thumbnail

From widgets to shipped apps

The Complete Flutter Guide takes you from these building blocks to production apps on Android, iOS, and the web.

Enrol now

ScrollController: react to and drive scrolling

A ScrollController attaches to any scrollable and gives you two powers: read the current offset, and drive the scroll programmatically. Create it in initState, dispose it, and pass it to the scrollable's controller.

class _FeedState extends State<Feed> {
  final _controller = ScrollController();
  bool _showFab = false;

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      final show = _controller.offset > 400;
      if (show != _showFab) setState(() => _showFab = show);
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _toTop() => _controller.animateTo(
        0,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView.builder(
        controller: _controller,
        itemCount: 100,
        itemBuilder: (context, index) => ListTile(title: Text('Row $index')),
      ),
      floatingActionButton: _showFab
          ? FloatingActionButton(onPressed: _toTop, child: const Icon(Icons.arrow_upward))
          : null,
    );
  }
}

The same controller powers infinite scroll: in the listener, when offset nears position.maxScrollExtent, load the next page of data. For a full walkthrough of paginated feeds and the RefreshIndicator, the dedicated tutorials in the Flutter hub go deeper than there is room for here.

NestedScrollView: a header above tabbed lists

When you want a single collapsing header sitting above a TabBarView where each tab has its own scrollable, a plain CustomScrollView is not enough — the outer and inner scrollables would fight. NestedScrollView coordinates them: its headerSliverBuilder supplies the slivers that scroll away, and the body holds the tabbed inner scrollables.

NestedScrollView(
  headerSliverBuilder: (context, innerBoxIsScrolled) => [
    const SliverAppBar(
      title: Text('Profile'),
      pinned: true,
      expandedHeight: 180,
      bottom: TabBar(tabs: [Tab(text: 'Posts'), Tab(text: 'Media')]),
    ),
  ],
  body: const TabBarView(
    children: [
      Center(child: Text('Posts list')),
      Center(child: Text('Media grid')),
    ],
  ),
)

Wrap the whole thing in a DefaultTabController so the TabBar and TabBarView stay in sync.

Pull to refresh

RefreshIndicator wraps any scrollable and shows the spinner when the user drags down from the top. Its onRefresh returns a Future; the spinner stays until that future completes, so just await your data load.

RefreshIndicator(
  onRefresh: () async {
    await context.read<FeedRepository>().reload();
  },
  child: ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) => ListTile(title: Text(items[index])),
  ),
)

The scrollable must always be scrollable for the gesture to register. If your list is short, set physics: const AlwaysScrollableScrollPhysics() so the pull still works.

Scroll physics and performance

The physics property controls the feel. BouncingScrollPhysics gives the iOS rubber-band, ClampingScrollPhysics the Android glow, and NeverScrollableScrollPhysics disables scrolling (handy when an outer scrollable should own the gesture). Leave it unset to match the platform automatically.

For smooth, long lists, a few habits matter:

  • Always use the .builder constructors for long lists and grids so widgets build lazily.
  • Give rows a stable Key when the list reorders, so Flutter recycles element state correctly.
  • Add itemExtent or prototypeItem to a ListView when every row is the same height — Flutter then skips measuring each child and scrolls more cheaply.
  • Keep item builders cheap. Heavy work per row (decoding images, parsing) belongs in a cache or a future, not in itemBuilder.

For the wider performance picture — Impeller, DevTools, and rebuild reduction — see the Flutter Performance guide.

Common scrolling errors and fixes

  • "Vertical viewport was given unbounded height." You nested a scrollable inside another without bounding it — for example a ListView inside a Column. Wrap the inner list in Expanded, give it a fixed height, or set shrinkWrap: true (only for short lists).
  • RenderFlex overflowed. A Row or Column is wider or taller than its space. Make the overflowing child Expanded/Flexible, or make the axis scroll. The RenderFlex overflow fix covers this in depth.
  • shrinkWrap: true on a long list. It forces the list to measure every child up front, killing the laziness you want. Prefer a bounded height or slivers instead.
  • Two scrollables fighting. Nesting independent scroll views causes janky gesture handling — use CustomScrollView with slivers, or NestedScrollView, so one gesture drives the whole surface.
  • Lost scroll position on rebuild. Recreate the ScrollController only once (in initState), never inside build.

Which scrolling widget should I use?

  • Content occasionally taller than the screen → SingleChildScrollView.
  • A list of items → ListView.builder (add .separated for dividers).
  • A grid of items → GridView.builder.
  • A collapsing header, or mixed lists and grids in one scroll → CustomScrollView with slivers.
  • A header above tabbed lists → NestedScrollView.
  • Refresh on drag-down → wrap the scrollable in RefreshIndicator.
  • Detect or drive scrolling → attach a ScrollController.

Frequently asked questions

What is a sliver in Flutter?

A sliver is a slice of a scrollable area that negotiates its size lazily with the surrounding viewport, so it can build only the visible portion. CustomScrollView scrolls a list of slivers — SliverAppBar, SliverList, SliverGrid — together as one surface.

When should I use CustomScrollView instead of ListView?

Use ListView or GridView when the whole page is one uniform list or grid. Use CustomScrollView when a single scroll combines different pieces — a collapsing header, then a grid, then a list — or needs pinned and floating headers.

Why is ListView.builder faster than a plain ListView?

ListView.builder builds items lazily and recycles them, so a 10,000-row list costs about the same as a short one. A ListView built from a children list constructs every child immediately, which is wasteful for long or dynamic lists.

How do I build a collapsing app bar?

Put a SliverAppBar first inside a CustomScrollView, give it an expandedHeight and a FlexibleSpaceBar, and set pinned: true to keep the toolbar visible or floating: true to let it reappear on scroll-up.

Further reads

Keep going with the tutorials that pair with this guide:

Sources: Flutter documentation — Slivers, Scrolling, and the Widget catalog (docs.flutter.dev); the official "Using slivers to achieve fancy scrolling" and ListView/CustomScrollView API references. Verified against current stable Flutter.