Flutter for Beginners: Lists, Scrolling, and Data Presentation

CapĂ­tulo 10

Estimated reading time: 7 minutes

+ Exercise

Why Lists Matter in Real Apps

Most apps present collections: messages, products, tasks, photos, settings options. Flutter provides several scrolling widgets for this, and the most common are ListView and GridView. In this chapter you will build a scrolling list backed by a simple in-memory model, handle empty states, add separators, and implement basic item interactions. You will also adopt performance-minded habits: using const where possible and extracting item UI into separate widgets.

Core Scrolling Widgets: When to Use What

ListView (static children)

ListView can take a children list. This is convenient for short, mostly static lists. Flutter builds all children up front, which can be wasteful for long lists.

ListView(  children: const [    ListTile(title: Text('One')),    ListTile(title: Text('Two')),  ],);

ListView.builder (lazy, scalable)

ListView.builder builds items on demand as they scroll into view. This is the default choice for long or dynamic lists.

ListView.builder(  itemCount: items.length,  itemBuilder: (context, index) {    final item = items[index];    return ListTile(title: Text(item));  },);

ListView.separated (built-in separators)

ListView.separated is like builder but adds a separatorBuilder between items. This is cleaner than manually inserting divider widgets into your data.

ListView.separated(  itemCount: items.length,  itemBuilder: (context, index) => ListTile(title: Text(items[index])),  separatorBuilder: (context, index) => const Divider(height: 1),);

GridView (two-dimensional layouts)

Use GridView for photo galleries, product tiles, or dashboards. Like lists, grids have a builder constructor for lazy building.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

Download the app

GridView.builder(  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(    crossAxisCount: 2,    mainAxisSpacing: 12,    crossAxisSpacing: 12,    childAspectRatio: 1.2,  ),  itemCount: items.length,  itemBuilder: (context, index) {    return Card(child: Center(child: Text(items[index])));  },);

Hands-On Build: A Scrolling List Backed by an In-Memory Model

Step 1: Create a simple data model

Define a small model that represents each row. Keep it immutable where possible so UI updates are predictable.

class GroceryItem {  final String id;  final String name;  final int quantity;  final bool bought;  const GroceryItem({    required this.id,    required this.name,    required this.quantity,    this.bought = false,  });  GroceryItem copyWith({    String? id,    String? name,    int? quantity,    bool? bought,  }) {    return GroceryItem(      id: id ?? this.id,      name: name ?? this.name,      quantity: quantity ?? this.quantity,      bought: bought ?? this.bought,    );  }}

Step 2: Add in-memory sample data

In your screen widget, store a list of items in memory. This chapter focuses on presentation and interaction, so no database is needed.

class GroceryListScreen extends StatefulWidget {  const GroceryListScreen({super.key});  @override  State<GroceryListScreen> createState() => _GroceryListScreenState();}class _GroceryListScreenState extends State<GroceryListScreen> {  final List<GroceryItem> _items = [    const GroceryItem(id: '1', name: 'Apples', quantity: 4),    const GroceryItem(id: '2', name: 'Milk', quantity: 1),    const GroceryItem(id: '3', name: 'Rice', quantity: 2),  ];

Step 3: Build an empty state (important for real UX)

Lists are often empty: first app launch, filters applied, or after deleting everything. Instead of showing a blank screen, show a helpful empty state.

Widget _buildEmptyState() {  return Center(    child: Padding(      padding: const EdgeInsets.all(24),      child: Column(        mainAxisSize: MainAxisSize.min,        children: const [          Icon(Icons.shopping_basket_outlined, size: 56),          SizedBox(height: 12),          Text('No items yet.'),          SizedBox(height: 8),          Text('Add a few groceries to see them here.'),        ],      ),    ),  );}

Notice the use of const for widgets with fixed configuration. This reduces rebuild work and is a good habit in list-heavy screens.

Step 4: Extract the row UI into a dedicated item widget

When list rows become more than a ListTile, extracting them into a separate widget improves readability and helps performance by keeping rebuild scopes smaller.

class GroceryListItem extends StatelessWidget {  final GroceryItem item;  final VoidCallback onToggleBought;  final VoidCallback onDelete;  const GroceryListItem({    super.key,    required this.item,    required this.onToggleBought,    required this.onDelete,  });  @override  Widget build(BuildContext context) {    return ListTile(      leading: Icon(        item.bought ? Icons.check_circle : Icons.radio_button_unchecked,      ),      title: Text(        item.name,        style: TextStyle(          decoration: item.bought ? TextDecoration.lineThrough : null,        ),      ),      subtitle: Text('Qty: ${item.quantity}'),      onTap: onToggleBought,      trailing: IconButton(        icon: const Icon(Icons.delete_outline),        onPressed: onDelete,      ),    );  }}

This widget is reusable and keeps the list builder clean. It also makes it easier to test and iterate on row design.

Step 5: Render the list with separators

Now connect the in-memory list to a scrolling UI. Use ListView.separated to add dividers without complicating your data.

@overrideWidget build(BuildContext context) {  final body = _items.isEmpty      ? _buildEmptyState()      : ListView.separated(          itemCount: _items.length,          separatorBuilder: (context, index) => const Divider(height: 1),          itemBuilder: (context, index) {            final item = _items[index];            return GroceryListItem(              key: ValueKey(item.id),              item: item,              onToggleBought: () => _toggleBought(item.id),              onDelete: () => _deleteItem(item.id),            );          },        );  return Scaffold(    appBar: AppBar(title: const Text('Groceries')),    body: body,  );}

ValueKey helps Flutter keep the correct element associated with each item when the list changes (for example, after deletion). This avoids subtle UI bugs and improves update behavior.

Step 6: Implement basic item interactions (toggle + delete)

Interactions should update the in-memory list and trigger a rebuild. Keep the logic simple and predictable.

void _toggleBought(String id) {  setState(() {    final index = _items.indexWhere((e) => e.id == id);    if (index == -1) return;    final current = _items[index];    _items[index] = current.copyWith(bought: !current.bought);  });}void _deleteItem(String id) {  setState(() {    _items.removeWhere((e) => e.id == id);  });}

Because each item is immutable, toggling creates a new instance via copyWith. This pattern keeps state updates clear and reduces accidental side effects.

Performance-Minded Habits for Smooth Scrolling

Prefer builder constructors for large collections

Use ListView.builder or ListView.separated for long lists. They build only what is visible plus a small cache, which keeps memory usage and build time under control.

Use const constructors wherever possible

In list screens, small savings add up. Mark widgets as const when their inputs are compile-time constants (for example, icons, fixed padding, static text). This allows Flutter to short-circuit rebuild work.

