İçeriğe geç / Skip to content / Zum Inhalt

Flutter: ListView Widget Usage

Ahmet Balaman

Last updated:

7 min read

FlutterListViewWidgetScrollPerformanceLayout
Flutter: ListView Widget Usage

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.

In ListView.builder only the rows inside the viewport are built; itemBuilder is not called for the rows above and below

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, Column plus SingleChildScrollView is 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 CustomScrollView and slivers; if every tab under a collapsing header has its own list, you need NestedScrollView. 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 Column is 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.

Comments