import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; /// ============================================================ /// ListView 列表演示页 /// /// 一、两种使用方式: /// 1. 直接运行本页面:runApp(const MaterialApp(home: ListDemoPage())) /// 2. 复制某段核心代码到任意 Scaffold 的 body 中。 /// /// 二、核心区别(重点记忆): /// ListView(children) : 一次性创建并渲染【全部】item,适合少量(<30)或异类组件 /// ListView.builder : 懒加载,只渲染屏幕可见的 item,滑到才创建,适合大量数据 /// /// 三、ListView.builder 必填两个参数: /// itemCount : 数据总条数(决定可滚动范围) /// itemBuilder : (context, index) 根据下标生成每一行 /// /// 四、同族 API: /// ListView.builder : 纯列表,item 之间没有分隔线 /// ListView.separated: item 之间自动插分隔线,需额外传 separatorBuilder /// (比在 itemBuilder 里手动加 Divider 更规范) /// /// 五、高频坑: /// ListView 放进 Column/Row 必须给固定高度(SizedBox)或 Expanded, /// 否则报 "Vertical viewport was given unbounded height"。 /// ============================================================ class ListDemoPage extends StatelessWidget { const ListDemoPage({super.key}); // 演示数据(静态常量,可以写进 const 构建里) static const List _fruits = [ '苹果', '香蕉', '橙子', '葡萄', '西瓜', '草莓', '芒果', '菠萝', ]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('ListView 列表 Demo')), body: SingleChildScrollView( padding: EdgeInsets.all(16.w), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const _SectionTitle('Demo1:ListView(children) 一次性全部渲染'), Text( '混装不同组件(卡片 + ListTile + for 循环生成),' 'item 全部创建,适合 <30 条', style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)), ), SizedBox(height: 8.h), SizedBox( height: 300.h, // 关键:ListView 必须要有确定高度 child: ListView( children: [ // ① 不同种类的组件直接混装 Container( margin: EdgeInsets.only(bottom: 8.h), padding: EdgeInsets.all(12.w), decoration: BoxDecoration( color: const Color(0xFFE3F2FD), borderRadius: BorderRadius.circular(8.r), ), child: const Text( '顶部公告:今天学习 ListView!', style: TextStyle(color: Color(0xFF1565C0)), ), ), const ListTile( leading: Icon(Icons.home, color: Color(0xFF3B82F6)), title: Text('首页入口'), trailing: Icon(Icons.chevron_right), ), Divider(height: 1.h, indent: 16, endIndent: 16), // ② children 里直接写 for 循环生成同构条目 for (final fruit in _fruits) ListTile( leading: CircleAvatar( backgroundColor: const Color(0xFFEDF2FF), child: Text( fruit.substring(0, 1), style: const TextStyle(color: Color(0xFF3B82F6)), ), ), title: Text(fruit), trailing: const Icon(Icons.chevron_right), ), ], ), ), SizedBox(height: 32.h), const _SectionTitle('Demo2:ListView.separated + 下拉刷新'), Text( 'separated 自动插分隔线 + RefreshIndicator 下拉刷新,' '下拉后新数据插到顶部', style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)), ), SizedBox(height: 8.h), const _RefreshableList(), ], ), ), ); } } /// 区块标题 class _SectionTitle extends StatelessWidget { const _SectionTitle(this.title); final String title; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only(bottom: 4.h), child: Text( title, style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.bold, color: Color(0xFF333333), ), ), ); } } /// 下拉刷新 + 上拉加载列表 —— StatefulWidget 版本 /// 关键点: /// 1. RefreshIndicator 包住可滚动组件,onRefresh 必须返回 Future; /// 2. physics: AlwaysScrollableScrollPhysics() 内容不满一屏也能下拉; /// 3. ScrollController 监听滚动位置,触底前 200px 触发 _loadMore; /// 4. ScrollController 必须在 dispose() 里释放,防内存泄漏; /// 5. _isLoadingMore 防重入(滚动事件会高频触发)、_hasMore 判断是否还有下一页; /// 6. 数据项用 Dart 3 记录类型 (编号, 来源),区分初始/刷新/加载更多。 class _RefreshableList extends StatefulWidget { const _RefreshableList(); @override State<_RefreshableList> createState() => _RefreshableListState(); } class _RefreshableListState extends State<_RefreshableList> { // 滚动控制器:监听触底 final ScrollController _scrollController = ScrollController(); // 数据源:Dart3 记录 (编号, 来源标签) List<(int, String)> _items = []; int _page = 1; // 当前页码(模拟分页) bool _hasMore = true; // 是否还有下一页 bool _isLoadingMore = false; // 防止滚动事件重复触发加载 bool _showBackToTop = false; // 是否显示"回到顶部"按钮 @override void initState() { super.initState(); _items = List.generate(10, (i) => (i + 1, '初始数据')); _scrollController.addListener(_onScroll); } @override void dispose() { _scrollController.dispose(); // 必须释放,防内存泄漏 super.dispose(); } /// 滚动监听:触底加载 + 控制"回到顶部"按钮显隐 void _onScroll() { if (!_scrollController.hasClients) return; // hasClients:这个控制器有没有真正挂载到滚动组件上。 final pos = _scrollController.position; // 触底前 200px 加载下一页 if (pos.pixels >= pos.maxScrollExtent - 200) { _loadMore(); } // 滚过 200px 才显示"回到顶部"按钮(用 != 对比,避免每像素都 setState) final shouldShow = pos.pixels > 200; if (shouldShow != _showBackToTop) { setState(() => _showBackToTop = shouldShow); } } /// 平滑滚回顶部 void _scrollToTop() { _scrollController.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } /// 下拉刷新:把新数据插到列表最前面 Future _onRefresh() async { // 模拟网络请求延迟 1 秒 await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; // 页面可能已被销毁 // await 1 秒后页面可能已被用户切走,此时 context 已失效,直接操作会崩。 if (!mounted) return; 是异步回调的通用兜底。 setState(() { // 刷新:模拟从服务端拉到新数据,插到列表最前面 final newStart = _items.first.$1 + 100; _items = [ ...List.generate(5, (i) => (newStart + i, '刷新新增')), ..._items, ]; }); // 刷新完成提示 ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('刷新成功,新增了 5 条数据')), ); } /// 触底加载下一页:追加到列表末尾 Future _loadMore() async { if (_isLoadingMore || !_hasMore) return; // 防重入 _isLoadingMore = true; setState(() {}); // 先刷新,显示底部 loading await Future.delayed(const Duration(milliseconds: 800)); // 模拟网络 if (!mounted) return; setState(() { final start = _items.last.$1 + 1; _items = [..._items, ...List.generate(10, (i) => (start + i, '加载更多'))]; _page++; _hasMore = _page < 5; // 模拟:到第 5 页后没有更多数据 }); _isLoadingMore = false; } /// 列表末尾的 footer:加载中 / 没有更多 Widget _buildFooter() { return Padding( padding: EdgeInsets.symmetric(vertical: 16.h), child: Center( child: _hasMore ? SizedBox( width: 20.w, height: 20.h, child: const CircularProgressIndicator(strokeWidth: 2), ) : Text( '— 没有更多了 —', style: TextStyle(fontSize: 12.sp, color: Color(0xFF999999)), ), ), ); } @override Widget build(BuildContext context) { return Stack( children: [ SizedBox( height: 300.h, // 固定高度,否则放 Column 里会报 unbounded height child: RefreshIndicator( onRefresh: _onRefresh, child: ListView.separated( // 关键:内容不满一屏时也能触发下拉手势 physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, itemCount: _items.length + 1, // 多出的一行是 footer separatorBuilder: (_, i) => Divider(height: 1.h, indent: 16, endIndent: 16), itemBuilder: (context, index) { // 最后一行渲染 footer if (index == _items.length) return _buildFooter(); final (num, tag) = _items[index]; // 记录解构 // 按来源标签决定颜色(Dart3 switch 表达式) final Color bg = switch (tag) { '刷新新增' => const Color(0xFFE8F5E9), '加载更多' => const Color(0xFFE3F2FD), _ => const Color(0xFFFFF3E0), }; final Color fg = switch (tag) { '刷新新增' => const Color(0xFF2E7D32), '加载更多' => const Color(0xFF1565C0), _ => const Color(0xFFE65100), }; return ListTile( leading: CircleAvatar( backgroundColor: bg, child: Text('$num', style: TextStyle(color: fg)), ), title: Text('数据 #$num'), subtitle: Text(tag), ); }, ), ), ), // 回到顶部悬浮按钮:Stack 定位在列表右下角 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 动画冲突 backgroundColor: const Color(0xFF3B82F6), onPressed: _scrollToTop, child: const Icon(Icons.arrow_upward, color: Colors.white), ), ), ), ), ], ); } }