Files
App.K12.Student.Miniapp_Flu…/docs/demos/animation_demo.md
T
2026-09-04 15:22:20 +08:00

6.4 KiB
Raw Blame History

【Demo】动画体系(隐式 / 显式 / Hero / CustomPaint)

源文件:lib/pages/demo/animation_demo.dart | 分类:基础布局 一句话:对 CSS 学 Flutter 动画 —— 隐式=transition,显式=keyframes,加上 Hero 转场和 Canvas 自绘。


一、这个 Demo 解决什么问题

新手面对 Flutter 动画容易懵:组件太多、概念太多。本页把动画体系按"对 CSS"的方式梳理成 4 块:

动画类型 对标 CSS 一句话
① 隐式动画 transition 属性变了自己补过渡帧,零 controller
② 显式动画 keyframes controller 手动驱动,精确控制时序/曲线
③ Hero 转场 无(原生独有) 页面跳转时元素"飞过去"
④ CustomPaint canvas 用 Canvas 画任意图形

二、怎么运行 / 演示内容

runApp(const MaterialApp(home: AnimationDemoPage()));
  1. 点方块:宽/圆角/颜色三属性同时变(AnimatedContainer);
  2. 答题互动:选对"弹跳放大",选错"水平抖动"(TweenSequence 分段动画);
  3. 点封面:共享元素飞到详情页(Hero);
  4. 拖滑块:进度环实时重绘(CustomPainter drawArc)。

三、核心知识点(新手向)

1. 隐式 vs 显式(判断口诀)

  • 只需要"A 变成 B 的过渡" → 隐式(写一行 duration 完事);
  • 需要"按我的节奏动起来"(回弹、抖动、连续) → 显式 controller。
// 隐式:属性变了自动补过渡(duration 必写!漏了就瞬变)
AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeInOut,
  width: _boxExpanded ? 200.w : 120.w,
  decoration: BoxDecoration(
    color: _boxExpanded ? Color(0xFF3B82F6) : Color(0xFFFF9800),
    borderRadius: BorderRadius.circular(_boxExpanded ? 45.r : 8.r),
  ),
)

2. 显式动画五件套

// ① 页面必须 with SingleTickerProviderStateMixin
class _State extends State<Page> with SingleTickerProviderStateMixin {
  // ② controller:驱动一切(vsync 必传,duration 定义时长)
  late final AnimationController _answerCtrl = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 500),
  );

  // ③ TweenSequence:分段动画(先放大再回弹 = 两个 Tween 按权重拼)
  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);

  // ④ 触发:每次从 0 重新播放
  _answerCtrl.forward(from: 0);

  // ⑤ 红线:必须 dispose
  @override
  void dispose() {
    _answerCtrl.dispose();
    super.dispose();
  }
}

3. AnimatedBuilder 的 child 缓存

AnimatedBuilder(
  animation: _correctScale,
  child: body, // 不变的子树用 child 传进来缓存,避免 builder 反复重建
  builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child),
)

4. Hero 共享元素

// 页面 A
Hero(tag: 'hero-demo-cover', child: Container(width: 120.w, height: 72.h, ...));
// 页面 B(同一个 tag!)
Hero(tag: 'hero-demo-cover', child: Container(width: 280.w, height: 168.h, ...));

两个页面里 tag 相同的 Hero,转场时自动共享动画(封面从小图"飞"成大图)。

5. CustomPainter 自绘进度环

class _ProgressRingPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final rect = (Offset.zero & size).deflate(stroke / 2); // 内缩一个线宽
    // 背景环(画满 360°)
    canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159, false, bgPaint);
    // 前景环:起点 -90° = 12 点方向,顺时针画 progress 比例
    canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159 * progress, false, fgPaint);
  }

  @override
  bool shouldRepaint(_ProgressRingPainter oldDelegate) {
    // 参数变了才重绘(滑块拖动时)
    return oldDelegate.progress != progress || oldDelegate.color != color;
  }
}

要点:drawArc 的角度单位是弧度,-π/2 才是 12 点方向(画布 0° 在 3 点方向)。


四、关键代码剖析

抖动动画(4 段 Tween 拼出"抖三下")

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);

对错反馈的应用(按选择状态包 Transform)

if (isRight) {
  body = AnimatedBuilder(
    animation: _correctScale,
    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),
  );
}

五、踩坑记录

  1. AnimationController 必须 dispose,且页面必须 with SingleTickerProviderStateMixin(否则报 Ticker 错误);
  2. 隐式动画一定要写 duration,漏了就瞬变没有过渡;
  3. AnimatedBuilder 的 builder 里别放不变子树,用 child 参数缓存(否则每帧重建,性能浪费);
  4. shouldRepaint 要正确比较参数:不重绘或一直重绘都是错;
  5. 多个 controller 用 TickerProviderStateMixin(多 Ticker),单个用 Single 版;
  6. 同屏多个 FloatingActionButton 要 heroTag: null(见 list_demo),Hero tag 全局必须唯一,重了会断言崩溃。

六、扩展思考

  • 本项目 exam 答题页的"选对弹跳/选错抖动"就是本页思路的真实落地;
  • 显式动画进阶:AnimationController.repeat() 无限循环(loading 旋转)、addStatusListener 监听完成回调;
  • 隐式动画家族:AnimatedOpacity / AnimatedSwitcher(切换动画)/ AnimatedPositioned / AnimatedAlign —— 都是"属性变了自动补帧";
  • 性能纪律:动画组件尽量用 Transform(不触发 layout),别动 width/height 频繁重建。