ListView and Scrollable Lists
Displaying scrollable content efficiently, and why ListView.builder matters for long lists.
阅读需 2 分钟
Column lays out children vertically, but it doesn't scroll and it renders every child immediately — fine for a handful of items, but the wrong tool once a list can grow long or come from a dynamic data source. That's what ListView is for.
A simple ListView
ListView(
children: const [
ListTile(leading: Icon(Icons.inbox), title: Text('Inbox')),
ListTile(leading: Icon(Icons.send), title: Text('Sent')),
ListTile(leading: Icon(Icons.delete), title: Text('Trash')),
],
)ListTile is a ready-made row layout — leading icon, title, optional subtitle and trailing widget — designed specifically for list rows, so you rarely need to build a custom Row for basic list items.
Used this way, ListView behaves like a scrollable Column. But just like Column, it still builds every child up front, which becomes a real problem once a list has hundreds or thousands of items.
ListView.builder for long or dynamic lists
ListView.builder only constructs the items currently visible on screen (plus a small buffer), building more as the user scrolls and discarding ones that scroll off:
final items = List.generate(1000, (i) => 'Item #$i');
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(items[index]),
);
},
)itemBuilder is called lazily with the index Flutter needs next — it's the same "describe what this should look like" model from earlier lessons, just applied per-row instead of per-screen. This is why ListView.builder, not plain ListView, is the standard choice any time list length is unbounded or comes from an API: it keeps memory and build time proportional to what's visible, not to the total item count.
Adding separators
ListView.separated(
itemCount: items.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(title: Text(items[index])),
)ListView.separated is ListView.builder with a second builder for the divider between items — cleaner than manually interleaving Divider widgets into your data.
Horizontal lists
Pass scrollDirection: Axis.horizontal for a horizontally-scrolling row of cards (a common pattern for "featured items" carousels):
SizedBox(
height: 140,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: featured.length,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: FeaturedCard(item: featured[index]),
),
),
)Wrapping a horizontal ListView in a SizedBox with a fixed height is necessary here — a horizontal list needs a bounded cross-axis size the same way a vertical one needs a bounded parent height, and without it Flutter can't determine how tall the scrolling area should be.
Pull-to-refresh
Wrapping a ListView in RefreshIndicator adds the standard pull-down-to-reload gesture with almost no extra code:
RefreshIndicator(
onRefresh: () async {
await reloadData();
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(title: Text(items[index])),
),
)onRefresh must return a Future — RefreshIndicator keeps its spinner visible until that future completes, giving the user clear feedback that a reload is actually happening rather than just guessing.