Module 8: Lists and Scrolling
Displaying Data That Doesn't Fit on One Screen
Open any app on your phone. Odds are the first thing you see is a scrollable list. Today you learn how to build one.
Teach: How to build scrollable lists and grids using ListView, GridView, and their builder variants, and why lazy rendering matters for performance. See: Contact lists, photo grids, and pull-to-refresh patterns that look and behave like real apps. Feel: That Flutter makes lists surprisingly easy once you understand the builder pattern.
Think about the apps you use every day. Your email app? A list. Instagram? A list of posts. A contacts app? A list. A shopping app? A list -- or maybe a grid. Lists are everywhere in mobile development, and getting them right matters more than you might think. A badly built list can freeze your app when it hits a thousand items. A well-built list scrolls smoothly through ten thousand. The difference comes down to one concept: lazy rendering. Today you'll learn why it matters and how to use it.
ListView: The Basic Scrollable List
Teach: ListView is a scrollable Column -- it takes a list of children and makes them scrollable, but builds them all at once. See: A simple ListView with three ListTile children, and the performance problem that emerges with thousands of items. Feel: That plain ListView is easy but has a hidden cost that motivates the builder pattern.
The simplest way to make content scrollable is ListView. It works like a scrollable Column:
ListView(
children: [
ListTile(title: Text('Item 1')),
ListTile(title: Text('Item 2')),
ListTile(title: Text('Item 3')),
],
)
This creates all three widgets immediately, which is fine for short lists. But what if you have 10,000 items?
The Problem with Building Everything Up Front
Imagine you have a contacts list with 5,000 entries. A plain ListView would:
1. Create 5,000 widget objects in memory
2. Calculate layout for all 5,000
3. Only display about 10 on screen at a time
That's wildly wasteful. Your phone screen shows maybe 10-15 items at once. Why build the other 4,985?
This basic form of ListView is fine — really, it is — but only if you know your list is short. Menus, settings screens, a small set of options. Anything beyond maybe fifty items and you start paying a real cost: memory, layout time, and scrolling that feels sluggish even on a modern phone. Flutter doesn't stop you from doing this — there's no warning — but every experienced Flutter developer has learned to reach for ListView.builder by default. Save the plain ListView(children: [...]) form for when you have a small, fixed list.
ListView.builder: The Smart Way
Teach: ListView.builder only creates widgets that are visible on screen, using a count and a builder function -- this is lazy rendering and it scales to tens of thousands of items. See: A 5,000-item list that scrolls smoothly because only visible items exist in memory at any time. Feel: That lazy rendering is not complicated -- it is actually simpler and more efficient than building everything up front.
ListView.builder is one of those things that sounds complicated but is actually simpler than the basic approach. Instead of giving it a list of pre-built widgets, you give it a count and a function. "I have 5,000 items. Here's how to build each one." Flutter then only calls that function for items that are actually visible on screen. When you scroll, it creates new items coming into view and recycles ones going off-screen. This is called lazy rendering, and it's the reason apps with huge lists don't explode.
ListView.builder(
itemCount: 5000,
itemBuilder: (context, index) {
return ListTile(
title: Text('Contact $index'),
subtitle: Text('phone: 555-${index.toString().padLeft(4, '0')}'),
);
},
)
That's it. Five thousand items, but only the visible ones exist in memory at any time. Flutter handles the rest.
How the Builder Pattern Works
The itemBuilder function receives:
- context -- the build context (used for themes, navigation, etc.)
- index -- which item number Flutter needs (0, 1, 2, ...)
Flutter calls this function only when it needs to display that item. When an item scrolls off-screen, Flutter may dispose of it to save memory.
Rule of thumb: if your list has fewer than ~20 items and you're building them from literal data, plain ListView is fine. If items come from a data source or could be long, always use ListView.builder.
ListView.separated: Lists with Dividers
Teach: ListView.separated adds a dedicated separatorBuilder for dividers between items, keeping separator logic clean and separate from item logic. See: A contact list with thin dividers between each item, built without manually inserting Divider widgets into the data. Feel: That adding dividers is elegant rather than hacky.
In most real apps, list items have some kind of divider between them -- a thin line, some padding, or a visual separator. ListView.separated handles this elegantly by giving you a separate builder function just for the separators. No more manually adding Divider widgets to your data list and getting the count wrong.
ListView.separated(
itemCount: contacts.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(contacts[index]['name']!),
subtitle: Text(contacts[index]['phone']!),
);
},
separatorBuilder: (context, index) {
return Divider();
},
)
The separatorBuilder creates widgets that appear BETWEEN items (not before the first or after the last). It's the cleanest way to add dividers.
You can also make fancy separators:
separatorBuilder: (context, index) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Divider(color: Colors.grey[300], thickness: 0.5),
);
},
ListTile and Card: Structured List Items
Teach: ListTile provides the standard Material Design list item layout (leading, title, subtitle, trailing) for free, and Card wraps items with elevation and rounded corners. See: A ListTile decomposed into its four zones: leading, title, subtitle, trailing. Feel: Grateful that you don't have to manually align avatars and trailing icons every time.
ListTile
ListTile gives you a standard Material Design list item layout without building it from scratch:
ListTile(
leading: CircleAvatar(child: Text('JD')),
title: Text('John Doe'),
subtitle: Text('john@example.com'),
trailing: Icon(Icons.arrow_forward_ios),
onTap: () {
print('Tapped John');
},
)
The anatomy of a ListTile:
[leading] [title ] [trailing]
[subtitle ]
Leading is typically an avatar or icon. Trailing is typically an arrow or action icon. Title and subtitle are text. This covers about 80% of list item designs you'll encounter.
Card
Card adds elevation (shadow) and rounded corners around its child:
Card(
elevation: 2,
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue,
child: Text('AB'),
),
title: Text('Alice Brown'),
subtitle: Text('555-0123'),
trailing: Icon(Icons.phone),
),
)
Cards give list items a sense of separation and depth. They're optional -- some designs use dividers instead of cards, and some use neither.
ListTile is a huge time-saver once you learn to spot when it fits. Anywhere you'd have a Row with an icon on the left, text in the middle, and an arrow on the right — ListTile is giving you that layout with built-in padding, tap feedback, and accessibility support for free. Could you build it yourself? Sure, but you'd re-implement the exact same layout every time. When the design calls for something more custom, drop down to Row and Column. When it fits the ListTile shape, use ListTile. The hours you save add up fast.
Teach: ListTile and Card are convenience widgets. You could build the same thing with Row, Column, Padding, and Container -- but ListTile gives you the standard Material layout for free. See: A ListTile decomposed into its four zones: leading, title, subtitle, trailing. Feel: Grateful that you don't have to manually align avatars and trailing icons every time.
GridView: Two-Dimensional Layouts
Teach: GridView arranges items in a two-dimensional grid, with GridView.builder for lazy rendering and delegates to control column count and aspect ratio. See: A grid of colored cards arranged in columns, with spacing and aspect ratio configured via SliverGridDelegate. Feel: That grids are just as easy as lists once you understand the delegate pattern.
Not everything belongs in a single-column list. Photo galleries, product catalogs, and dashboard tiles all work better as grids. GridView is Flutter's answer, and just like ListView, it has a builder variant for lazy rendering. The key decision with GridView is telling it how many columns you want and how to size each cell.
GridView.builder
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 2 columns
crossAxisSpacing: 8, // horizontal gap between items
mainAxisSpacing: 8, // vertical gap between items
childAspectRatio: 1.0, // width:height ratio of each cell
),
itemCount: 20,
itemBuilder: (context, index) {
return Card(
color: Colors.primaries[index % Colors.primaries.length],
child: Center(
child: Text(
'Item $index',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
);
},
)
The gridDelegate controls the grid structure. SliverGridDelegateWithFixedCrossAxisCount is the most common -- you tell it how many columns, and it figures out the sizing.
GridView.count
For quick grids with all children specified up front:
GridView.count(
crossAxisCount: 3,
children: [
Card(child: Center(child: Text('A'))),
Card(child: Center(child: Text('B'))),
Card(child: Center(child: Text('C'))),
Card(child: Center(child: Text('D'))),
Card(child: Center(child: Text('E'))),
Card(child: Center(child: Text('F'))),
],
)
Same rule as ListView vs. ListView.builder: use GridView.count for small, static grids. Use GridView.builder for dynamic or large grids.
Putting It Together: A Complete List Screen
Teach: How ListView.builder, Card, ListTile, and dynamic data combine into the bread-and-butter pattern used in messaging apps, email clients, and task managers. See: A complete message list with avatars, titles, subtitles, timestamps, and tap handlers -- all in one cohesive example. Feel: That you can now build the most common screen pattern in all of mobile development.
Here's a complete example that combines ListView.builder, Card, ListTile, and dynamic data. Study this pattern -- you'll reuse it constantly:
class MessageList extends StatelessWidget {
final List<Map<String, String>> messages = [
{'sender': 'Alice', 'text': 'Hey, are you coming tonight?', 'time': '2:30 PM'},
{'sender': 'Bob', 'text': 'The project is done!', 'time': '1:15 PM'},
{'sender': 'Carol', 'text': 'Check out this link', 'time': '12:00 PM'},
// ... more messages
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Messages (${messages.length})')),
body: ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: CircleAvatar(
child: Text(msg['sender']![0]),
),
title: Text(msg['sender']!),
subtitle: Text(
msg['text']!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
msg['time']!,
style: TextStyle(color: Colors.grey, fontSize: 12),
),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Opened message from ${msg['sender']}')),
);
},
),
);
},
),
);
}
}
This is the bread-and-butter pattern: data list, ListView.builder, Card wrapping ListTile, with leading avatar, title, subtitle, trailing info, and onTap handler. You'll see variations of this in messaging apps, email clients, task managers, and countless other apps.
Memorize the shape of this code. Data list, builder, Card, ListTile, tap handler. If I wake you up at three in the morning and say "write a messages screen," this is the code you should produce. Notice the maxLines and overflow on the subtitle — that prevents a long message from pushing the list item to an awkward height. Notice the snackbar on tap — it gives users feedback that their action registered. These small touches are the difference between a list that works and a list that feels right. Steal this pattern wherever it fits.
SingleChildScrollView
Teach: SingleChildScrollView makes a single composed layout scrollable when it overflows the screen, unlike ListView which is optimized for repeating items. See: A Column of tall containers wrapped in SingleChildScrollView becoming scrollable instead of overflowing. Feel: That you know which scrolling widget to reach for in each situation.
Sometimes you have a single widget that's taller than the screen -- maybe a long form or a column of content. Wrapping it in SingleChildScrollView makes it scrollable:
SingleChildScrollView(
child: Column(
children: [
// Lots of content that might overflow
Container(height: 200, color: Colors.red),
Container(height: 200, color: Colors.green),
Container(height: 200, color: Colors.blue),
Container(height: 200, color: Colors.orange),
Container(height: 200, color: Colors.purple),
],
),
)
When to Use SingleChildScrollView vs. ListView
- SingleChildScrollView -- when you have a single composed layout that might overflow (like a form page or a settings screen)
- ListView -- when you have a repeating list of similar items
The key difference: SingleChildScrollView renders ALL its content at once. ListView.builder renders only visible items. For long repeating lists, always prefer ListView.builder.
The decision between SingleChildScrollView and ListView is about whether your content is a single thing that happens to be tall, or a collection of similar things. A settings page with a dozen toggles? That's one composed layout. Wrap it in SingleChildScrollView. A feed of posts? That's repeating similar items. Use ListView.builder. Pick the wrong one and you either rebuild too aggressively or render too much memory at once. The mental model that helps: SingleChildScrollView is "this one screen is bigger than the viewport." ListView is "this is a sequence of things."
RefreshIndicator: Pull to Refresh
Teach: RefreshIndicator wraps a scrollable widget and adds the pull-to-refresh gesture users expect, with an async callback for reloading data. See: A list that shows a spinning indicator when pulled down, reloads data, and dismisses the spinner automatically. Feel: That adding pull-to-refresh is trivially easy and makes your app feel polished.
Users expect to be able to pull down on a list to refresh its contents. It's so universal that if your list doesn't support it, people will try anyway and be confused when nothing happens. Flutter makes this dead simple with RefreshIndicator -- wrap your scrollable widget and provide an async callback.
RefreshIndicator(
onRefresh: () async {
// Simulate a network call
await Future.delayed(Duration(seconds: 2));
setState(() {
// Update your data here
_items = _fetchNewItems();
});
},
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(_items[index]));
},
),
)
The onRefresh callback MUST return a Future. Flutter shows a spinning indicator while the Future is pending, then hides it when it completes. The user pulls down, sees the spinner, and releases -- the data reloads.
Important: the child of RefreshIndicator must be a scrollable widget (ListView, GridView, etc.). It won't work with a plain Column.
RefreshIndicator wraps a scrollable widget and adds pull-to-refresh. The onRefresh callback must be async (return a Future). Flutter handles the animation and timing automatically.
There Are No Dumb Questions
Teach: Answers to the most common list and scrolling questions -- ListView vs. Column, mixing widget types, infinite lists, and grid sizing. See: Concise answers that resolve typical beginner confusion about scrollable widgets. Feel: That these are normal questions and the answers will save you debugging time.
Q: What's the difference between ListView and Column?
A: Column lays out all its children and throws an overflow error if they don't fit. ListView is scrollable -- if children don't fit, you scroll to see the rest. Also, Column takes all available cross-axis space by default, while ListView takes all available main-axis space.
Q: Can I mix different widget types in a ListView.builder?
A: Yes. The itemBuilder function can return any widget. You can use if/else logic on the index to return different widget types for different positions -- like a header for index 0 and regular items for everything else.
Q: Why not always use ListView.builder even for short lists?
A: You can, and it won't hurt. But for a list of 3-5 static items, the overhead of the builder pattern (item count, builder function) adds complexity without meaningful performance benefit. It's a readability tradeoff.
Q: Can GridView.builder have items of different sizes?
A: Not with the standard delegates. SliverGridDelegateWithFixedCrossAxisCount gives all items the same size. For items of varying sizes, you'll need more advanced tools like SliverGrid with custom delegates, or packages like flutter_staggered_grid_view.
Q: What happens if I forget to set itemCount on ListView.builder?
A: If you omit itemCount, the builder creates an infinite list. Flutter will keep calling your itemBuilder as the user scrolls, with ever-increasing indices. This is actually useful for infinite scroll feeds, but make sure your builder handles any index gracefully.
The infinite-scroll question comes up more than you'd expect. Omitting itemCount is how social feeds like Twitter or Instagram pull off endless scrolling — they load more data as the user reaches the end, and the builder just keeps producing widgets. The gotcha is that your builder has to be ready for ANY index, including ones past the data you currently have. So you need a loading placeholder or a fetch-more-data trigger. This is a pattern we'll revisit in Module 13 when we introduce HTTP and real data sources.
Sharpen Your Pencil: Contact List
Teach: How to build a complete contacts screen using ListView.separated, ListTile with avatars, and SnackBar feedback on tap. See: A scrollable list of 15+ contacts with dividers, avatar initials, phone numbers, and interactive tap behavior. Feel: That you can build a real contacts app screen from scratch.
Build a scrollable contacts app that demonstrates ListView.separated, ListTile, and user interaction.
Requirements
- Create a
StatelessWidgetcalledContactList - Define a list of at least 15 contacts, each a
Map<String, String>withname,phone, andemail - Use
ListView.separatedwithDividerseparators - Each item:
ListTilewith: leading:CircleAvatarshowing the first letter of the nametitle: contact namesubtitle: phone numbertrailing:Icon(Icons.phone)onTapshows aSnackBarwith the contact's email- Wrap in a
ScaffoldwithAppBartitled"Contacts"
This exercise is very close to what you'd write in a real contacts app. Fifteen contacts is enough to exercise scrolling on a small screen, and ListView.separated with Divider is the canonical Material Design pattern for lists. Pay attention to the CircleAvatar with the first letter — that's a cheap, reliable placeholder when you don't have a photo yet. And remember that onTap is just a callback. When we get to navigation in Module 10, that tap will open a detail screen instead of a snackbar. The structure stays the same.
Sharpen Your Pencil: Photo Grid
Teach: How to use GridView.builder for a two-dimensional layout, and how to convert to StatefulWidget to track user interaction. See: A colorful 3-column grid of 30 cards, then an enhanced version with tap-to-detail and state tracking. Feel: That grids are fun to build and the upgrade from stateless to stateful is a smooth transition.
Build a colorful photo grid that demonstrates GridView.builder and user interaction.
Part A: Basic Grid (photo_grid.dart)
- Create a
StatelessWidgetcalledPhotoGrid GridView.builderwithcrossAxisCount: 3, spacing of 4, and 30 items- Each item: a
Cardwith a coloredContainer(useColors.primaries[index % Colors.primaries.length]) and centered white bold text showing"Photo ${index + 1}" - Padding of 4 around the GridView
Part B: Grid with Detail (photo_grid_detail.dart)
- Convert to
StatefulWidgetcalledPhotoGridDetail onTapshows aDialogwith the photo number, color index, and a Close button- Track the last tapped photo in state and display it in the AppBar subtitle
Part B is where this exercise gets interesting. Converting from StatelessWidget to StatefulWidget is a one-minute mechanical transformation, but the thinking shift matters. Now your widget has memory. The last tapped photo persists across scroll events. The AppBar reflects the user's most recent action. Notice how little code changes — you add a State class, a field for the last tap, and a setState call. The rest of the layout is untouched. That's the Flutter upgrade path in miniature: start simple, add state only where you need it.
Sharpen Your Pencil: Pull-to-Refresh List
Teach: How to combine RefreshIndicator with ListView.builder and setState to build a list that grows on each pull-to-refresh. See: A list that starts with 10 items and adds 3 more each time you pull down, with a dynamic count in the AppBar. Feel: That pull-to-refresh with dynamic data is straightforward to implement.
Build a list that grows every time you pull to refresh.
Requirements
- Create a
StatefulWidgetcalledRefreshList - Initialize with 10 items:
"Item 1"through"Item 10" - Wrap a
ListView.builderinside aRefreshIndicator onRefresh:- Wait 1 second (simulate network call)
- Add 3 new items numbered sequentially
- Call
setState - Each item: a
Cardcontaining aListTilewith the item text and aCircleAvatarshowing the index - Display total count in AppBar:
"Items (X)"
The one-second delay before adding new items matters more than it looks. Users expect pull-to-refresh to show the spinner long enough that they see it worked. Zero delay feels jarring — your finger is still on the screen and the refresh indicator barely flashes. A full second feels deliberate. In a real app, the delay is your actual network call. Here, we're simulating it. The structural point is that onRefresh returns a Future, and the RefreshIndicator keeps its spinner visible until that Future completes. Don't return eagerly or the UX breaks.
Where this fits: Lists and grids are the backbone of mobile apps. The builder pattern (lazy rendering) is one of Flutter's most important performance tools. The patterns you learned today -- ListView.builder for lists, GridView.builder for grids, RefreshIndicator for pull-to-refresh -- will appear in nearly every app you build from here on.
📝 Module Quiz
Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.
Wrapping Up
Teach: A summary of the list and scrolling toolkit -- ListView.builder for lists, GridView.builder for grids, RefreshIndicator for pull-to-refresh, and why lazy rendering matters. See: The complete picture of scrollable data display in Flutter. Feel: Equipped to handle any collection of data, from 10 items to 10,000.
You now know how to display data in scrollable lists and grids. More importantly, you understand WHY ListView.builder exists -- it's not just a different syntax, it's a fundamentally different approach that only creates widgets you can actually see. For a list of 10 items, it doesn't matter. For a list of 10,000 items, it's the difference between an app that runs smoothly and one that crashes. Add ListTile and Card for structure, GridView for two-dimensional layouts, and RefreshIndicator for that pull-to-refresh gesture users expect, and you have a complete toolkit for displaying collections of data.
Two rules for lists in Flutter: (1) always use the .builder variant for dynamic or potentially long lists, and (2) always use RefreshIndicator when the user might want to reload the data. Follow these two rules and your lists will be fast and user-friendly.