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

316 lines
12 KiB
Dart
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.
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<String> _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<void> _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<void> _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),
),
),
),
),
],
);
}
}