动画:隐式动画与 AnimationController
动画在 Flutter 里不是额外的库,而是渲染管线的一部分:只要某个属性在两次 build 之间变了值,就能让它按时间插值过渡。绝大多数“界面动一下”的需求用隐式动画组件就够了,只有需要重复播放、中途暂停、与手势联动的场景才需要 AnimationController。本章先分类再给两套模板代码,最后讲 Hero 转场的坑与动画性能的边界。
动画的四种类型
| 类型 | 代表 | 适用 | 特点 |
|---|---|---|---|
| 隐式动画 | AnimatedContainer、AnimatedOpacity | 状态变化引起的过渡 | 只改属性,框架负责插值,代码最少 |
| 显式动画 | AnimationController + AnimatedBuilder | 循环、暂停、跟随手势 | 完全可控,需要自己管理生命周期 |
| 物理动画 | ScrollPhysics、SpringSimulation | 甩动、回弹、拖拽释放 | 手感自然,参数实测调优 |
| 转场动画 | Hero、PageRouteBuilder | 页面之间的元素延续 | 表达层级关系,别过度使用 |
选择顺序:能隐式就不显式;能用 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,否则框架认不出换了内容),AnimatedAlign、AnimatedPadding、AnimatedScale 同理。
隐式动画组件对照表
| 组件 | 动画属性 | 典型场景 | 注意点 |
|---|---|---|---|
AnimatedContainer | 尺寸、颜色、内边距、圆角 | 展开收起、选中态 | 只能用于 Container 支持的属性 |
AnimatedOpacity | 透明度 | 淡入淡出、禁用态 | 透明度为 0 的子树仍参与布局,别当 Visibility 用 |
AnimatedSwitcher | 子树切换 | 数字变化、加载态切换 | 子节点需要 key,默认淡入淡出 |
AnimatedList | 增删条目 | 聊天、待办 | 增删必须同时改数据与调用 insertItem/removeItem |
TweenAnimationBuilder | 任意数值 | 进度条、计数器 | 无需建 State,tween 变化时从当前值续播 |
AnimatedCrossFade | 两个子树互切 | 骨架屏 ↔ 内容 | 两棵子树都会构建,注意开销 |
AnimationController 四件套
vsync、duration、forward 与 dispose 是显式动画的四件套,缺一个都会出问题:没有 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 包裹可以减少形变。
性能与设计尺度
- 不要在动画回调里
setState:AnimationController每帧通知,setState会让整棵子树按帧重建,改用AnimatedBuilder且只包住变化的那一小块。 - 高频重绘的区域加
RepaintBoundary,把动画层与列表内容隔离,避免一个动画带动整屏重绘。 - 长列表+滚动动画时优先用
Transform/Opacity这类只影响绘制阶段的属性,避免改width/height触发重新布局。 - 动画是为了表达层级与因果:点击卡片后它放大成详情页,用户才理解“这是同一个东西”。纯粹为了好看而加动画,会拖慢操作节奏。
- 时长参考:小反馈 100
200 ms,页面切换 250350 ms,超过 500 ms 用户就会觉得卡。
常见坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
忘记 dispose 控制器 | 页面退出后动画仍在跑,内存上涨 | 控制器一律在 dispose 里释放 |
AnimatedSwitcher 子节点没有 key | 内容换了却不动画 | 给子节点一个随内容变化的 key |
vsync 传了别的对象 | 运行时报 TickerProvider 相关异常 | State 里 with SingleTickerProviderStateMixin 后传 this |
多个控制器共用 SingleTicker | 第二个控制器抛异常 | 多个控制器改用 TickerProviderStateMixin |
在 builder 里做重活 | 掉帧、CPU 飙高 | 不变的子树放进 AnimatedBuilder 的 child |
Hero 的 tag 重复 | 路由跳转直接抛异常 | tag 里拼上数据 id 保证唯一 |
小结:先判断需求属于隐式、显式、物理还是转场,能隐式就不要显式;隐式记“改状态 + 组件插值”,显式记 vsync、duration、forward、dispose 四件套并用 CurvedAnimation 加曲线;Hero 的 tag 必须唯一,动画区域用 RepaintBoundary 与 AnimatedBuilder 的 child 控制重建范围,时长控制在 300 ms 上下最舒服。