482 lines
17 KiB
Dart
482 lines
17 KiB
Dart
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;
|
||
}
|
||
}
|