在移动应用开发中,列表是展示数据的最常用方式之一。无论是联系人列表、商品展示还是新闻资讯,都离不开列表组件。Flutter 提供了丰富的列表相关组件,能够满足各种复杂的布局需求和性能要求。本节课将详细介绍 Flutter 中常用的列表组件及其高级用法,帮助你构建高效、美观的列表界面。
一、基础列表:ListView
ListView 是 Flutter 中最基础的列表组件,它可以沿一个方向线性排列子组件。ListView 有多种构造方法,适用于不同的使用场景。
1. 基本用法:ListView 直接包含子组件
最简单的 ListView 使用方式是直接在 children 属性中传入子组件列表:
ListView(
// 列表内边距
padding: const EdgeInsets.all(16),
// 列表项
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)),
],
)
这种方式适用于子组件数量较少的情况,因为它会一次性创建所有子组件,即使它们还没有显示在屏幕上。
2. 优化长列表:ListView.builder
当列表项数量较多(超过 10 个)或列表长度不确定时,应该使用 ListView.builder 来构建列表。它会根据滚动位置动态创建和销毁列表项,显著提高性能
class LongListExample extends StatelessWidget {
const LongListExample({super.key});
@override
Widget build(BuildContext context) {
// 模拟 1000 条数据
return ListView.builder(
// 列表项数量
itemCount: 1000,
// 列表内边距
padding: const EdgeInsets.all(16),
// 列表项构建器
itemBuilder: (context, index) {
// 只在需要显示时才会创建
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),
);
},
);
}
}
ListView.builder 的核心参数:
itemCount:列表项数量,指定后列表会有明确的长度itemBuilder:列表项构建函数,接收索引参数,返回对应位置的列表项physics:控制滚动行为(如NeverScrollableScrollPhysics禁用滚动)shrinkWrap:是否根据子组件尺寸确定列表大小,默认为false
3. 其他 ListView 构造方法
- ListView.separated:可以在列表项之间添加分隔符,适用于需要分隔线的场景:
ListView.separated(
itemCount: 50,
// 列表项构建器
itemBuilder: (context, index) {
return ListTile(
title: Text('Contact ${index + 1}'),
leading: const CircleAvatar(child: Icon(Icons.person)),
);
},
// 分隔符构建器
separatorBuilder: (context, index) {
// 每两个列表项之间的分隔线
return const Divider(height: 1);
},
)
- ListView.custom:完全自定义列表项的构建方式,适用于复杂场景,需要配合
SliverChildBuilderDelegate或SliverChildListDelegate使用。
4. 列表滚动控制
通过 ScrollController 可以控制列表的滚动行为,如滚动到指定位置、监听滚动事件等:
class ControlledListExample extends StatefulWidget {
const ControlledListExample({super.key});
@override
State<ControlledListExample> createState() => _ControlledListExampleState();
}
class _ControlledListExampleState extends State<ControlledListExample> {
// 创建滚动控制器
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
// 释放资源
_scrollController.dispose();
super.dispose();
}
// 滚动到顶部
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
// 滚动到底部
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(
// 关联滚动控制器
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),
),
],
),
);
}
}
二、列表项:ListTile 与自定义列表项
列表项是列表的基本组成单元,Flutter 提供了预设样式的 ListTile,也支持完全自定义列表项。
1. ListTile 组件
ListTile 是 Material Design 风格的列表项组件,包含了常见的列表项元素,如图标、标题、副标题等:
ListTile(
// 左侧图标
leading: const Icon(Icons.email),
// 主标题
title: const Text('Contact Us'),
// 副标题
subtitle: const Text('support@example.com'),
// 右侧图标
trailing: const Icon(Icons.arrow_forward_ios),
// 是否可点击
enabled: true,
// 是否选中
selected: false,
// 点击事件
onTap: () {
print('ListTile tapped');
},
// 长按事件
onLongPress: () {
print('ListTile long pressed');
},
// 左侧图标与文字之间的间距
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
)
ListTile 变体:
CheckboxListTile:包含复选框的列表项RadioListTile:包含单选按钮的列表项SwitchListTile:包含开关的列表项
示例:
// 带复选框的列表项
CheckboxListTile(
title: const Text('Remember me'),
value: true,
onChanged: (value) {},
controlAffinity: ListTileControlAffinity.leading, // 复选框位置
)
// 带开关的列表项
SwitchListTile(
title: const Text('Dark mode'),
value: false,
onChanged: (value) {},
secondary: const Icon(Icons.dark_mode),
)
2. 自定义列表项
当 ListTile 不能满足需求时,可以通过 Container、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);
}
// 使用自定义列表项
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,
);
},
)
// 自定义商品列表项
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: [
// 商品图片
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
// 商品信息
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),
// 评分
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),
),
],
),
],
),
),
// 价格
Text(
'$${price.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
);
}
}
三、网格布局:GridView
GridView 用于构建网格布局,适用于展示图片画廊、商品网格等场景。它同样提供了多种构造方法,以适应不同的使用需求。
1. GridView.count
GridView.count 允许指定每行的列数,是最常用的网格布局构造方法:
GridView.count(
// 每行的列数
crossAxisCount: 2,
// 列之间的间距
crossAxisSpacing: 10,
// 行之间的间距
mainAxisSpacing: 10,
// 网格内边距
padding: const EdgeInsets.all(10),
// 子组件宽高比
childAspectRatio: 1.0, // 宽高相等
// 子组件列表
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)),
),
);
}),
)
核心参数:
crossAxisCount:交叉轴方向的组件数量(即每行的列数)childAspectRatio:子组件的宽高比,影响网格项的形状crossAxisSpacing和mainAxisSpacing:控制网格项之间的间距
2. GridView.builder
当网格项数量较多时,应使用 GridView.builder 来优化性能,它会动态创建和销毁网格项:
class ImageGallery extends StatelessWidget {
const ImageGallery({super.key});
@override
Widget build(BuildContext context) {
return GridView.builder(
// 网格布局委托
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3列
crossAxisSpacing: 4,
mainAxisSpacing: 4,
childAspectRatio: 0.75, // 宽高比 4:3
),
// 网格项数量
itemCount: 50,
// 网格项构建器
itemBuilder: (context, index) {
// 构建图片网格项
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
// 使用网络图片
'https://picsum.photos/300/400?random=$index',
fit: BoxFit.cover,
),
);
},
);
}
}
GridView.builder 需要通过 gridDelegate 参数指定网格布局规则,常用的有:
SliverGridDelegateWithFixedCrossAxisCount:固定列数SliverGridDelegateWithMaxCrossAxisExtent:根据最大宽度自动计算列数
3. GridView.extent
GridView.extent 允许指定网格项的最大宽度,自动计算每行可以容纳的列数
GridView.extent(
// 网格项的最大宽度
maxCrossAxisExtent: 150,
// 列间距
crossAxisSpacing: 10,
// 行间距
mainAxisSpacing: 10,
// 内边距
padding: const EdgeInsets.all(10),
// 子组件
children: List.generate(12, (index) {
return Container(
color: Colors.green[100 * ((index % 9) + 1)],
child: Center(child: Text('Item $index')),
);
}),
)
这种方式适用于需要在不同屏幕尺寸上自动调整列数的场景。
四、列表滑动监听与下拉刷新
在实际应用中,我们经常需要监听列表的滚动状态(如实现无限滚动加载)或提供下拉刷新功能。
1. 下拉刷新:RefreshIndicator
RefreshIndicator 是实现下拉刷新功能的官方组件,使用简单且符合 Material Design 规范:
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);
// 模拟刷新数据
Future<void> _refreshData() async {
// 模拟网络请求延迟
await Future.delayed(const Duration(seconds: 2));
// 更新数据
setState(() {
_items = List.generate(20, (index) => index);
});
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
// 刷新指示器颜色
color: Colors.blue,
// 刷新背景颜色
backgroundColor: Colors.white,
// 刷新回调函数(必须返回Future)
onRefresh: _refreshData,
// 子列表
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
return ListTile(title: Text('Item ${_items[index] + 1}'));
},
),
);
}
}
RefreshIndicator 的核心是 onRefresh 回调函数,它必须返回一个 Future,当 Future 完成时,刷新指示器会停止动画。
2. 无限滚动与滚动监听
通过监听列表的滚动事件,可以实现无限滚动加载功能(当用户滚动到列表底部时,自动加载更多数据):
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();
// 监听滚动事件
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
// 移除监听器并释放资源
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
// 滚动事件处理
void _onScroll() {
// 检查是否滚动到了底部
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_isLoading) {
// 加载更多数据
_loadMoreData();
}
}
// 模拟加载更多数据
Future<void> _loadMoreData() async {
setState(() {
_isLoading = true;
});
// 模拟网络请求延迟
await Future.delayed(const Duration(seconds: 2));
setState(() {
_page++;
// 添加新数据
final newItems = List.generate(10, (index) => _items.length + index);
_items.addAll(newItems);
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
// 列表项数量(原有数据 + 加载指示器)
itemCount: _items.length + (_isLoading ? 1 : 0),
itemBuilder: (context, index) {
// 如果是最后一项且正在加载,则显示加载指示器
if (index == _items.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: CircularProgressIndicator()),
);
}
// 正常列表项
return ListTile(
title: Text('Item ${_items[index] + 1}'),
subtitle: Text('Page ${(_items[index] ~/ 10) + 1}'),
);
},
);
}
}
实现无限滚动的关键点:
- 使用
ScrollController监听滚动事件 - 判断是否滚动到接近底部的位置(通常是距离底部一定距离)
- 触发加载更多数据的逻辑,并显示加载指示器
- 加载完成后更新列表数据,并隐藏加载指示器
3. 滚动位置恢复
在某些场景下(如屏幕旋转、页面切换后返回),需要恢复列表的滚动位置。Flutter 提供了 ScrollController 的 keepScrollOffset 属性来实现这一功能:
final ScrollController _scrollController = ScrollController(
keepScrollOffset: true, // 默认为true,会保存滚动位置
);
对于列表项动态变化的场景,可能需要使用 PageStorageKey 来帮助 Flutter 保存和恢复滚动位置:
ListView.builder(
key: const PageStorageKey<String>('my_list_key'),
// ...其他参数
)
五、列表性能优化
当列表包含大量数据或复杂列表项时,性能优化变得尤为重要。以下是一些常用的列表性能优化技巧:
1. 使用合适的列表构造方法
- 少量固定数据:使用
ListView或GridView的普通构造方法 - 大量数据或动态数据:使用
ListView.builder或GridView.builder - 需要分隔符:使用
ListView.separated
2. 减少重建范围
- 将列表项抽取为独立的
StatelessWidget或StatefulWidget - 使用
const构造函数创建不变的列表项
// 优化前
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
leading: Icon(Icons.item),
);
}
// 优化后
itemBuilder: (context, index) {
return MyListItem(
index: index,
);
}
// 列表项组件
class MyListItem extends StatelessWidget {
final int index;
// 使用const构造函数
const MyListItem({super.key, required this.index});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text('Item $index'),
leading: const Icon(Icons.item), // 不变的部分使用const
);
}
}
3. 图片优化
- 使用合适尺寸的图片,避免大图缩小显示
- 实现图片缓存(可使用
cached_network_image库) - 列表滑动时暂停图片加载,停止滑动后再加载
4. 避免在构建方法中执行耗时操作
确保 itemBuilder 方法简洁高效,避免在其中执行:
- 复杂计算
- 网络请求
- 大量对象创建
5. 使用 RepaintBoundary 减少重绘
对于频繁重绘的列表项,可以使用 RepaintBoundary 包裹,避免影响其他列表项:
itemBuilder: (context, index) {
return RepaintBoundary(
child: AnimatedListItem(index: index),
);
}
六、实例:商品列表应用
下面实现一个功能完整的商品列表应用,包含网格 / 列表切换、下拉刷新、无限滚动等功能:
// 商品模型
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,
});
}
// 主应用
class ProductListApp extends StatefulWidget {
const ProductListApp({super.key});
@override
State<ProductListApp> createState() => _ProductListAppState();
}
class _ProductListAppState extends State<ProductListApp> {
// 商品列表数据
final List<Product> _products = [];
// 滚动控制器
final ScrollController _scrollController = ScrollController();
// 加载状态
bool _isLoading = false;
// 当前页码
int _page = 1;
// 布局模式(网格/列表)
bool _isGridMode = true;
@override
void initState() {
super.initState();
// 初始加载数据
_loadProducts();
// 监听滚动事件
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
// 加载商品数据
Future<void> _loadProducts({bool isRefresh = false}) async {
if (isRefresh) {
// 刷新时重置页码
_page = 1;
}
setState(() {
_isLoading = true;
});
// 模拟网络请求
await Future.delayed(const Duration(seconds: 1));
// 生成模拟数据
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;
});
}
// 下拉刷新
Future<void> _refreshProducts() async {
await _loadProducts(isRefresh: true);
}
// 滚动到底部加载更多
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300 &&
!_isLoading) {
_loadProducts();
}
}
// 切换布局模式
void _toggleLayoutMode() {
setState(() {
_isGridMode = !_isGridMode;
});
}
// 构建列表项
Widget _buildProductItem(Product product) {
if (_isGridMode) {
// 网格模式列表项
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 {
// 列表模式列表项
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: [
// 切换布局按钮
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]);
},
),
),
);
}
}