  • Good: const Divider(height: 1)
  • Good: const Icon(Icons.delete_outline)
  • Not const: Text(item.name) because it depends on runtime data

Extract item widgets to reduce rebuild scope

When you keep row UI inside the list builder, it is easy to accidentally capture extra state and rebuild more than needed. A dedicated StatelessWidget for each row keeps the builder focused on wiring data to UI.

Provide stable keys for dynamic lists

When items can be inserted, removed, or reordered, provide stable keys (like an id). This helps Flutter preserve state and avoid visual glitches.

return GroceryListItem(  key: ValueKey(item.id),  item: item,  onToggleBought: () => _toggleBought(item.id),  onDelete: () => _deleteItem(item.id),);

Keep itemBuilder fast

The itemBuilder runs often during scrolling. Avoid heavy computations inside it. If you need derived values, compute them once (for example, when creating the model) or keep them simple.

Presenting Data as a Grid (Alternative View)

Sometimes the same data works better as tiles. You can switch to a grid presentation by using GridView.builder and a tile widget. The same in-memory list and interactions can be reused.

class GroceryGridTile extends StatelessWidget {  final GroceryItem item;  final VoidCallback onToggleBought;  const GroceryGridTile({    super.key,    required this.item,    required this.onToggleBought,  });  @override  Widget build(BuildContext context) {    return InkWell(      onTap: onToggleBought,      child: Card(        child: Padding(          padding: const EdgeInsets.all(12),          child: Column(            crossAxisAlignment: CrossAxisAlignment.start,            children: [              Row(                children: [                  Icon(item.bought ? Icons.check_circle : Icons.circle_outlined),                  const SizedBox(width: 8),                  Expanded(                    child: Text(                      item.name,                      maxLines: 1,                      overflow: TextOverflow.ellipsis,                    ),                  ),                ],              ),              const Spacer(),              Text('Qty: ${item.quantity}'),            ],          ),        ),      ),    );  }}
GridView.builder(  padding: const EdgeInsets.all(12),  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(    crossAxisCount: 2,    mainAxisSpacing: 12,    crossAxisSpacing: 12,  ),  itemCount: _items.length,  itemBuilder: (context, index) {    final item = _items[index];    return GroceryGridTile(      key: ValueKey(item.id),      item: item,      onToggleBought: () => _toggleBought(item.id),    );  },);

Common UI Patterns for Data Presentation

Empty state vs. loading state

This chapter uses an in-memory list, so there is no loading. In real apps, distinguish between:

  • Loading: show a progress indicator while fetching data.
  • Empty: show a friendly message and next action when there is no data.
  • Error: show an error message and a retry action.

Even with in-memory data, implementing an empty state now makes it easy to extend later.

Separators and spacing

Use ListView.separated for dividers. For extra spacing between cards, you can return a SizedBox from separatorBuilder instead of a divider.

separatorBuilder: (context, index) => const SizedBox(height: 8),

Clickable rows and accessible touch targets

ListTile provides a good default touch target size. For custom rows, wrap content with InkWell or GestureDetector, and ensure padding is sufficient so taps are easy.

Now answer the exercise about the content:

You need to display a long, dynamic list of items that may be inserted or removed, and you want efficient scrolling and correct UI updates when the list changes. Which approach best fits these requirements?

You are right! Congratulations, now go to the next page

You missed! Try again.

Builder constructors create items on demand, which scales for long or changing lists. Stable keys (like ValueKey(item.id)) help Flutter keep the correct element with each item when deleting or reordering, avoiding UI glitches.

Next chapter

Flutter for Beginners: Async Programming and Simple Data Fetching

Arrow Right Icon
Free Ebook cover Flutter for Beginners: Build Your First Cross-Platform Apps from Scratch
83%

Flutter for Beginners: Build Your First Cross-Platform Apps from Scratch

New course

12 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.