# 【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 _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。