5.2 KiB
5.2 KiB
【Demo】ListView 列表(懒加载 / 刷新 / 触底加载 / 回顶)
源文件:
lib/pages/demo/list_demo.dart| 分类:基础布局 一句话:列表是 App 的骨架 —— 本页把列表的"基础、分隔、下拉刷新、上拉加载、回到顶部"一次讲透。
一、这个 Demo 解决什么问题
列表占了移动 App 80% 的页面形态。本 Demo 演示列表开发的全套基础:
ListView(children)vsListView.builder的区别(重点!);ListView.separated自动分隔线;RefreshIndicator下拉刷新;ScrollController监听触底 → 上拉加载更多;- 滚动超过 200px 显示"回到顶部"悬浮按钮。
二、怎么运行 / 演示内容
runApp(const MaterialApp(home: ListDemoPage()));
- Demo1:一次性渲染的 ListView(公告 + ListTile + for 循环混装);
- Demo2:可下拉刷新、可触底加载更多、带回到顶部按钮的完整列表。
三、核心知识点(新手向)
1. 最核心的区别(面试常问)
| 写法 | 行为 | 适用 |
|---|---|---|
ListView(children: [...]) |
一次性创建并渲染全部 item | 少量(<30)或异类组件混装 |
ListView.builder |
懒加载,只渲染可见项,滚到才创建 | 大量数据(聊天记录、长列表) |
ListView.builder(
itemCount: _items.length, // 数据总条数(决定滚动范围)
itemBuilder: (context, index) => ListTile(...), // 按下标生成每一行
)
2. 同族 API
ListView.builder:纯列表,没有分隔线;ListView.separated:自动插分隔线,多一个separatorBuilder(比在 item 里手动加 Divider 规范)。
3. 下拉刷新三件套
RefreshIndicator(
onRefresh: _onRefresh, // 必须返回 Future(转圈直到 Future 完成)
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), // 内容不满一屏也能下拉!
...
),
)
4. 触底加载的原理
ScrollController.addListener 监听滚动位置:
if (pos.pixels >= pos.maxScrollExtent - 200) {
_loadMore(); // 距底部 200px 时预加载
}
pos.pixels:当前滚了多少;pos.maxScrollExtent:总共能滚多少;hasClients:控制器是否已挂载到滚动组件上(没挂载就访问会崩)。
5. Dart 3 记录类型(顺带学会)
List<(int, String)> _items = []; // (编号, 来源标签) 元组
final (num, tag) = _items[index]; // 解构
final newStart = _items.first.$1 + 100; // $1 取第一个字段
四、关键代码剖析
防重入的 _loadMore
Future<void> _loadMore() async {
if (_isLoadingMore || !_hasMore) return; // 防重入:滚动事件会高频触发
_isLoadingMore = true;
setState(() {}); // 先刷新,显示底部 loading
await Future.delayed(const Duration(milliseconds: 800)); // 模拟网络
if (!mounted) return; // 异步后必须判 mounted
setState(() {
_items = [..._items, ...List.generate(10, (i) => (start + i, '加载更多'))];
_page++;
_hasMore = _page < 5; // 模拟:到第 5 页后没有更多
});
_isLoadingMore = false;
}
回到顶部按钮(Stack + AnimatedOpacity + IgnorePointer)
Positioned(
right: 8,
bottom: 8,
child: AnimatedOpacity(
opacity: _showBackToTop ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
// opacity: 0 的按钮还能被点到!必须 IgnorePointer 禁用点击
ignoring: !_showBackToTop,
child: FloatingActionButton.small(
heroTag: null, // 避免多个 FAB 的 Hero 动画冲突
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward, color: Colors.white),
),
),
),
)
_scrollToTop = _scrollController.animateTo(0, duration: 300ms, curve: Curves.easeOut)。
显隐判断用
if (shouldShow != _showBackToTop)而不是直接 setState —— 滚动事件每像素都触发, 只有状态真的变了才重建,避免性能浪费。
五、踩坑记录
- ListView 放进 Column/Row 必须给固定高度(SizedBox)或 Expanded,否则报
Vertical viewport was given unbounded height—— 列表是"无限高"的,放在"无限高"的 Column 里矛盾。 - RefreshIndicator 必须配
AlwaysScrollableScrollPhysics(),否则内容不满一屏时拉不动。 ScrollController必须在 dispose 释放(与 TextEditingController 同理,防内存泄漏)。- 异步后 setState 前必须
if (!mounted) return——await 期间用户可能已切走页面。 - 触底判断用
maxScrollExtent - 200提前量,不然在底部"刚好"才加载会有白屏等待感。
六、扩展思考
- 性能红线:长列表用 builder,不要用 children + for;1000 条数据一次性渲染会掉帧;
- 数据源接 Riverpod 后,
_items换成ref.watch(xxxProvider),刷新逻辑交给 provider(见 exam 系列); - 分页的通用模式:
page页码 +hasMore标记 +_isLoadingMore防重入,三件套缺一不可; - 想整页只滚一次(AppBar 折叠 + 列表混排)→ 升级到 sliver_demo.md 的 CustomScrollView。