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

482 lines
17 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';
/// ============================================================
/// 【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;
}
}