动画:隐式动画与 AnimationController

动画在 Flutter 里不是额外的库,而是渲染管线的一部分:只要某个属性在两次 build 之间变了值,就能让它按时间插值过渡。绝大多数“界面动一下”的需求用隐式动画组件就够了,只有需要重复播放、中途暂停、与手势联动的场景才需要 AnimationController。本章先分类再给两套模板代码,最后讲 Hero 转场的坑与动画性能的边界。

动画的四种类型

类型代表适用特点
隐式动画AnimatedContainerAnimatedOpacity状态变化引起的过渡只改属性,框架负责插值,代码最少
显式动画AnimationController + AnimatedBuilder循环、暂停、跟随手势完全可控,需要自己管理生命周期
物理动画ScrollPhysicsSpringSimulation甩动、回弹、拖拽释放手感自然,参数实测调优
转场动画HeroPageRouteBuilder页面之间的元素延续表达层级关系,别过度使用

选择顺序:能隐式就不显式;能用 Hero 表达“同一物体放大”就不要写两个页面的自定义过渡。

隐式动画:只改状态

class ExpandBox extends StatefulWidget {
  const ExpandBox({super.key});
  @override
  State<ExpandBox> createState() => _ExpandBoxState();
}

class _ExpandBoxState extends State<ExpandBox> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _expanded = !_expanded), // 只改状态,插值交给组件
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOutCubic,
        width: _expanded ? 240 : 120,
        height: _expanded ? 160 : 60,
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.primary,
          borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
        ),
      ),
    );
  }
}

同一个思路可以覆盖大部分交互反馈:AnimatedOpacity 做淡入淡出,AnimatedSwitcher 做内容替换(务必给子 Widget 一个会变化的 key,否则框架认不出换了内容),AnimatedAlignAnimatedPaddingAnimatedScale 同理。

隐式动画组件对照表

组件动画属性典型场景注意点
AnimatedContainer尺寸、颜色、内边距、圆角展开收起、选中态只能用于 Container 支持的属性
AnimatedOpacity透明度淡入淡出、禁用态透明度为 0 的子树仍参与布局,别当 Visibility
AnimatedSwitcher子树切换数字变化、加载态切换子节点需要 key,默认淡入淡出
AnimatedList增删条目聊天、待办增删必须同时改数据与调用 insertItem/removeItem
TweenAnimationBuilder任意数值进度条、计数器无需建 State,tween 变化时从当前值续播
AnimatedCrossFade两个子树互切骨架屏 ↔ 内容两棵子树都会构建,注意开销

AnimationController 四件套

vsyncdurationforwarddispose 是显式动画的四件套,缺一个都会出问题:没有 vsync 无法绑定帧回调,没有 dispose 会泄漏 Ticker。

class LikeButton extends StatefulWidget {
  const LikeButton({super.key});
  @override
  State<LikeButton> createState() => _LikeButtonState();
}

class _LikeButtonState extends State<LikeButton> with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this, // 必须传 this,State 通过 mixin 提供 TickerProvider
    duration: const Duration(milliseconds: 800),
  );
  late final Animation<double> _scale = Tween<double>(begin: 1.0, end: 1.3).animate(
    CurvedAnimation(parent: _controller, curve: Curves.elasticOut), // 曲线单独加,方便替换
  );

  @override
  void dispose() {
    _controller.dispose(); // 不 dispose 会一直占用 Ticker,页面退出后仍在跑
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => _controller.forward(from: 0), // 从 0 播到 1,重复点击从头再来
      child: AnimatedBuilder(
        animation: _scale,
        builder: (context, child) => Transform.scale(scale: _scale.value, child: child),
        child: const Icon(Icons.favorite, size: 48), // child 只构建一次,下一节细说
      ),
    );
  }
}

AnimatedBuilder 的关键在于 child 参数:它会被原样传给 builder 且不参与重建。把不随动画变化的子树放进去(图标、文字、图片),每帧只重跑 Transform,这是显式动画最常见的性能优化。

页面转场:Hero

// 列表页
Hero(tag: 'cover-${item.id}', child: Image.network(item.cover, width: 80));

// 详情页:tag 必须与上一页完全一致
Hero(tag: 'cover-${item.id}', child: Image.network(item.cover));

Hero 的 tag 在当前路由栈中必须唯一:长列表里同一页出现两个相同 tag 会直接抛异常,所以一定要把 id 拼进去;两个页面的图片尺寸不一致时,用 FittedBox+ClipRect 包裹可以减少形变。

性能与设计尺度

  • 不要在动画回调里 setStateAnimationController 每帧通知,setState 会让整棵子树按帧重建,改用 AnimatedBuilder 且只包住变化的那一小块。
  • 高频重绘的区域加 RepaintBoundary,把动画层与列表内容隔离,避免一个动画带动整屏重绘。
  • 长列表+滚动动画时优先用 Transform/Opacity 这类只影响绘制阶段的属性,避免改 width/height 触发重新布局。
  • 动画是为了表达层级与因果:点击卡片后它放大成详情页,用户才理解“这是同一个东西”。纯粹为了好看而加动画,会拖慢操作节奏。
  • 时长参考:小反馈 100200 ms,页面切换 250350 ms,超过 500 ms 用户就会觉得卡。

常见坑

现象正确做法
忘记 dispose 控制器页面退出后动画仍在跑,内存上涨控制器一律在 dispose 里释放
AnimatedSwitcher 子节点没有 key内容换了却不动画给子节点一个随内容变化的 key
vsync 传了别的对象运行时报 TickerProvider 相关异常State 里 with SingleTickerProviderStateMixin 后传 this
多个控制器共用 SingleTicker第二个控制器抛异常多个控制器改用 TickerProviderStateMixin
builder 里做重活掉帧、CPU 飙高不变的子树放进 AnimatedBuilderchild
Hero 的 tag 重复路由跳转直接抛异常tag 里拼上数据 id 保证唯一

小结:先判断需求属于隐式、显式、物理还是转场,能隐式就不要显式;隐式记“改状态 + 组件插值”,显式记 vsyncdurationforwarddispose 四件套并用 CurvedAnimation 加曲线;Hero 的 tag 必须唯一,动画区域用 RepaintBoundaryAnimatedBuilderchild 控制重建范围,时长控制在 300 ms 上下最舒服。

笔记加载中…