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(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 createState() => _AnimationDemoPageState(); } class _AnimationDemoPageState extends State 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 _correctScale = TweenSequence([ 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 _wrongShake = TweenSequence([ 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 _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; } }