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')),
  ],
)
Note: Every widget passed to plain ListView's 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),
    );
  },
)
Next up: Lists of text are useful, but most real apps show pictures too — head to Images & Assets next.