EthicsComputerCourses
Join training +91 90532 10052

Lists & ListView.builder

Flutter & Dart · Lesson 18 of 29 Intermediate 6 min read Updated

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.

Dart
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.

Dart
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.

Dart
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:

  • Dismissible lets the user swipe a row away. It needs a unique key so Flutter knows which item was removed.
  • RefreshIndicator shows a spinner when the user pulls down from the top, and calls an async onRefresh function.
  • When the list is empty, show a helpful message instead of a blank screen.
Dart
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:

Dart
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. Use ListView.builder so rows are built lazily.
  • Using the index as a key for Dismissible. Use a stable id from the data.
  • A ListView directly inside a Column. Wrap it in Expanded.
  • 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.builder builds rows lazily for long lists.
  • itemCount and itemBuilder(context, index) describe the rows.
  • ListView.separated adds separators; GridView.builder lays items out in a grid.
  • Dismissible with a stable ValueKey adds swipe-to-delete; RefreshIndicator adds pull-to-refresh.
  • Show an empty state when there is no data, and wrap lists in Expanded inside a Column.

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
Dart
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?

2. What should you use as the key of a Dismissible row?

3. A ListView inside a Column throws an "unbounded height" error. What is the fix?

4. Which widget adds pull-to-refresh to a list?

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