Lists & ListView.builder
Most apps are lists: chats, products, transactions, train results, notifications. A list may have five items or fifty thousand, and it must scroll smoothly on a budget phone. Flutter's ListView family handles all of this.
In this lesson you will build lists the simple way, then the efficient way with ListView.builder, and add the features users expect: separators, swipe-to-delete, pull-to-refresh and a friendly message when the list is empty.
ListView with children
For a short, fixed list, give ListView a children list. It is a scrollable Column.
import 'package:flutter/material.dart';
class SettingsList extends StatelessWidget {
const SettingsList({super.key});
@override
Widget build(BuildContext context) {
return ListView(
children: const [
ListTile(leading: Icon(Icons.person), title: Text('Profile')),
ListTile(leading: Icon(Icons.language), title: Text('Language'), subtitle: Text('English, Hindi')),
ListTile(leading: Icon(Icons.notifications), title: Text('Notifications')),
Divider(),
ListTile(leading: Icon(Icons.logout), title: Text('Log out')),
],
);
}
}The screen shows four settings rows with icons, a thin line before the last one, and "Language" has a second line "English, Hindi". If the rows do not fit on the screen, the list scrolls. This version builds every child at once, so it is only suitable for short lists.
ListView.builder for long lists
ListView.builder creates rows lazily: only the rows that are visible (plus a few nearby) are built. As the user scrolls, Flutter builds new rows and throws away old ones. That is why a list of 10,000 transactions scrolls as smoothly as a list of 10.
import 'package:flutter/material.dart';
class Txn {
const Txn(this.title, this.amount);
final String title;
final int amount; // negative = money spent
}
final transactions = List.generate(
1000,
(i) => Txn(i.isEven ? 'UPI to Sharma General Store' : 'Salary credit', i.isEven ? -(120 + i) : 25000),
);
class TransactionList extends StatelessWidget {
const TransactionList({super.key, required this.items});
final List<Txn> items;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final txn = items[index];
final spent = txn.amount < 0;
return ListTile(
leading: CircleAvatar(child: Icon(spent ? Icons.north_east : Icons.south_west)),
title: Text(txn.title),
subtitle: Text('Transaction #${index + 1}'),
trailing: Text(
'${spent ? '-' : '+'}₹${txn.amount.abs()}',
style: TextStyle(color: spent ? colors.error : Colors.green),
),
);
},
);
}
}With TransactionList(items: transactions), the screen shows rows alternating between "UPI to Sharma General Store" with a red amount such as "-₹120" and "Salary credit" with a green "+₹25000", each numbered "Transaction #1", "#2" and so on. Scrolling all the way down reaches "Transaction #1000" without slowing down.
itemBuilder is called with the index of each row it needs, and returns that row's widget. Always pass itemCount when you know it; without it the list is infinite and itemBuilder must return null to end it.
ListView.separated and GridView.builder
ListView.separated adds a widget between every pair of rows, such as a Divider. GridView.builder works like ListView.builder but lays rows out in columns.
import 'package:flutter/material.dart';
class ContactList extends StatelessWidget {
const ContactList({super.key, required this.names});
final List<String> names;
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: names.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(child: Text(names[index][0])),
title: Text(names[index]),
),
);
}
}
class SweetsGrid extends StatelessWidget {
const SweetsGrid({super.key});
static const sweets = ['Ladoo', 'Barfi', 'Jalebi', 'Rasgulla', 'Peda', 'Kaju katli'];
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.all(12),
itemCount: sweets.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemBuilder: (context, index) => Card(child: Center(child: Text(sweets[index]))),
);
}
}ContactList shows each name with a circle containing its first letter, separated by thin lines. SweetsGrid shows six square cards, three per row, each with the name of a sweet. The gridDelegate controls the grid shape; SliverGridDelegateWithMaxCrossAxisExtent instead fits as many columns as possible of a maximum width, which adapts nicely to tablets.
Swipe to delete, pull to refresh and empty states
Real lists change. Keep the data in a State, and use these widgets:
Dismissiblelets the user swipe a row away. It needs a unique key so Flutter knows which item was removed.RefreshIndicatorshows a spinner when the user pulls down from the top, and calls an asynconRefreshfunction.- When the list is empty, show a helpful message instead of a blank screen.
import 'package:flutter/material.dart';
class Reminder {
Reminder(this.id, this.text);
final int id;
final String text;
}
class ReminderScreen extends StatefulWidget {
const ReminderScreen({super.key});
@override
State<ReminderScreen> createState() => _ReminderScreenState();
}
class _ReminderScreenState extends State<ReminderScreen> {
final List<Reminder> _items = [
Reminder(1, 'Pay electricity bill'),
Reminder(2, 'Recharge metro card'),
Reminder(3, 'Submit assignment'),
];
var _nextId = 4;
Future<void> _refresh() async {
await Future<void>.delayed(const Duration(seconds: 1)); // pretend to sync
setState(() => _items.add(Reminder(_nextId++, 'Call Nani on Sunday')));
}
void _remove(Reminder r) {
final index = _items.indexOf(r);
setState(() => _items.remove(r));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Deleted "${r.text}"'),
action: SnackBarAction(
label: 'Undo',
onPressed: () => setState(() => _items.insert(index, r)),
),
),
);
}
@override
Widget build(BuildContext context) {
if (_items.isEmpty) {
return const Center(child: Text('No reminders. Enjoy your day!'));
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
final r = _items[index];
return Dismissible(
key: ValueKey(r.id),
direction: DismissDirection.endToStart,
background: Container(
color: Theme.of(context).colorScheme.errorContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete),
),
onDismissed: (_) => _remove(r),
child: ListTile(title: Text(r.text)),
);
},
),
);
}
}Three reminders appear. Swiping a row from right to left reveals a red background with a bin icon, and the row disappears; a snack bar says, for example, Deleted "Recharge metro card" with an Undo button that puts it back in the same place. Pulling the list down shows a spinner for a second, then "Call Nani on Sunday" appears at the bottom. After deleting everything, the screen shows "No reminders. Enjoy your day!".
ValueKey(r.id) uses the reminder's id, not its index, because indexes shift when items are removed. A snack bar that has an action stays until the user dismisses it or taps the action (Flutter 3.38 changed this default), so users always get a chance to undo.
Lists inside other layouts
A ListView wants to fill all the space it is given. Inside a Column, which gives its children unlimited height, a ListView causes an error ("Vertical viewport was given unbounded height"). Wrap it in Expanded:
import 'package:flutter/material.dart';
class SearchableList extends StatelessWidget {
const SearchableList({super.key, required this.cities});
final List<String> cities;
@override
Widget build(BuildContext context) {
return Column(
children: [
const Padding(
padding: EdgeInsets.all(12),
child: TextField(decoration: InputDecoration(labelText: 'Search city')),
),
Expanded(
child: ListView.builder(
itemCount: cities.length,
itemBuilder: (context, i) => ListTile(title: Text(cities[i])),
),
),
],
);
}
}The search box stays fixed at the top and only the city list below it scrolls. For more advanced scrolling, such as an app bar that shrinks as you scroll, Flutter has CustomScrollView with slivers (SliverAppBar, SliverList); you can learn those once you are comfortable with ListView.
Common mistakes
- Using
ListView(children: ...)for long or growing data. UseListView.builderso rows are built lazily. - Using the index as a key for
Dismissible. Use a stable id from the data. - A
ListViewdirectly inside aColumn. Wrap it inExpanded. - Changing the list without
setState. The data changes but the screen does not. - Heavy work in
itemBuilder. It runs while scrolling; prepare data beforehand.
Key points
ListView(children: ...)suits short fixed lists;ListView.builderbuilds rows lazily for long lists.itemCountanditemBuilder(context, index)describe the rows.ListView.separatedadds separators;GridView.builderlays items out in a grid.Dismissiblewith a stableValueKeyadds swipe-to-delete;RefreshIndicatoradds pull-to-refresh.- Show an empty state when there is no data, and wrap lists in
Expandedinside aColumn.
Exercise
Build a ShoppingListScreen with a TextField and an Add button at the top, and a ListView.builder below it showing the items. Each row is a CheckboxListTile that strikes through the item's name when ticked (use TextDecoration.lineThrough). Show "Your list is empty" when there are no items, and show the number of items still to buy in the app bar title.
Show solution
import 'package:flutter/material.dart';
class ShoppingListScreen extends StatefulWidget {
const ShoppingListScreen({super.key});
@override
State<ShoppingListScreen> createState() => _ShoppingListScreenState();
}
class _ShoppingListScreenState extends State<ShoppingListScreen> {
final _input = TextEditingController();
final List<({String name, bool done})> _items = [];
@override
void dispose() {
_input.dispose();
super.dispose();
}
void _add() {
final name = _input.text.trim();
if (name.isEmpty) return;
setState(() => _items.add((name: name, done: false)));
_input.clear();
}
@override
Widget build(BuildContext context) {
final left = _items.where((i) => !i.done).length;
return Scaffold(
appBar: AppBar(title: Text('To buy: $left')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(child: TextField(controller: _input, onSubmitted: (_) => _add())),
const SizedBox(width: 8),
FilledButton(onPressed: _add, child: const Text('Add')),
],
),
),
Expanded(
child: _items.isEmpty
? const Center(child: Text('Your list is empty'))
: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, i) {
final item = _items[i];
return CheckboxListTile(
value: item.done,
title: Text(
item.name,
style: TextStyle(decoration: item.done ? TextDecoration.lineThrough : null),
),
onChanged: (v) => setState(() => _items[i] = (name: item.name, done: v ?? false)),
);
},
),
),
],
),
);
}
}Each item is a small record (name:, done:); because records are immutable, ticking a box replaces the record at that index. The title counts items that are not done, and the empty message shows until the first item is added.
Quick Quiz
1. Why is ListView.builder better than ListView(children: ...) for 5,000 rows?
Lazy building keeps memory and build time low for long lists.
2. What should you use as the key of a Dismissible row?
Indexes shift when rows are removed; a stable id keeps the right state with the right item.
3. A ListView inside a Column throws an "unbounded height" error. What is the fix?
Expanded gives the list a bounded height: the space left in the column.
4. Which widget adds pull-to-refresh to a list?
RefreshIndicator calls its async onRefresh when the user pulls down.
Learn Flutter & Dart with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
