Files
App.K12.Student.Miniapp_Flu…/docs/demos/list_demo.md
T
2026-09-04 15:22:20 +08:00

5.2 KiB
Raw Blame History

【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 显示"回到顶部"悬浮按钮。

二、怎么运行 / 演示内容

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 —— 滚动事件每像素都触发, 只有状态真的变了才重建,避免性能浪费。


五、踩坑记录

  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 的 CustomScrollView。