第13章 - 列表进阶
嗨,朋友!我是长安。
在之前的章节中,我们学习了 ListView 的基本用法。但实际开发中,列表往往需要更多功能,比如下拉刷新、上拉加载更多、滑动删除等。这一章,我们来深入学习列表的进阶用法。
🔄 下拉刷新
使用 RefreshIndicator
RefreshIndicator 是 Flutter 内置的下拉刷新组件:
class RefreshListPage extends StatefulWidget {
const RefreshListPage({super.key});
State<RefreshListPage> createState() => _RefreshListPageState();
}
class _RefreshListPageState extends State<RefreshListPage> {
List<String> _items = List.generate(20, (index) => '项目 $index');
Future<void> _onRefresh() async {
// 模拟网络请求
await Future.delayed(const Duration(seconds: 2));
setState(() {
// 更新数据
_items = List.generate(20, (index) => '刷新后的项目 $index');
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('下拉刷新')),
body: RefreshIndicator(
onRefresh: _onRefresh,
color: Colors.blue, // 指示器颜色
backgroundColor: Colors.white, // 背景色
displacement: 40, // 下拉距离
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(_items[index]),
);
},
),
),
);
}
}
自定义刷新指示器
RefreshIndicator(
onRefresh: _onRefresh,
color: Colors.blue,
strokeWidth: 3, // 指示器粗细
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(), // 确保可以下拉
itemCount: _items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(_items[index]));
},
),
)
📜 上拉加载更多(无限滚动)
使用 ScrollController 监听滚动
class InfiniteListPage extends StatefulWidget {
const InfiniteListPage({super.key});
State<InfiniteListPage> createState() => _InfiniteListPageState();
}
class _InfiniteListPageState extends State<InfiniteListPage> {
final ScrollController _scrollController = ScrollController();
final List<String> _items = [];
bool _isLoading = false;
bool _hasMore = true;
int _page = 1;
void initState() {
super.initState();
_loadData();
_scrollController.addListener(_onScroll);
}
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
// 当滚动到底部时加载更多
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_page = 1;
});
await Future.delayed(const Duration(seconds: 1));
setState(() {
_items.clear();
_items.addAll(List.generate(20, (index) => '项目 ${index + 1}'));
_isLoading = false;
_hasMore = true;
});
}
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
await Future.delayed(const Duration(seconds: 1));
_page++;
final newItems = List.generate(
20,
(index) => '项目 ${(_page - 1) * 20 + index + 1}',
);
setState(() {
_items.addAll(newItems);
_isLoading = false;
// 假设加载 5 页后没有更多数据
_hasMore = _page < 5;
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('无限滚动')),
body: RefreshIndicator(
onRefresh: _loadData,
child: ListView.builder(
controller: _scrollController,
itemCount: _items.length + 1, // +1 用于底部加载指示器
itemBuilder: (context, index) {
// 最后一个项目显示加载状态
if (index == _items.length) {
return _buildLoadMoreWidget();
}
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(_items[index]),
);
},
),
),
);
}
Widget _buildLoadMoreWidget() {
if (!_hasMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: Text(
'没有更多数据了',
style: TextStyle(color: Colors.grey),
),
),
);
}
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(),
),
);
}
}
使用 NotificationListener
另一种监听滚动的方式:
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification is ScrollEndNotification) {
if (notification.metrics.pixels >=
notification.metrics.maxScrollExtent - 200) {
_loadMore();
}
}
return false;
},
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(_items[index]));
},
),
)
👆 滑动删除
使用 Dismissible
ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
return Dismissible(
key: Key(item), // 必须提供唯一的 key
// 滑动方向
direction: DismissDirection.endToStart,
// 背景(滑动时显示)
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
// 确认删除
confirmDismiss: (direction) async {
return await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认删除'),
content: Text('确定要删除 "$item" 吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('删除'),
),
],
),
);
},
// 删除回调
onDismissed: (direction) {
setState(() {
_items.removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已删除 "$item"'),
action: SnackBarAction(
label: '撤销',
onPressed: () {
setState(() {
_items.insert(index, item);
});
},
),
),
);
},
child: ListTile(
title: Text(item),
),
);
},
)
左右滑动不同操作
Dismissible(
key: Key(item),
// 左滑背景(删除)
background: Container(
color: Colors.green,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 20),
child: const Icon(Icons.archive, color: Colors.white),
),
// 右滑背景(归档)
secondaryBackground: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (direction) {
if (direction == DismissDirection.startToEnd) {
// 左滑 - 归档
print('归档');
} else {
// 右滑 - 删除
print('删除');
}
},
child: ListTile(title: Text(item)),
)
🔀 列表排序
使用 ReorderableListView
class ReorderListPage extends StatefulWidget {
const ReorderListPage({super.key});
State<ReorderListPage> createState() => _ReorderListPageState();
}
class _ReorderListPageState extends State<ReorderListPage> {
final List<String> _items = List.generate(10, (index) => '项目 ${index + 1}');
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('拖拽排序')),
body: ReorderableListView.builder(
itemCount: _items.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
},
itemBuilder: (context, index) {
return ListTile(
key: Key(_items[index]), // 必须提供 key
leading: const Icon(Icons.drag_handle),
title: Text(_items[index]),
tileColor: Colors.white,
);
},
),
);
}
}
📌 Sticky Header(粘性头部)
使用 SliverList 和 SliverPersistentHeader
class StickyHeaderPage extends StatelessWidget {
const StickyHeaderPage({super.key});
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// AppBar
const SliverAppBar(
title: Text('粘性头部'),
floating: true,
),
// 第一组
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(
child: Container(
color: Colors.blue,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Text(
'A',
style: TextStyle(color: Colors.white, fontSize: 20),
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('A - 项目 $index')),
childCount: 5,
),
),
// 第二组
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(
child: Container(
color: Colors.green,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Text(
'B',
style: TextStyle(color: Colors.white, fontSize: 20),
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('B - 项目 $index')),
childCount: 5,
),
),
// 第三组
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(
child: Container(
color: Colors.orange,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Text(
'C',
style: TextStyle(color: Colors.white, fontSize: 20),
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('C - 项目 $index')),
childCount: 10,
),
),
],
),
);
}
}
// StickyHeader 代理
class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
_StickyHeaderDelegate({required this.child});
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return SizedBox.expand(child: child);
}
double get maxExtent => 50;
double get minExtent => 50;
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return false;
}
}
📱 分组列表
带分组头的列表
class GroupedListPage extends StatelessWidget {
GroupedListPage({super.key});
final Map<String, List<String>> _data = {
'水果': ['苹果', '香蕉', '橙子', '葡萄'],
'蔬菜': ['白菜', '萝卜', '西红柿', '黄瓜'],
'肉类': ['猪肉', '牛肉', '鸡肉', '羊肉'],
};
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('分组列表')),
body: ListView.builder(
itemCount: _data.length,
itemBuilder: (context, groupIndex) {
final groupName = _data.keys.elementAt(groupIndex);
final items = _data[groupName]!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 分组头
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Colors.grey[200],
child: Text(
groupName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
// 分组内容
...items.map((item) => ListTile(
title: Text(item),
onTap: () {},
)),
],
);
},
),
);
}
}
🎯 实战:完整的新闻列表
import 'package:flutter/material.dart';
class News {
final int id;
final String title;
final String summary;
final String imageUrl;
final String date;
News({
required this.id,
required this.title,
required this.summary,
required this.imageUrl,
required this.date,
});
}
class NewsListPage extends StatefulWidget {
const NewsListPage({super.key});
State<NewsListPage> createState() => _NewsListPageState();
}
class _NewsListPageState extends State<NewsListPage> {
final ScrollController _scrollController = ScrollController();
final List<News> _newsList = [];
bool _isLoading = false;
bool _hasMore = true;
int _page = 1;
void initState() {
super.initState();
_loadData();
_scrollController.addListener(_onScroll);
}
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_page = 1;
});
await Future.delayed(const Duration(seconds: 1));
setState(() {
_newsList.clear();
_newsList.addAll(_generateNews(1));
_isLoading = false;
_hasMore = true;
});
}
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
await Future.delayed(const Duration(seconds: 1));
_page++;
setState(() {
_newsList.addAll(_generateNews(_page));
_isLoading = false;
_hasMore = _page < 5;
});
}
List<News> _generateNews(int page) {
return List.generate(10, (index) {
final id = (page - 1) * 10 + index + 1;
return News(
id: id,
title: '新闻标题 $id:这是一条测试新闻的标题内容',
summary: '这是新闻 $id 的摘要内容,用于展示在列表中的简短描述...',
imageUrl: 'https://picsum.photos/200/100?random=$id',
date: '2024-01-${(id % 28 + 1).toString().padLeft(2, '0')}',
);
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('新闻列表'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
),
],
),
body: RefreshIndicator(
onRefresh: _loadData,
child: _newsList.isEmpty && _isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
controller: _scrollController,
itemCount: _newsList.length + 1,
itemBuilder: (context, index) {
if (index == _newsList.length) {
return _buildLoadMoreWidget();
}
return _buildNewsItem(_newsList[index]);
},
),
),
);
}
Widget _buildNewsItem(News news) {
return Dismissible(
key: Key('news_${news.id}'),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (direction) {
setState(() {
_newsList.removeWhere((n) => n.id == news.id);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已删除: ${news.title}')),
);
},
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: () {
// 跳转到详情页
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// 图片
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
news.imageUrl,
width: 100,
height: 80,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 100,
height: 80,
color: Colors.grey[200],
child: const Icon(Icons.image),
);
},
),
),
const SizedBox(width: 12),
// 内容
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
news.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
news.summary,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
news.date,
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
),
),
],
),
),
],
),
),
),
),
);
}
Widget _buildLoadMoreWidget() {
if (!_hasMore) {
return Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: const Text(
'— 没有更多了 —',
style: TextStyle(color: Colors.grey),
),
);
}
return Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: const CircularProgressIndicator(),
);
}
}
📝 小结
这一章我们学习了:
- ✅ 下拉刷新(RefreshIndicator)
- ✅ 上拉加载更多(无限滚动)
- ✅ 滑动删除(Dismissible)
- ✅ 拖拽排序(ReorderableListView)
- ✅ 粘性头部(SliverPersistentHeader)
- ✅ 分组列表
列表性能优化
- 使用 ListView.builder - 只渲染可见项
- 提供 itemExtent - 固定高度时性能更好
- 使用 const - 减少不必要的重建
- 图片懒加载 - 使用 cached_network_image
- 避免在 itemBuilder 中创建对象
💪 练习题
- 实现一个带下拉刷新和上拉加载的商品列表
- 实现一个可以滑动删除的待办事项列表
- 实现一个可以拖拽排序的收藏列表
🚀 下一步
学会了列表进阶后,下一章我们来学习 主题定制,让应用有独特的视觉风格!
由 编程指南 提供
