flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
# 【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。
|
||||
Reference in New Issue
Block a user