flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user