Beginner · Lesson 7 · 12 min read
Lists, Grids and Scrolling
Render long lists efficiently with ListView.builder, lay out grids, add pull-to-refresh and swipe-to-delete, and meet slivers.
Updated August 1, 2026
What you will learn
- Choose the right ListView constructor for the job
- Build grids that adapt to screen width
- Add pull-to-refresh, swipe-to-delete and reordering
- Understand what slivers are and when you need them
Most app screens are a list of something. Flutter's scrolling widgets are excellent, but choosing the wrong one is the single most common cause of a beginner app feeling slow — so this lesson is as much about which widget as it is about how to use it.
Four ways to build a list
| Constructor | Builds | Use when |
|---|---|---|
ListView(children: [...]) | Everything, immediately | A short, fixed list — a settings screen |
ListView.builder | Only visible items, lazily | Long or unknown-length lists — the default choice |
ListView.separated | Lazily, with dividers between | Lists that need separators |
ListView.custom | Whatever your delegate says | Rare — custom child management |
// Short and fixed: fine to build everything up front
ListView(
padding: const EdgeInsets.all(16),
children: const [
ListTile(leading: Icon(Icons.person), title: Text('Account')),
ListTile(leading: Icon(Icons.lock), title: Text('Privacy')),
ListTile(leading: Icon(Icons.info), title: Text('About')),
],
)
// Long: only builds what is on screen (plus a small buffer)
ListView.builder(
itemCount: articles.length,
itemBuilder: (context, index) {
final article = articles[index];
return ListTile(
title: Text(article.title),
subtitle: Text('${article.minutes} min read'),
onTap: () => _open(article),
);
},
)
// With dividers — note separatorBuilder gets its own index
ListView.separated(
itemCount: articles.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) => ArticleTile(article: articles[index]),
)Grids
// Fixed number of columns — simple, but wrong on tablets
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 3 / 4,
),
itemCount: products.length,
itemBuilder: (context, i) => ProductCard(product: products[i]),
)
// Better: fix the maximum tile width and let the column count follow
GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 220,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: products.length,
itemBuilder: (context, i) => ProductCard(product: products[i]),
)childAspectRatio is width divided by height. If your grid tiles overflow vertically, that ratio is usually the culprit — not the tile's contents.
Pull to refresh
RefreshIndicator(
onRefresh: () async {
final fresh = await api.fetchArticles();
if (!mounted) return;
setState(() => _articles = fresh);
},
child: ListView.builder(
// Keeps the gesture working even when the list is too short to scroll
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _articles.length,
itemBuilder: (context, i) => ArticleTile(article: _articles[i]),
),
)Swipe to delete
Dismissible(
// The key must be stable and unique, or the wrong row disappears
key: ValueKey(task.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Theme.of(context).colorScheme.errorContainer,
child: const Icon(Icons.delete_outline),
),
confirmDismiss: (direction) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete task?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
},
onDismissed: (direction) {
setState(() => _tasks.remove(task));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Task deleted'),
action: SnackBarAction(
label: 'Undo',
onPressed: () => setState(() => _tasks.insert(index, task)),
),
),
);
},
child: TaskTile(task: task),
)Reorderable lists
ReorderableListView.builder(
itemCount: _tasks.length,
onReorder: (oldIndex, newIndex) {
setState(() {
// The framework reports newIndex before the removal
if (newIndex > oldIndex) newIndex -= 1;
final task = _tasks.removeAt(oldIndex);
_tasks.insert(newIndex, task);
});
},
itemBuilder: (context, index) {
final task = _tasks[index];
return ListTile(
key: ValueKey(task.id),
title: Text(task.title),
trailing: const Icon(Icons.drag_handle),
);
},
)Slivers: scrolling beyond a plain list
A sliver is a piece of a scrollable area that knows how to behave as it scrolls in and out of view. You need them when one scroll view has to contain several different sections — a collapsing header, then a grid, then a list.
CustomScrollView(
slivers: [
// Collapses into a compact bar as you scroll
SliverAppBar.large(
title: const Text('Discover'),
floating: true,
flexibleSpace: FlexibleSpaceBar(
background: Image.network(headerUrl, fit: BoxFit.cover),
),
),
// A normal widget inside a sliver world
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Popular this week'),
),
),
SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
),
itemCount: featured.length,
itemBuilder: (context, i) => ProductCard(product: featured[i]),
),
SliverList.builder(
itemCount: articles.length,
itemBuilder: (context, i) => ArticleTile(article: articles[i]),
),
],
)Scroll controllers and infinite scroll
final _controller = ScrollController();
bool _loadingMore = false;
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
}
@override
void dispose() {
_controller
..removeListener(_onScroll)
..dispose();
super.dispose();
}
void _onScroll() {
final position = _controller.position;
// Start loading before the user actually hits the bottom
if (position.pixels >= position.maxScrollExtent - 400 && !_loadingMore) {
_loadNextPage();
}
}Loading the next page a few hundred pixels early is what makes infinite scroll feel seamless rather than stuttering at every page boundary.
Key takeaways
ListView.builderis the default for any list whose length comes from data.- Grids should size by maximum tile extent, not a fixed column count.
- Anything that can be removed or reordered needs a stable key from the item's id.
- Use
CustomScrollViewwith slivers when one scroll view holds several kinds of section.
Practice
A feed with everything
Build a screen with a collapsing SliverAppBar, a horizontal 'featured' row, and a long paginated list below it. Add pull-to-refresh, swipe-to-delete with an undo snackbar, and load the next page 400 pixels before the bottom.
Show hints
- A horizontal list inside a vertical scroll needs a fixed height — wrap it in a
SizedBox. - Keep the page number and loading flag in State, and guard against firing two loads at once.