Flutter Layouts & Responsive Design

Slivers: CustomScrollView

55 min Lesson 12 of 16

What Are Slivers?

Slivers are the low-level building blocks of scrollable areas in Flutter. While convenience widgets like ListView and GridView are built on top of slivers, working with slivers directly via CustomScrollView gives you the power to combine lists, grids, headers, and other scrollable elements into a single, unified scrolling experience.

Why Slivers? With ListView, you get a scrollable list. With GridView, you get a scrollable grid. But what if you want a scrollable page with a header, then a list, then a grid, then more content? That is exactly what CustomScrollView with slivers enables—mixing different scrollable layouts under one scroll controller.

CustomScrollView

CustomScrollView is a scroll view that creates custom scroll effects using slivers. Instead of accepting a single list of children, it accepts a list of sliver widgets. Each sliver defines a portion of the scrollable area.

Basic CustomScrollView

CustomScrollView(
  slivers: [
    // A sliver app bar
    const SliverAppBar(
      title: Text('My Page'),
      floating: true,
    ),

    // A sliver list
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ListTile(
          title: Text('Item \$index'),
        ),
        childCount: 10,
      ),
    ),

    // A sliver grid
    SliverGrid(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      delegate: SliverChildBuilderDelegate(
        (context, index) => Card(
          child: Center(child: Text('Grid \$index')),
        ),
        childCount: 6,
      ),
    ),
  ],
)

SliverList

SliverList creates a linear array of widgets along the scroll axis. It uses a delegate pattern to build children lazily, just like ListView.builder.

SliverList with Delegate

// Builder delegate - lazy, efficient for large lists
SliverList(
  delegate: SliverChildBuilderDelegate(
    (context, index) {
      return Card(
        margin: const EdgeInsets.symmetric(
          horizontal: 16, vertical: 4,
        ),
        child: ListTile(
          leading: CircleAvatar(child: Text('\$index')),
          title: Text('Item \$index'),
          subtitle: Text('Description for item \$index'),
        ),
      );
    },
    childCount: 100,
  ),
)

// Fixed list delegate - for small, known lists
SliverList.list(
  children: [
    const ListTile(title: Text('First item')),
    const ListTile(title: Text('Second item')),
    const ListTile(title: Text('Third item')),
  ],
)

SliverGrid

SliverGrid creates a two-dimensional arrangement of children in a scrollable area. Like SliverList, it uses delegates for lazy building.

SliverGrid Examples

// Fixed column count
SliverGrid(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,
    mainAxisSpacing: 10,
    crossAxisSpacing: 10,
    childAspectRatio: 1.0,
  ),
  delegate: SliverChildBuilderDelegate(
    (context, index) => Container(
      color: Colors.primaries[index % Colors.primaries.length],
      child: Center(
        child: Text(
          '\$index',
          style: const TextStyle(
            color: Colors.white, fontSize: 20,
          ),
        ),
      ),
    ),
    childCount: 30,
  ),
)

// Max extent per tile
SliverGrid(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 200,
    mainAxisSpacing: 10,
    crossAxisSpacing: 10,
  ),
  delegate: SliverChildBuilderDelegate(
    (context, index) => Card(
      child: Center(child: Text('Tile \$index')),
    ),
    childCount: 20,
  ),
)

SliverToBoxAdapter

SliverToBoxAdapter wraps a regular (non-sliver) widget so it can be used inside a CustomScrollView. This is how you insert headers, banners, or any box widget between slivers.

SliverToBoxAdapter Usage

CustomScrollView(
  slivers: [
    const SliverAppBar(
      title: Text('Shop'),
      expandedHeight: 200,
      flexibleSpace: FlexibleSpaceBar(
        background: Image.network(
          'https://example.com/banner.jpg',
          fit: BoxFit.cover,
        ),
      ),
    ),

    // Section header
    SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(
          'Featured Products',
          style: Theme.of(context).textTheme.headlineSmall,
        ),
      ),
    ),

    // Product grid
    SliverGrid(
      gridDelegate:
          const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      delegate: SliverChildBuilderDelegate(
        (context, index) => const ProductCard(),
        childCount: 6,
      ),
    ),

    // Another section header
    SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(
          'All Products',
          style: Theme.of(context).textTheme.headlineSmall,
        ),
      ),
    ),

    // Product list
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => const ProductListTile(),
        childCount: 20,
      ),
    ),
  ],
)
Tip: Use SliverToBoxAdapter sparingly for non-repeating items like headers and banners. For lists of items, always prefer SliverList or SliverGrid with builder delegates for optimal performance.

SliverPadding

SliverPadding adds padding around a sliver, similar to how Padding works for box widgets. You cannot wrap a sliver with a regular Padding widget—you must use SliverPadding.

SliverPadding Example

CustomScrollView(
  slivers: [
    SliverPadding(
      padding: const EdgeInsets.all(16),
      sliver: SliverGrid(
        gridDelegate:
            const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          crossAxisSpacing: 12,
          mainAxisSpacing: 12,
        ),
        delegate: SliverChildBuilderDelegate(
          (context, index) => Card(
            child: Center(child: Text('Item \$index')),
          ),
          childCount: 10,
        ),
      ),
    ),
  ],
)

SliverFillRemaining

SliverFillRemaining fills the remaining space in the viewport. This is incredibly useful for creating layouts where the last element should expand to fill whatever space is left, such as footer content or empty states.

SliverFillRemaining for Footer

