This content originally appeared on DEV Community and was authored by Ge Ji
In mobile application development, lists are one of the most common ways to display data. Whether it’s contact lists, product displays, or news feeds, list components are essential. Flutter provides a rich set of list-related components that can meet various complex layout and performance requirements. This lesson will detail the commonly used list components in Flutter and their advanced usage, helping you build efficient and beautiful list interfaces.
I. Basic Lists: ListView
ListView is the most basic list component in Flutter, which can arrange child components linearly along one direction. ListView has multiple constructors suitable for different usage scenarios.
1. Basic Usage: ListView with Direct Children
The simplest way to use ListView is to directly pass a list of child components in the children property:
ListView(
// List padding
padding: const EdgeInsets.all(16),
// List items
children: const [
Text('Item 1', style: TextStyle(fontSize: 18)),
Text('Item 2', style: TextStyle(fontSize: 18)),
Text('Item 3', style: TextStyle(fontSize: 18)),
Text('Item 4', style: TextStyle(fontSize: 18)),
Text('Item 5', style: TextStyle(fontSize: 18)),
],
)
This approach is suitable for cases with a small number of child components, as it creates all child components at once, even if they haven’t been displayed on the screen yet.
2. Optimizing Long Lists: ListView.builder
When the number of list items is large (more than 10) or the list length is uncertain, you should use ListView.builder to build the list. It dynamically creates and destroys list items based on the scroll position, significantly improving performance:
class LongListExample extends StatelessWidget {
const LongListExample({super.key});
@override
Widget build(BuildContext context) {
// Simulate 1000 items of data
return ListView.builder(
// Number of list items
itemCount: 1000,
// List padding
padding: const EdgeInsets.all(16),
// Item builder
itemBuilder: (context, index) {
// Only created when needed for display
return ListTile(
title: Text('Item ${index + 1}'),
subtitle: Text('This is item ${index + 1} in the list'),
leading: const Icon(Icons.list),
trailing: const Icon(Icons.arrow_forward_ios),
);
},
);
}
}
Core parameters of ListView.builder:
- itemCount: The number of list items; when specified, the list will have a definite length
- itemBuilder: Item construction function that receives an index parameter and returns the item at the corresponding position
- physics: Controls scrolling behavior (e.g., NeverScrollableScrollPhysics disables scrolling)
- shrinkWrap: Whether to determine the list size based on child component dimensions, default is false
3. Other ListView Constructors
ListView.separated: Allows adding separators between list items, suitable for scenarios requiring dividers:
ListView.separated(
itemCount: 50,
// Item builder
itemBuilder: (context, index) {
return ListTile(
title: Text('Contact ${index + 1}'),
leading: const CircleAvatar(child: Icon(Icons.person)),
);
},
// Separator builder
separatorBuilder: (context, index) {
// Divider between each pair of items
return const Divider(height: 1);
},
)
- ListView.custom: Fully customizes how list items are built, suitable for complex scenarios, needs to work with SliverChildBuilderDelegate or SliverChildListDelegate.
4. List Scroll Control
ScrollController allows you to control list scrolling behavior, such as scrolling to a specific position and listening to scroll events:
class ControlledListExample extends StatefulWidget {
const ControlledListExample({super.key});
@override
State<ControlledListExample> createState() => _ControlledListExampleState();
}
class _ControlledListExampleState extends State<ControlledListExample> {
// Create scroll controller
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
// Release resources
_scrollController.dispose();
super.dispose();
}
// Scroll to top
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
// Scroll to bottom
void _scrollToBottom() {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Controlled List')),
body: ListView.builder(
// Associate scroll controller
controller: _scrollController,
itemCount: 50,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item ${index + 1}'),
);
},
),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
),
const SizedBox(width: 10),
FloatingActionButton(
onPressed: _scrollToBottom,
child: const Icon(Icons.arrow_downward),
),
],
),
);
}
}
II. List Items: ListTile and Custom List Items
List items are the basic building blocks of lists. Flutter provides the preset ListTile style as well as support for fully custom list items.
1. ListTile Component
ListTile is a Material Design-style list item component that contains common list item elements such as icons, titles, and subtitles:
ListTile(
// Leading icon
leading: const Icon(Icons.email),
// Main title
title: const Text('Contact Us'),
// Subtitle
subtitle: const Text('support@example.com'),
// Trailing icon
trailing: const Icon(Icons.arrow_forward_ios),
// Whether it's clickable
enabled: true,
// Whether it's selected
selected: false,
// Tap event
onTap: () {
print('ListTile tapped');
},
// Long press event
onLongPress: () {
print('ListTile long pressed');
},
// Spacing between leading icon and text
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
)
ListTile variants:
- CheckboxListTile: List item with checkbox
- RadioListTile: List item with radio button
- SwitchListTile: List item with switch
Examples:
// List item with checkbox
CheckboxListTile(
title: const Text('Remember me'),
value: true,
onChanged: (value) {},
controlAffinity: ListTileControlAffinity.leading, // Checkbox position
)
// List item with switch
SwitchListTile(
title: const Text('Dark mode'),
value: false,
onChanged: (value) {},
secondary: const Icon(Icons.dark_mode),
)
2. Custom List Items
When ListTile can’t meet your needs, you can build custom list items using basic components like Container and Row:
final products = [
PItem(
'a',
11.2,
'https://picsum.photos/300/400?random=$3',
23.1,
),
PItem(
'b',
12.2,
'https://picsum.photos/300/400?random=$5',
26.1,
),
];
class PItem {
final String name;
final double price;
final String imageUrl;
final double rating;
PItem(this.name, this.price, this.imageUrl, this.rating);
}
// Custom product list item
class ProductItem extends StatelessWidget {
final String name;
final double price;
final String imageUrl;
final double rating;
const ProductItem({
super.key,
required this.name,
required this.price,
required this.imageUrl,
required this.rating,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Product image
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
// Product information
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Rating
Row(
children: [
const Icon(Icons.star, color: Colors.yellow, size: 16),
const SizedBox(width: 4),
Text(
rating.toString(),
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
],
),
],
),
),
// Price
Text(
'\$${price.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
);
}
}
// Using custom list items
ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ProductItem(
name: product.name,
price: product.price,
imageUrl: product.imageUrl,
rating: product.rating,
);
},
)
III. Grid Layout: GridView
GridView is used to build grid layouts, suitable for scenarios like image galleries and product grids. It also provides multiple constructors to adapt to different usage needs.
1. GridView.count
GridView.count allows specifying the number of columns per row and is the most commonly used grid layout constructor:
GridView.count(
// Number of columns per row
crossAxisCount: 2,
// Spacing between columns
crossAxisSpacing: 10,
// Spacing between rows
mainAxisSpacing: 10,
// Grid padding
padding: const EdgeInsets.all(10),
// Aspect ratio of child components
childAspectRatio: 1.0, // Equal width and height
// List of child components
children: List.generate(20, (index) {
return Container(
color: Colors.blue[100 * ((index % 9) + 1)],
child: Center(
child: Text(
'Item $index',
style: const TextStyle(fontSize: 18),
),
),
);
}),
)
Core parameters:
- crossAxisCount: Number of components in the cross-axis direction (i.e., columns per row)
- childAspectRatio: Aspect ratio of child components, affecting the shape of grid items
- crossAxisSpacing and mainAxisSpacing: Control spacing between grid items
2. GridView.builder
When there are many grid items, you should use GridView.builder to optimize performance, as it dynamically creates and destroys grid items:
class ImageGallery extends StatelessWidget {
const ImageGallery({super.key});
@override
Widget build(BuildContext context) {
return GridView.builder(
// Grid layout delegate
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3 columns
crossAxisSpacing: 4,
mainAxisSpacing: 4,
childAspectRatio: 0.75, // 4:3 aspect ratio
),
// Number of grid items
itemCount: 50,
// Grid item builder
itemBuilder: (context, index) {
// Build image grid item
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
// Use network image
'https://picsum.photos/300/400?random=$index',
fit: BoxFit.cover,
),
);
},
);
}
}
GridView.builder needs to specify grid layout rules through the gridDelegate parameter. Common ones are:
- SliverGridDelegateWithFixedCrossAxisCount: Fixed number of columns
- SliverGridDelegateWithMaxCrossAxisExtent: Automatically calculates number of columns based on maximum width
3. GridView.extent
GridView.extent allows specifying the maximum width of grid items and automatically calculates how many columns can fit in each row:
GridView.extent(
// Maximum width of grid items
maxCrossAxisExtent: 150,
// Column spacing
crossAxisSpacing: 10,
// Row spacing
mainAxisSpacing: 10,
// Padding
padding: const EdgeInsets.all(10),
// Child components
children: List.generate(12, (index) {
return Container(
color: Colors.green[100 * ((index % 9) + 1)],
child: Center(
child: Text('Item $index'),
),
);
}),
)
This approach is suitable for scenarios where you need to automatically adjust the number of columns on different screen sizes.
IV. List Scroll Listening and Pull-to-Refresh
In practical applications, we often need to listen to the scroll state of lists (e.g., to implement infinite scroll loading) or provide pull-to-refresh functionality.
1. Pull-to-Refresh: RefreshIndicator
RefreshIndicator is the official component for implementing pull-to-refresh functionality, which is simple to use and conforms to Material Design specifications:
class RefreshableList extends StatefulWidget {
const RefreshableList({super.key});
@override
State<RefreshableList> createState() => _RefreshableListState();
}
class _RefreshableListState extends State<RefreshableList> {
List<int> _items = List.generate(20, (index) => index);
// Simulate refreshing data
Future<void> _refreshData() async {
// Simulate network request delay
await Future.delayed(const Duration(seconds: 2));
// Update data
setState(() {
_items = List.generate(20, (index) => index);
});
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
// Refresh indicator color
color: Colors.blue,
// Refresh background color
backgroundColor: Colors.white,
// Refresh callback function (must return Future)
onRefresh: _refreshData,
// Child list
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item ${_items[index] + 1}'),
);
},
),
);
}
}
The core of RefreshIndicator is the onRefresh callback function, which must return a Future. When the Future completes, the refresh indicator will stop animating.
2. Infinite Scroll and Scroll Listening
By listening to list scroll events, you can implement infinite scroll loading functionality (automatically loading more data when the user scrolls to the bottom of the list):
class InfiniteScrollList extends StatefulWidget {
const InfiniteScrollList({super.key});
@override
State<InfiniteScrollList> createState() => _InfiniteScrollListState();
}
class _InfiniteScrollListState extends State<InfiniteScrollList> {
final List<int> _items = List.generate(20, (index) => index);
final ScrollController _scrollController = ScrollController();
bool _isLoading = false;
int _page = 1;
@override
void initState() {
super.initState();
// Listen to scroll events
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
// Remove listener and release resources
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
// Scroll event handling
void _onScroll() {
// Check if scrolled to near the bottom
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_isLoading) {
// Load more data
_loadMoreData();
}
}
// Simulate loading more data
Future<void> _loadMoreData() async {
setState(() {
_isLoading = true;
});
// Simulate network request delay
await Future.delayed(const Duration(seconds: 2));
setState(() {
_page++;
// Add new data
final newItems = List.generate(10, (index) => _items.length + index);
_items.addAll(newItems);
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
// Number of list items (existing data + loading indicator)
itemCount: _items.length + (_isLoading ? 1 : 0),
itemBuilder: (context, index) {
// If it's the last item and loading, show loading indicator
if (index == _items.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(
child: CircularProgressIndicator(),
),
);
}
// Normal list item
return ListTile(
title: Text('Item ${_items[index] + 1}'),
subtitle: Text('Page ${(_items[index] ~/ 10) + 1}'),
);
},
);
}
}
Key points for implementing infinite scroll:
- Use ScrollController to listen to scroll events
- Determine if scrolling to near the bottom position (usually a certain distance from the bottom)
- Trigger logic to load more data and display loading indicator
- Update list data after loading is complete and hide loading indicator
3. Scroll Position Restoration
In some scenarios (such as after screen rotation or returning after page switching), you need to restore the scroll position of the list. Flutter provides the keepScrollOffset property of ScrollController to achieve this:
final ScrollController _scrollController = ScrollController(
keepScrollOffset: true, // Default is true, will save scroll position
);
For scenarios where list items change dynamically, you may need to use PageStorageKey to help Flutter save and restore scroll positions:
ListView.builder(
key: const PageStorageKey<String>('my_list_key'),
// ...other parameters
)
V. List Performance Optimization
When a list contains a large amount of data or complex list items, performance optimization becomes particularly important. Here are some common list performance optimization techniques:
1. Use Appropriate List Constructors
- Small amount of fixed data: Use regular constructors of ListView or GridView
- Large amount of data or dynamic data: Use ListView.builder or GridView.builder
- Need separators: Use ListView.separated
2. Reduce Rebuild Scope
- Extract list items into independent StatelessWidget or StatefulWidget
- Use const constructors to create immutable list items:
// Before optimization
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
leading: Icon(Icons.item),
);
}
// After optimization
itemBuilder: (context, index) {
return MyListItem(
index: index,
);
}
// List item component
class MyListItem extends StatelessWidget {
final int index;
// Use const constructor
const MyListItem({super.key, required this.index});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text('Item $index'),
leading: const Icon(Icons.item), // Use const for unchanged parts
);
}
}
3. Image Optimization
- Use appropriately sized images to avoid shrinking large images
- Implement image caching (can use cached_network_image library)
- Pause image loading when list is scrolling, resume after scrolling stops
4. Avoid Time-Consuming Operations in Build Methods
Ensure the itemBuilder method is concise and efficient. Avoid performing:
- Complex calculations
- Network requests
- Creation of a large number of objects
5. Use RepaintBoundary to Reduce Redraws
For list items that redraw frequently, wrap them with RepaintBoundary to avoid affecting other list items:
itemBuilder: (context, index) {
return RepaintBoundary(
child: AnimatedListItem(index: index),
);
}
VI. Example: Product List Application
The following implements a fully functional product list application with features including grid/list switching, pull-to-refresh, and infinite scrolling:
// Product model
class Product {
final int id;
final String name;
final String description;
final double price;
final double rating;
final String imageUrl;
Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.rating,
required this.imageUrl,
});
}
// Main application
class ProductListApp extends StatefulWidget {
const ProductListApp({super.key});
@override
State<ProductListApp> createState() => _ProductListAppState();
}
class _ProductListAppState extends State<ProductListApp> {
// Product list data
final List<Product> _products = [];
// Scroll controller
final ScrollController _scrollController = ScrollController();
// Loading state
bool _isLoading = false;
// Current page number
int _page = 1;
// Layout mode (grid/list)
bool _isGridMode = true;
@override
void initState() {
super.initState();
// Initial data loading
_loadProducts();
// Listen to scroll events
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
// Load product data
Future<void> _loadProducts({bool isRefresh = false}) async {
if (isRefresh) {
// Reset page number when refreshing
_page = 1;
}
setState(() {
_isLoading = true;
});
// Simulate network request
await Future.delayed(const Duration(seconds: 1));
// Generate mock data
final newProducts = List.generate(10, (index) {
final id = ((_page - 1) * 10) + index + 1;
return Product(
id: id,
name: 'Product $id',
description: 'This is a description for product $id',
price: 10.0 + (id * 0.5),
rating: 3.0 + (id % 2) + (id % 10) * 0.1,
imageUrl: 'https://picsum.photos/300/300?random=$id',
);
});
setState(() {
if (isRefresh) {
_products.clear();
}
_products.addAll(newProducts);
_page++;
_isLoading = false;
});
}
// Pull to refresh
Future<void> _refreshProducts() async {
await _loadProducts(isRefresh: true);
}
// Load more when scrolling to bottom
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300 &&
!_isLoading) {
_loadProducts();
}
}
// Toggle layout mode
void _toggleLayoutMode() {
setState(() {
_isGridMode = !_isGridMode;
});
}
// Build product item
Widget _buildProductItem(Product product) {
if (_isGridMode) {
// Grid mode item
return Card(
elevation: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
child: Image.network(
product.imageUrl,
width: double.infinity,
fit: BoxFit.cover,
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'\$${product.price.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.red),
),
Row(
children: [
const Icon(Icons.star, color: Colors.yellow, size: 16),
const SizedBox(width: 2),
Text(
product.rating.toStringAsFixed(1),
style: const TextStyle(fontSize: 14),
),
],
),
],
),
],
),
),
],
),
);
} else {
// List mode item
return ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
product.imageUrl,
width: 50,
height: 50,
fit: BoxFit.cover,
),
),
title: Text(product.name),
subtitle: Text(
product.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'\$${product.price.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, color: Colors.yellow, size: 14),
const SizedBox(width: 2),
Text(
product.rating.toStringAsFixed(1),
style: const TextStyle(fontSize: 12),
),
],
),
],
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Product List'),
actions: [
// Layout toggle button
IconButton(
icon: Icon(_isGridMode ? Icons.list : Icons.grid_view),
onPressed: _toggleLayoutMode,
),
],
),
body: RefreshIndicator(
onRefresh: _refreshProducts,
child: _products.isEmpty && _isLoading
? const Center(child: CircularProgressIndicator())
: _products.isEmpty
? const Center(child: Text('No products found'))
: _isGridMode
? GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.8,
),
itemCount: _products.length + (_isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _products.length) {
return const Center(child: CircularProgressIndicator());
}
return _buildProductItem(_products[index]);
},
)
: ListView.builder(
controller: _scrollController,
itemCount: _products.length + (_isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _products.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: CircularProgressIndicator()),
);
}
return _buildProductItem(_products[index]);
},
),
),
);
}
}
This content originally appeared on DEV Community and was authored by Ge Ji