Flutter: ListView Widget Usage
Last updated:
7 min read

ListView lays out items one after another and makes whatever does not fit on screen scrollable. You meet it on every screen where rows of the same kind stack up: a message list, a settings menu, a product feed. Writing a ListView is the easy part. The part worth learning is which constructor to pick, because the wrong choice goes unnoticed with ten rows and turns into jank with a thousand.
Live Demo
You can try this widget in the interactive example below:
💡 If the example above doesn't load, click DartPad to run it in a new tab.
Basic Usage
The simplest form takes a ready-made list of widgets:
ListView(
children: const [
Text('Item 1'),
Text('Item 2'),
Text('Item 3'),
],
)With this constructor every widget object in children is created up front. That is fine for a fixed menu of five or ten rows; for hundreds of rows the right tool is ListView.builder, covered below.
Important Properties
| Property | Description |
|---|---|
scrollDirection |
Scroll direction (Axis.vertical / Axis.horizontal) |
padding |
Space around the list content |
reverse |
Starts the list from the end (handy for chat screens) |
physics |
Scroll behavior |
shrinkWrap |
Forces the list to be only as tall as its content (expensive) |
itemExtent |
Fixed height for every item |
prototypeItem |
Measures the height from a sample widget |
controller |
A ScrollController to read and change the scroll position |
Classic Rows with ListTile
For row design ListTile is usually enough; the icon, title, subtitle and trailing chevron come ready-made:
ListView(
children: [
ListTile(
leading: const Icon(Icons.person),
title: const Text('Username'),
subtitle: const Text('Subtitle'),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
const ListTile(
leading: Icon(Icons.email),
title: Text('Email'),
subtitle: Text('[email protected]'),
trailing: Icon(Icons.chevron_right),
),
],
)Lazy Lists with ListView.builder
ListView.builder does not produce items up front. It calls itemBuilder only for the rows that are visible, plus the small cache area just outside the viewport. New rows are built as the user scrolls and rows that leave the screen are released. Whether the list has 100 items or 100,000, the number of rows alive at any moment is roughly what fits on screen.
ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(child: Text('$index')),
title: Text('Item $index'),
subtitle: const Text('Description of this item'),
);
},
)When ListTile is not enough you can build the row any way you like. Wrapping the text inside a Row with Expanded is a good habit so long titles do not overflow:
final List<Map<String, dynamic>> items = [
{'title': 'Flutter', 'icon': Icons.flutter_dash, 'color': Colors.blue},
{'title': 'Dart', 'icon': Icons.code, 'color': Colors.teal},
{'title': 'Firebase', 'icon': Icons.cloud, 'color': Colors.orange},
];
ListView.builder(
itemCount: items.length,
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
final item = items[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(item['icon'], size: 40, color: item['color']),
const SizedBox(width: 16),
Expanded(
child: Text(item['title'], style: const TextStyle(fontSize: 18)),
),
ElevatedButton(
onPressed: () {},
child: const Text('Details'),
),
],
),
),
);
},
)Dividers with ListView.separated
To put a line or a gap between rows, use ListView.separated instead of appending a Divider to every row. separatorBuilder is called only between items, so nothing is drawn after the last one and the "extra line under the final row" problem never appears. This constructor is lazy in the same way builder is.
ListView.separated(
itemCount: 10,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
return ListTile(title: Text('Item $index'));
},
)Horizontal ListView
scrollDirection: Axis.horizontal turns the list sideways. One catch: a horizontal list cannot decide its own height, so you have to give it a bounded one.
SizedBox(
height: 120, // A horizontal list needs a bounded height
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 10,
itemBuilder: (context, index) {
return Container(
width: 100,
margin: const EdgeInsets.all(8),
color: Colors.primaries[index % Colors.primaries.length],
child: Center(child: Text('${index + 1}')),
);
},
),
)itemExtent and prototypeItem
If every row has the same height, telling ListView so makes scrolling cheaper. Flutter no longer has to measure each row individually, and on big jumps, such as dragging the scrollbar or calling jumpTo for a distant offset, it can compute the target without laying out the rows in between.
ListView.builder(
itemExtent: 72, // Every row is exactly 72 pixels
itemCount: 1000,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
)If you do not know the height in pixels (the font size may change with the user's accessibility settings), pass a prototypeItem instead. Flutter measures that sample widget and applies the same height to every row. The two properties cannot be used together.
ListView.builder(
prototypeItem: const ListTile(title: Text('Sample row')),
itemCount: 1000,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
)Scroll Control
A "back to top" button or infinite scrolling needs a ScrollController. Create it inside the State and release it in dispose:
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
// inside build
ListView.builder(
controller: _scrollController,
itemCount: 50,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
)
// Scroll the list to the top
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);When to Use It and When Not To
Pick ListView when you show items of the same kind, in a number you do not control, stacked vertically or side by side. If the data comes from a collection, go to the builder or separated constructor almost every time and keep the children constructor for short menus you type by hand.
In these cases another widget fits better:
- For a handful of different widgets, like a form or a settings page,
ColumnplusSingleChildScrollViewis more comfortable. The details, and how it differs from ListView, are in the SingleChildScrollView, ListView and NestedScrollView post. - If items should sit in a grid rather than in rows, use GridView.
- If a list, a grid and a collapsing header must scroll together on one screen, you need
CustomScrollViewand slivers; if every tab under a collapsing header has its own list, you needNestedScrollView. Both are covered in the post linked in the first bullet. - If the content already fits on screen there is nothing to scroll; a plain
Columnis enough.
Common Mistakes
1. Putting a ListView directly inside a Column
Symptom: the screen stays empty and the console shows:
Vertical viewport was given unbounded height.A Column tells its children "be as tall as you want", while a ListView wants to take all the height it can find. Two infinities meet and Flutter cannot compute a size. The fix is to hand the ListView the space that is left in the Column:
Column(
children: [
const Text('Header'),
Expanded(
child: ListView.builder(
itemCount: 50,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
),
],
)The horizontal version of the same problem reads Horizontal viewport was given unbounded height.; there the fix is a SizedBox with a height, as shown above.
2. Sprinkling shrinkWrap: true everywhere to silence the error
shrinkWrap: true really does make the error go away, but it has a price: to know its own height the ListView has to measure all of its content. Put that inside a SingleChildScrollView with NeverScrollableScrollPhysics and every row is built in one go, so nothing remains of builder's laziness. Symptom: the longer the list gets, the slower the screen opens and the more the scroll stutters.
For a list of five or ten rows shrinkWrap is acceptable. For a long one, make the content above the list its first item instead:
ListView.builder(
itemCount: items.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text('Header area'),
);
}
final item = items[index - 1];
return ListTile(title: Text(item['title']));
},
)3. Feeding large data through children
ListView(children: products.map((p) => ProductRow(p)).toList()) works, but map(...).toList() recreates every row widget on every build call. Symptom: a noticeable hitch after each setState. The fix is to pass the same data through ListView.builder: itemCount: products.length and ProductRow(products[index]) inside itemBuilder.
Mini Scenario: A Task List Screen
Picture a to-do screen: the number of remaining tasks at the top, checkable tasks below it, and a hint when the list is empty. The counter stays put while the list scrolls, which is exactly what the Column + Expanded + ListView.separated trio is for.
class Task {
Task(this.title, {this.done = false});
final String title;
bool done;
}
class TaskListPage extends StatefulWidget {
const TaskListPage({super.key});
@override
State<TaskListPage> createState() => _TaskListPageState();
}
class _TaskListPageState extends State<TaskListPage> {
final List<Task> _tasks = [
Task('Finish the Flutter lesson'),
Task('Write the ListView example', done: true),
Task('Push the project to the repo'),
];
@override
Widget build(BuildContext context) {
final remaining = _tasks.where((task) => !task.done).length;
return Scaffold(
appBar: AppBar(title: const Text('Tasks')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text('$remaining tasks left'),
),
Expanded(
child: _tasks.isEmpty
? const Center(child: Text('No tasks yet'))
: ListView.separated(
itemCount: _tasks.length,
separatorBuilder: (context, index) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final task = _tasks[index];
return CheckboxListTile(
value: task.done,
title: Text(task.title),
onChanged: (value) {
setState(() => task.done = value ?? false);
},
);
},
),
),
],
),
);
}
}Three decisions are at work here. Expanded gives the list whatever the counter leaves over, which rules out the unbounded height error from the start. The empty state is handled outside the ListView, because an empty list tells the user nothing. And the dividers come from separatorBuilder rather than being part of each row. If the data comes from the network, the same structure goes inside a FutureBuilder.
Frequently Asked Questions
What is the difference between ListView and ListView.builder?
ListView(children: [...]) creates all row widgets up front, while ListView.builder calls itemBuilder only for the rows that are visible. Use the first for short, fixed menus and the second for anything that comes from a collection or may grow long.
How do I fix "Vertical viewport was given unbounded height"?
It happens when a ListView sits inside a Column, whose height is unbounded. Wrap the ListView in Expanded or give it a fixed height with SizedBox; shrinkWrap: true is only a reasonable fix for short lists.
Why is shrinkWrap: true slow?
To determine its own size the list has to measure all of its content, and that size is recomputed while scrolling. On long lists this cancels out the benefit of lazy building.
Why is there a gap at the top of my ListView?
When the screen has no AppBar, ListView applies the MediaQuery padding as its default padding so content does not end up under system areas such as the status bar. Pass padding: EdgeInsets.zero if you do not want it.
Related Posts
Flutter: SingleChildScrollView, ListView, NestedScrollView
SingleChildScrollView vs ListView vs NestedScrollView, the keyboard overflow error, Expanded's unbounded height error and a tabbed NestedScrollView setup.
Flutter: GridView Widget Usage
A practical Flutter GridView guide: count, extent and builder, the two grid delegates, the childAspectRatio overflow error and the real cost of shrinkWrap.
Flutter: Sizing and Spacing with SizedBox
SizedBox for fixed sizes and gaps: expand, shrink and square constructors, SizedBox vs Container, why your size gets ignored, and common mistakes.