CustomScrollView(
  slivers: [
    SliverList.list(
      children: [
        const SizedBox(height: 200, child: Text('Header')),
        const SizedBox(height: 100, child: Text('Content')),
      ],
    ),

    // This fills whatever space remains
    SliverFillRemaining(
      hasScrollBody: false,
      child: Container(
        color: Colors.grey[200],
        child: const Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.end,
            children: [
              Text('Footer Content'),
              SizedBox(height: 24),
              Text('Copyright 2024'),
              SizedBox(height: 16),
            ],
          ),
        ),
      ),
    ),
  ],
)
Warning: Set hasScrollBody: false on SliverFillRemaining when its child is not a scrollable widget. If hasScrollBody is true (the default), the child is expected to be a scrollable like ListView. Setting it incorrectly can cause layout issues.

Combining Multiple Slivers

The real power of CustomScrollView comes from combining multiple slivers into a single scrollable page. Here is a comprehensive example:

Complex Scrollable Page

class ShopPage extends StatelessWidget {
  const ShopPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // Collapsible app bar with image
          SliverAppBar(
            expandedHeight: 250,
            pinned: true,
            flexibleSpace: FlexibleSpaceBar(
              title: const Text('Online Store'),
              background: Image.network(
                'https://example.com/hero.jpg',
                fit: BoxFit.cover,
              ),
            ),
          ),

          // Search bar
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: SearchBar(
                hintText: 'Search products...',
                leading: const Icon(Icons.search),
              ),
            ),
          ),

          // Category chips
          SliverToBoxAdapter(
            child: SizedBox(
              height: 50,
              child: ListView.builder(
                scrollDirection: Axis.horizontal,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                itemCount: 8,
                itemBuilder: (context, index) => Padding(
                  padding: const EdgeInsets.only(right: 8),
                  child: Chip(label: Text('Category \$index')),
                ),
              ),
            ),
          ),

          // Featured section header
          _buildSectionHeader(context, 'Featured'),

          // Featured grid
          SliverPadding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            sliver: SliverGrid(
              gridDelegate:
                  const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                crossAxisSpacing: 12,
                mainAxisSpacing: 12,
                childAspectRatio: 0.75,
              ),
              delegate: SliverChildBuilderDelegate(
                (context, index) => _buildProductCard(index),
                childCount: 4,
              ),
            ),
          ),

          // Recent section header
          _buildSectionHeader(context, 'Recently Added'),

          // Recent products list
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => _buildProductTile(index),
              childCount: 15,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSectionHeader(BuildContext context, String title) {
    return SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
        child: Text(title,
            style: Theme.of(context).textTheme.titleLarge),
      ),
    );
  }

  Widget _buildProductCard(int index) {
    return Card(
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: Container(
              color: Colors.primaries[index % Colors.primaries.length][100],
              child: const Center(child: Icon(Icons.image, size: 48)),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('Product \$index',
                    style: const TextStyle(fontWeight: FontWeight.bold)),
                Text('\\$\${(index + 1) * 19.99}',
                    style: const TextStyle(color: Colors.green)),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildProductTile(int index) {
    return ListTile(
      leading: Container(
        width: 56,
        height: 56,
        color: Colors.grey[200],
        child: const Icon(Icons.shopping_bag),
      ),
      title: Text('Product \$index'),
      subtitle: Text('\\$\${(index + 1) * 9.99}'),
      trailing: const Icon(Icons.chevron_right),
    );
  }
}

Performance Benefits

Slivers provide significant performance advantages over nesting multiple scrollable widgets:

  • Lazy rendering: Only visible children are built and laid out. Off-screen items consume zero resources.
  • Single scroll controller: All slivers share the same scroll physics and controller, creating a smooth, unified scrolling experience.
  • No nested scrolling issues: Instead of wrapping a ListView in a Column with shrinkWrap: true (which defeats lazy loading), slivers compose naturally.
  • Efficient memory: Builder delegates dispose of off-screen widgets, keeping memory usage constant regardless of list size.
Anti-Pattern: Never use shrinkWrap: true on a ListView inside a Column for large lists. This forces Flutter to measure ALL items upfront, destroying lazy loading. Use CustomScrollView with slivers instead.

Practical Example: Lazy Infinite Scroll

Infinite Scroll with Slivers

class InfiniteScrollPage extends StatefulWidget {
  const InfiniteScrollPage({super.key});

  @override
  State<InfiniteScrollPage> createState() => _InfiniteScrollPageState();
}

class _InfiniteScrollPageState extends State<InfiniteScrollPage> {
  final List<String> _items = List.generate(20, (i) => 'Item \$i');
  bool _isLoading = false;

  Future<void> _loadMore() async {
    if (_isLoading) return;
    setState(() => _isLoading = true);

    // Simulate network delay
    await Future.delayed(const Duration(seconds: 1));

    setState(() {
      final start = _items.length;
      _items.addAll(
        List.generate(20, (i) => 'Item \${start + i}'),
      );
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          const SliverAppBar(
            title: Text('Infinite Scroll'),
            floating: true,
          ),

          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                if (index == _items.length) {
                  // Trigger load more
                  _loadMore();
                  return const Padding(
                    padding: EdgeInsets.all(16),
                    child: Center(
                      child: CircularProgressIndicator(),
                    ),
                  );
                }
                return ListTile(
                  title: Text(_items[index]),
                  leading: CircleAvatar(
                    child: Text('\$index'),
                  ),
                );
              },
              childCount: _items.length + 1,
            ),
          ),
        ],
      ),
    );
  }
}
Summary: Slivers are the foundation of all scrollable content in Flutter. Use CustomScrollView when you need to combine lists, grids, and other content in a single scroll. SliverList and SliverGrid provide efficient lazy rendering, SliverToBoxAdapter integrates non-sliver widgets, SliverPadding adds spacing, and SliverFillRemaining handles remaining viewport space. Prefer slivers over nested scrollable widgets with shrinkWrap for better performance.