flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+481
View File
@@ -0,0 +1,481 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【AnimationDemoPage】Flutter 动画体系 Demo(4 个区域)
///
/// 一、动画体系总览(对标 CSS):
/// 1. 隐式动画(ImplicitlyAnimatedWidget) = CSS transition:
/// 只要属性值变化,组件自己补过渡帧。如 AnimatedContainer/
/// AnimatedOpacity/AnimatedSwitcher。零 controller,最简单。
/// 2. 显式动画(AnimationController) = CSS keyframes:
/// controller 手动驱动(forward/repeat),配合 Tween 定义
/// 起始值曲线,可精确控制时长/曲线/监听状态。最灵活。
/// 3. Hero 共享元素转场 = 页面跳转时元素"飞过去"
/// 4. CustomPaint 自绘 = 用 Canvas 画任意图形
///
/// 二、为什么 Flutter 动画要区分隐式/显式:
/// - 隐式适合"状态切换"(点选变绿、展开收起)——写一行 duration 即可;
/// - 显式适合"连续/回弹/自定义曲线"(答题对错弹跳、点赞缩放)——
/// 需要 controller 控制时序;它是单方向的值驱动,方向/起点自己定。
/// 判断口诀:只需要"A 变成 B 的过渡"→ 隐式;
/// 需要"按我的节奏动起来"→ 显式 controller。
///
/// 三、核心类速查:
/// - AnimationController(vsync: this):驱动一切,durations 定义时长;
/// vsync 必须传(页面 State with SingleTickerProviderStateMixin)
/// - `Tween<double>(begin, end)`:定义 0→1 之间插值出的值
/// - CurvedAnimation / CurveTween:套曲线(easeOut/bounceOut...)
/// - AnimatedBuilder(animation, builder):监听 controller 重建子树
/// - TweenSequence:分段动画(先弹大再回弹 = 两个 Tween 按权重拼)
/// - Hero(tag, child):页面 A/B 里同 tag 的两个组件自动共享转场
///
/// 四、高频坑:
/// 1. AnimationController 必须在 dispose 里 dispose,且页面必须
/// with SingleTickerProviderStateMixin(否则报 Ticker 错误);
/// 2. 隐式动画组件一定要写 duration,漏了就瞬变没有过渡;
/// 3. AnimatedBuilder 的 builder 里别放不会变的子组件(重绘浪费),
/// 不变的子树用 child 参数缓存;
/// 4. CustomPainter 的 shouldRepaint 要正确比较参数,否则 Slider
/// 拖动时不重绘(或一直重绘)。
/// ============================================================
class AnimationDemoPage extends StatefulWidget {
const AnimationDemoPage({super.key});
@override
State<AnimationDemoPage> createState() => _AnimationDemoPageState();
}
class _AnimationDemoPageState extends State<AnimationDemoPage>
with SingleTickerProviderStateMixin {
// ==================== ① 隐式动画状态 ====================
bool _boxExpanded = false; // 方块是否展开
// ==================== ② 显式动画(答题反馈) ====================
/// 唯一 controller:驱动对/错两套动画(forward 到 1.0)
late final AnimationController _answerCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
/// 选对:放大 1.0 → 1.25 → 回弹到 1.0(TweenSequence 分段)
late final Animation<double> _correctScale = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 1.25)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 40,
),
TweenSequenceItem(
tween: Tween(begin: 1.25, end: 1.0)
.chain(CurveTween(curve: Curves.bounceOut)),
weight: 60,
),
]).animate(_answerCtrl);
/// 选错:水平抖动 -8 → 8 → -6 → 0
late final Animation<double> _wrongShake = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 0.0, end: -8.0)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 20,
),
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: 8.0, end: -6.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: -6.0, end: 0.0), weight: 20),
]).animate(_answerCtrl);
static const String _question = '属性变化自动产生过渡动画,用哪个组件?';
static const List<String> _options = [
'A. AnimationController',
'B. AnimatedContainer',
'C. CustomPaint',
'D. Hero',
];
static const int _rightIndex = 1; // 正确答案 B
int? _selected; // 已选选项(null = 未选)
bool? _isRight; // 本次选择对不对
// ==================== ④ CustomPaint 进度环状态 ====================
double _ringProgress = 0.76; // 0~1,配合 Slider 拖动演示实时重绘
@override
void dispose() {
// 显式动画红线:controller 必须释放
_answerCtrl.dispose();
super.dispose();
}
/// 点击选项:记录选择并触发对应反馈动画
void _onPick(int index) {
setState(() {
_selected = index;
_isRight = index == _rightIndex;
});
_answerCtrl.forward(from: 0); // 每次从 0 重新播放
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('动画体系 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('① 隐式动画 = CSS transition'),
_buildImplicitCard(),
SizedBox(height: 20.h),
_buildSectionTitle('② 显式动画 = 答题反馈(controller + TweenSequence)'),
_buildAnswerCard(),
SizedBox(height: 20.h),
_buildSectionTitle('③ Hero 共享元素转场'),
_buildHeroCard(),
SizedBox(height: 20.h),
_buildSectionTitle('④ CustomPaint 自绘进度环'),
_buildRingCard(),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF3B82F6),
),
);
}
// ==================== ① 隐式动画卡片 ====================
/// AnimatedContainer:属性(宽/圆角/颜色)变化自动补过渡帧
Widget _buildImplicitCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('点击方块:宽 / 圆角 / 颜色 三处属性同时变,'
'AnimatedContainer 自动补过渡'),
SizedBox(height: 12.h),
Center(
child: GestureDetector(
onTap: () => setState(() => _boxExpanded = !_boxExpanded),
child: AnimatedContainer(
// 核心:duration 必须写,否则"瞬变"没有过渡
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _boxExpanded ? 200.w : 120.w,
height: 90.h,
decoration: BoxDecoration(
color: _boxExpanded
? const Color(0xFF3B82F6)
: const Color(0xFFFF9800),
borderRadius: BorderRadius.circular(_boxExpanded ? 45.r : 8.r),
),
alignment: Alignment.center,
child: Text(
_boxExpanded ? '展开了' : '点我',
style: TextStyle(fontSize: 14.sp, color: Colors.white),
),
),
),
),
],
),
);
}
// ==================== ② 显式动画卡片(答题反馈) ====================
Widget _buildAnswerCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_question,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 12.h),
for (int i = 0; i < _options.length; i++) ...[
_buildOption(i),
if (i < _options.length - 1) SizedBox(height: 8.h),
],
SizedBox(height: 12.h),
if (_selected != null)
Text(
_isRight == true ? '✅ 回答正确' : '❌ 回答错误',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: _isRight == true
? const Color(0xFF22C55E)
: const Color(0xFFEF4444),
),
),
],
),
);
}
Widget _buildOption(int index) {
final isSelected = _selected == index;
final isRight = isSelected && _isRight == true;
final isWrong = isSelected && _isRight == false;
// 选项本体(颜色由选中/对错决定)
Widget body = Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
decoration: BoxDecoration(
color: isRight
? const Color(0xFFE8F7EE)
: isWrong
? const Color(0xFFFDECEA)
: Colors.white,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isRight
? const Color(0xFF22C55E)
: isWrong
? const Color(0xFFEF4444)
: const Color(0xFFE5E7EB),
),
),
child: Row(
children: [
Icon(
isRight
? Icons.check_circle
: isWrong
? Icons.cancel
: Icons.radio_button_unchecked,
size: 18,
color: isRight
? const Color(0xFF22C55E)
: isWrong
? const Color(0xFFEF4444)
: const Color(0xFF9CA3AF),
),
SizedBox(width: 8.w),
Text(
_options[index],
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF333333),
),
),
],
),
);
// 对:用 AnimatedBuilder 包 Transform.scale 做"弹跳放大"
if (isRight) {
body = AnimatedBuilder(
animation: _correctScale,
// child 缓存不变的子树,避免 builder 反复重建文字
child: body,
builder: (context, child) =>
Transform.scale(scale: _correctScale.value, child: child),
);
}
// 错:水平抖动
if (isWrong) {
body = AnimatedBuilder(
animation: _wrongShake,
child: body,
builder: (context, child) =>
Transform.translate(offset: Offset(_wrongShake.value, 0), child: child),
);
}
return GestureDetector(onTap: () => _onPick(index), child: body);
}
// ==================== ③ Hero 转场卡片 ====================
Widget _buildHeroCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('点击封面 → 共享元素飞到详情页(Hero 同 tag 配对)'),
SizedBox(height: 12.h),
Center(
child: GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const _HeroDetailPage(),
),
),
child: Hero(
// 页面 A/B 中 tag 相同 → 转场时自动共享动画
tag: 'hero-demo-cover',
child: Container(
width: 120.w,
height: 72.h,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF3B82F6), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(12.r),
),
alignment: Alignment.center,
child: const Icon(Icons.school, color: Colors.white, size: 32),
),
),
),
),
],
),
);
}
// ==================== ④ CustomPaint 进度环卡片 ====================
Widget _buildRingCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('CustomPainter 画圆弧 + 居中文字(拖动滑块实时重绘)'),
SizedBox(height: 12.h),
Center(
child: SizedBox(
width: 120.w,
height: 120.w,
child: CustomPaint(
painter: _ProgressRingPainter(
progress: _ringProgress,
color: const Color(0xFF3B82F6),
),
child: Center(
child: Text(
'${(_ringProgress * 100).round()}%',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: const Color(0xFF333333),
),
),
),
),
),
),
SizedBox(height: 8.h),
Slider(
value: _ringProgress,
onChanged: (v) => setState(() => _ringProgress = v),
),
],
),
);
}
}
/// Hero 详情页:同 tag 的大图(转场时封面"飞"到这里的尺寸)
class _HeroDetailPage extends StatelessWidget {
const _HeroDetailPage();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Hero 详情页')),
body: Center(
child: Hero(
tag: 'hero-demo-cover',
child: Container(
width: 280.w,
height: 168.h,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF3B82F6), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(16.r),
),
alignment: Alignment.center,
child: const Icon(Icons.school, color: Colors.white, size: 64),
),
),
),
);
}
}
/// 自绘进度环:CustomPainter 在 Canvas 上画两段圆弧
class _ProgressRingPainter extends CustomPainter {
const _ProgressRingPainter({required this.progress, required this.color});
/// 进度 0.0 ~ 1.0
final double progress;
final Color color;
@override
void paint(Canvas canvas, Size size) {
// 圆环几何:内缩一个线宽,让笔画完整落在画布内
const stroke = 10.0;
final rect = Offset.zero & size;
final arcRect = rect.deflate(stroke / 2);
// 背景环(浅灰,画满 360°)
final bgPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..color = const Color(0xFFE5E7EB);
canvas.drawArc(arcRect, -0.5 * 3.14159, 2 * 3.14159, false, bgPaint);
// 前景环(从 12 点方向顺时针画 progress 的比例)
final fgPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round // 圆头端点
..color = color;
canvas.drawArc(
arcRect,
-0.5 * 3.14159, // 起点:-90° = 12 点方向
2 * 3.14159 * progress,
false,
fgPaint,
);
}
/// 滑块拖动时参数变了才重绘(返回 true = 重绘)
@override
bool shouldRepaint(_ProgressRingPainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}