Files
2026-09-04 15:22:20 +08:00

145 lines
5.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 【Demo】ListView 列表(懒加载 / 刷新 / 触底加载 / 回顶)
> 源文件:`lib/pages/demo/list_demo.dart` | 分类:基础布局
> 一句话:列表是 App 的骨架 —— 本页把列表的"基础、分隔、下拉刷新、上拉加载、回到顶部"一次讲透。
---
## 一、这个 Demo 解决什么问题
列表占了移动 App 80% 的页面形态。本 Demo 演示列表开发的全套基础:
1. `ListView(children)` vs `ListView.builder` 的区别(重点!);
2. `ListView.separated` 自动分隔线;
3. `RefreshIndicator` 下拉刷新;
4. `ScrollController` 监听触底 → 上拉加载更多;
5. 滚动超过 200px 显示"回到顶部"悬浮按钮。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ListDemoPage()));
```
- Demo1:一次性渲染的 ListView(公告 + ListTile + for 循环混装);
- Demo2:可下拉刷新、可触底加载更多、带回到顶部按钮的完整列表。
---
## 三、核心知识点(新手向)
### 1. 最核心的区别(面试常问)
| 写法 | 行为 | 适用 |
|---|---|---|
| `ListView(children: [...])` | **一次性创建并渲染全部** item | 少量(<30)或异类组件混装 |
| `ListView.builder` | **懒加载**,只渲染可见项,滚到才创建 | 大量数据(聊天记录、长列表) |
```dart
ListView.builder(
itemCount: _items.length, // 数据总条数(决定滚动范围)
itemBuilder: (context, index) => ListTile(...), // 按下标生成每一行
)
```
### 2. 同族 API
- `ListView.builder`:纯列表,没有分隔线;
- `ListView.separated`:自动插分隔线,多一个 `separatorBuilder`(比在 item 里手动加 Divider 规范)。
### 3. 下拉刷新三件套
```dart
RefreshIndicator(
onRefresh: _onRefresh, // 必须返回 Future(转圈直到 Future 完成)
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), // 内容不满一屏也能下拉!
...
),
)
```
### 4. 触底加载的原理
`ScrollController.addListener` 监听滚动位置:
```dart
if (pos.pixels >= pos.maxScrollExtent - 200) {
_loadMore(); // 距底部 200px 时预加载
}
```
- `pos.pixels`:当前滚了多少;
- `pos.maxScrollExtent`:总共能滚多少;
- `hasClients`:控制器是否已挂载到滚动组件上(没挂载就访问会崩)。
### 5. Dart 3 记录类型(顺带学会)
```dart
List<(int, String)> _items = []; // (编号, 来源标签) 元组
final (num, tag) = _items[index]; // 解构
final newStart = _items.first.$1 + 100; // $1 取第一个字段
```
---
## 四、关键代码剖析
### 防重入的 _loadMore
```dart
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)
```dart
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 —— 滚动事件每像素都触发,
> 只有状态真的变了才重建,避免性能浪费。
---
## 五、踩坑记录
1. **ListView 放进 Column/Row 必须给固定高度(SizedBox)或 Expanded**,否则报
`Vertical viewport was given unbounded height` —— 列表是"无限高"的,放在"无限高"的 Column 里矛盾。
2. **RefreshIndicator 必须配 `AlwaysScrollableScrollPhysics()`**,否则内容不满一屏时拉不动。
3. **`ScrollController` 必须在 dispose 释放**(与 TextEditingController 同理,防内存泄漏)。
4. **异步后 setState 前必须 `if (!mounted) return`**——await 期间用户可能已切走页面。
5. 触底判断用 `maxScrollExtent - 200` 提前量,不然在底部"刚好"才加载会有白屏等待感。
---
## 六、扩展思考
- **性能红线**:长列表用 builder,不要用 children + for;1000 条数据一次性渲染会掉帧;
- 数据源接 Riverpod 后,`_items` 换成 `ref.watch(xxxProvider)`,刷新逻辑交给 provider(见 exam 系列);
- 分页的通用模式:`page` 页码 + `hasMore` 标记 + `_isLoadingMore` 防重入,三件套缺一不可;
- 想整页只滚一次(AppBar 折叠 + 列表混排)→ 升级到 [sliver_demo.md](sliver_demo.md) 的 CustomScrollView。