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.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
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.