Slivers: CustomScrollView
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.
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,
),
),
],
)
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),
],
),
),
),
),
],
)
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
ListViewin aColumnwithshrinkWrap: 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.
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,
),
),
],
),
);
}
}
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.