Lists & ListView
Scrollable lists are everywhere — chat messages, product catalogs, news feeds, settings screens. Flutter's ListView family handles the scrolling for you, but which constructor you reach for matters a great deal for performance once a list gets long. This lesson covers the difference, a full worked example of the efficient version, the handy ListTile row widget, grids, pull-to-refresh, and why keys matter for reorderable lists.
Plain ListView: A Simple, Fixed List
The basic ListView constructor takes a fixed children list,
exactly like a Column would, except it's scrollable. It's fine for a
small, known number of items — a handful of settings rows, for example.
ListView(
children: const [
ListTile(title: Text('Profile')),
ListTile(title: Text('Notifications')),
ListTile(title: Text('Privacy')),
],
)
children is built immediately, all at once, whether it's currently visible on screen or not. That's fine for three items — it's a real problem for three thousand.ListView.builder: Lazy Building for Large Lists
ListView.builder is the standard way to display a potentially large or dynamically-sized list efficiently. Instead of building every item up front, it only builds the items that are currently visible on screen (plus a small buffer just outside the viewport) — as the user scrolls, items that leave the screen are discarded and new ones are built on demand. This "lazy building" is a genuinely important performance concept: a plain ListView with ten thousand children would allocate ten thousand widgets immediately and likely stutter or run out of memory, while ListView.builder with the same ten thousand items stays just as smooth as one with ten, because at any given moment it's only ever building a handful of rows.
It takes two key arguments: itemCount, the total number of items in the list, and
itemBuilder, a function that Flutter calls to build the widget for a given index — only
for indices it actually needs to render.
final List<String> fruits = ['Apple', 'Banana', 'Cherry', 'Durian', 'Elderberry'];
ListView.builder(
itemCount: fruits.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(fruits[index]),
);
},
)
The same pattern works just as well with a list of model objects instead of plain strings:
class Product {
final String name;
final double price;
Product(this.name, this.price);
}
final List<Product> products = [
Product('Keyboard', 29.99),
Product('Mouse', 14.99),
Product('Monitor', 199.99),
];
ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
title: Text(product.name),
trailing: Text('\$${product.price}'),
);
},
)
ListTile: A Ready-Made Row Layout
ListTile is a convenience widget that implements the extremely common "row with an icon, a title, a subtitle, and something on the right" layout, so you don't have to rebuild that structure from Row/Column every single time.
ListTile(
leading: CircleAvatar(child: Icon(Icons.person)),
title: const Text('Jane Doe'),
subtitle: const Text('Last seen 2 hours ago'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
print('Tapped Jane Doe');
},
)
GridView.builder: The 2D Equivalent
Where ListView lays items out along a single scrolling axis (1D), GridView.builder arranges them in rows and columns (2D) — ideal for photo galleries, product grids, or icon pickers. It shares the exact same lazy-building principle as ListView.builder: only the visible cells are built.
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: products.length,
itemBuilder: (context, index) {
return Card(
child: Center(child: Text(products[index].name)),
);
},
)
Pull-to-Refresh with RefreshIndicator
Wrapping a scrollable widget in RefreshIndicator adds the familiar
"pull down to refresh" gesture. Its onRefresh callback must return a
Future — the spinner stays visible until that future completes, which
makes it a natural fit for re-fetching data from an API (covered in the upcoming HTTP lesson).
RefreshIndicator(
onRefresh: () async {
// e.g. re-fetch the list from an API
await Future.delayed(const Duration(seconds: 1));
setState(() {
// update the underlying data here
});
},
child: ListView.builder(
itemCount: fruits.length,
itemBuilder: (context, index) => ListTile(title: Text(fruits[index])),
),
)
Why the key Parameter Matters for List Items
As mentioned briefly back in Widgets: Stateless vs Stateful,
the key parameter helps Flutter tell widgets apart when the tree changes shape. This
matters a lot specifically for list items that can be reordered, inserted, or removed —
without a stable key (typically a ValueKey built from a unique id),
Flutter matches old and new widgets up by position alone, which can cause it to reuse the wrong
element's state after a reorder — for example a checkbox's checked state, or a text field's cursor
position, appearing to "jump" to the wrong row.
ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
key: ValueKey(product.name), // stable identity across reorders
title: Text(product.name),
);
},
)