Flutter隐式动画与显式动画—AnimatedWidget到AnimationController|新宇宙博客
Back to list隐式动画与显式动画
Site Owner
Published on 2026-05-22
系统讲解Flutter AnimatedContainer/AnimatedOpacity隐式动画、AnimationController显式控制及Tween组合
隐式动画与显式动画 (Implicit & Explicit Animations)
模块:六 - 动画系统
预计阅读时间:25 分钟
前言
Flutter 的动画系统是其最令人着迷的特性之一。与其他框架不同,Flutter 从底层设计了一套完整的动画架构,将"动画是什么"(描述层)与"动画如何运行"(执行层)完全分离。
理解 Flutter 动画的第一步,是搞清楚两大阵营的边界:
隐式动画(Implicit Animations) :你只需要改变目标值,Flutter 自动处理过渡
显式动画(Explicit Animations) :你完全掌控动画的播放、暂停、反转和每一帧的值
本文从底层机制出发,彻底讲透这两套系统的设计哲学、实现原理与最佳实践。
一、动画的底层基础:Ticker 与 vsync
在讲隐式/显式动画之前,必须先理解 Flutter 动画的驱动力。
1.1 Ticker:帧回调的原始驱动
Ticker 是 Flutter 动画的最底层机制。每一帧屏幕刷新时,Ticker 会触发一次回调,传入自上次开始以来的 Duration:
class MyWidget extends StatefulWidget { ... }
class _MyWidgetState extends State<MyWidget> {
late Ticker _ticker;
Duration _elapsed = Duration.zero;
@override
void initState() {
super.initState();
// 直接使用 Ticker(通常你不需要这样做)
_ticker = Ticker((elapsed) {
setState(() {
_elapsed = elapsed;
});
});
_ticker.start();
}
@override
void dispose() {
_ticker.dispose(); // 必须释放!
super.dispose();
}
}
关键理解 :Ticker 只是一个计时器,它本身不知道什么是"动画值"。AnimationController 正是在 Ticker 之上构建的,它将 elapsed 时间映射到 [0.0, 1.0] 的进度值。
1.2 vsync:防止后台动画浪费资源 vsync 参数接收一个 TickerProvider,它的核心作用是:当 Widget 不可见时,自动暂停 Ticker,避免后台消耗 CPU/GPU 资源 。
// 最常用的两种 TickerProvider mixin:
// 1. 只有一个 AnimationController 时
class _MyState extends State<MyWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // this 就是 TickerProvider
duration: const Duration(milliseconds: 300),
);
}
}
// 2. 有多个 AnimationController 时
class _MyState extends State<MyWidget> with TickerProviderStateMixin {
late AnimationController _controller1;
late AnimationController _controller2;
}
⚠️ 常见错误 :在 initState 中忘记 vsync: this,或在 dispose 中忘记 _controller.dispose(),都会导致内存泄漏和断言错误。
二、隐式动画:声明式的优雅
2.1 什么是隐式动画 隐式动画的设计哲学是:你只需声明"我想要什么状态",框架负责如何从当前状态过渡到目标状态 。
Flutter 内置了大量 AnimatedXxx Widget,当它们的属性发生变化时,会自动产生动画:
class _CardState extends State<Card> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _expanded ? 300 : 150,
height: _expanded ? 200 : 100,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.grey,
borderRadius: BorderRadius.circular(_expanded ? 20 : 8),
),
child: const Center(child: Text('点击展开')),
),
);
}
}
这段代码涉及了同时对 宽度、高度、颜色、圆角 四个属性的动画——而你完全没有写任何 AnimationController。
Widget 动画属性 适用场景 AnimatedContainer任意 Container 属性 布局变化、样式切换 AnimatedOpacityopacity淡入淡出 AnimatedPaddingpadding间距变化 AnimatedAlignalignment对齐变化 AnimatedPositioned位置(需在 Stack 内) 元素滑动定位 AnimatedDefaultTextStyle文字样式 文字大小/颜色变化 AnimatedScale缩放 放大/缩小效果 AnimatedRotation旋转角度 旋转效果 AnimatedSlide偏移 滑入滑出 AnimatedSwitcher子 Widget 切换 内容替换动画 AnimatedCrossFade两个子 Widget 交叉淡化 状态切换
2.3 AnimatedSwitcher:内容切换的利器 class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
// 自定义过渡效果
transitionBuilder: (child, animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
'$_count',
// ⚠️ key 是必须的!AnimatedSwitcher 通过 key 判断是否是新 Widget
key: ValueKey<int>(_count),
style: const TextStyle(fontSize: 40),
),
),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('+1'),
),
],
);
}
}
为什么需要 key? AnimatedSwitcher 通过比较新旧 child 的 key 来判断是否发生了变化。如果没有 key,两个相同类型的 Text Widget 会被认为是"同一个 Widget 更新了",而不是"旧 Widget 离开、新 Widget 进入"。
当内置 Widget 无法满足需求时,可以继承 ImplicitlyAnimatedWidget 来创建自己的隐式动画:
// 自定义:动画文字颜色
class AnimatedTextColor extends ImplicitlyAnimatedWidget {
const AnimatedTextColor({
super.key,
required this.color,
required this.child,
super.duration = const Duration(milliseconds: 200),
super.curve = Curves.linear,
});
final Color color;
final Widget child;
@override
AnimatedWidgetBaseState<AnimatedTextColor> createState() =>
_AnimatedTextColorState();
}
class _AnimatedTextColorState
extends AnimatedWidgetBaseState<AnimatedTextColor> {
ColorTween? _colorTween;
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
// 核心:注册需要动画的 Tween
_colorTween = visitor(
_colorTween, // 当前 Tween(首次为 null)
widget.color, // 目标值
(value) => ColorTween(begin: value as Color), // 创建 Tween 的工厂
) as ColorTween?;
}
@override
Widget build(BuildContext context) {
return DefaultTextStyle.merge(
style: TextStyle(color: _colorTween?.evaluate(animation)),
child: widget.child,
);
}
}
三、显式动画:精确控制的力量
3.1 显式动画的四要素 AnimationController ──→ Animation<T> ──→ Widget
↑ ↑
Ticker驱动 Tween + Curve
(0.0 ~ 1.0) (值变换 + 缓动)
要素一:AnimationController late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
// lowerBound 和 upperBound 默认为 0.0 和 1.0
// 也可以自定义范围,但通常保持默认
);
}
// AnimationController 的常用方法:
_controller.forward(); // 正向播放 0→1
_controller.reverse(); // 反向播放 1→0
_controller.repeat(); // 循环播放
_controller.repeat(reverse: true); // 往返循环
_controller.stop(); // 停止
_controller.reset(); // 重置到起始值
_controller.animateTo(0.5); // 动画到指定值
// 监听状态变化:
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
// 动画播放完成
} else if (status == AnimationStatus.dismissed) {
// 动画回到起始位置
}
});
要素二:Tween(值的插值器) Tween 定义了从 begin 到 end 的值变化范围:
// 基础 Tween
final tween = Tween<double>(begin: 0.0, end: 1.0);
// 常用内置 Tween:
ColorTween(begin: Colors.red, end: Colors.blue);
SizeTween(begin: Size(100, 100), end: Size(200, 200));
RectTween(begin: Rect.zero, end: Rect.fromLTWH(0, 0, 100, 100));
IntTween(begin: 0, end: 100);
BorderRadiusTween(
begin: BorderRadius.circular(0),
end: BorderRadius.circular(20),
);
// Tween 链(先应用 curve,再变换值):
final animation = Tween<double>(begin: 0.0, end: 300.0)
.chain(CurveTween(curve: Curves.elasticOut))
.animate(_controller);
要素三:Curve(缓动曲线) // 将 controller 的线性进度映射为非线性的动画进度
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut, // 正向曲线
reverseCurve: Curves.easeIn, // 反向曲线(可选)
);
// 常用内置曲线:
Curves.linear // 线性
Curves.easeIn // 先慢后快
Curves.easeOut // 先快后慢
Curves.easeInOut // 两端慢中间快
Curves.bounceOut // 弹跳回弹
Curves.elasticIn // 弹簧拉伸
Curves.elasticOut // 弹簧回弹
要素四:AnimatedBuilder(Widget 层的监听) @override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller, // 监听的 Animation
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * math.pi, // 每次都重建
child: child, // child 不随动画变化,所以传入 child 参数避免重建
);
},
child: const Icon(Icons.refresh, size: 48), // 不参与动画的子树
);
}
child 参数的优化意义 :builder 在每一帧都会调用。如果 child 是一个复杂的 Widget 树,每帧重建代价很高。通过将不变的部分传入 child 参数,框架会缓存它,builder 直接接收已建好的 Widget 实例。
3.2 完整示例:自定义加载指示器 class PulsingLoader extends StatefulWidget {
const PulsingLoader({super.key});
@override
State<PulsingLoader> createState() => _PulsingLoaderState();
}
class _PulsingLoaderState extends State<PulsingLoader>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true); // 创建并立即开始往返循环
// 缩放:0.5 → 1.0,使用弹性曲线
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
// 透明度:0.3 → 1.0,与缩放同步
_opacityAnimation = Tween<double>(begin: 0.3, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose(); // 必须!否则内存泄漏
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
child: Container(
width: 60,
height: 60,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
builder: (context, child) {
return Opacity(
opacity: _opacityAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: child,
),
);
},
);
}
}
3.3 使用 AnimationController.value 监听器 除了 AnimatedBuilder,还可以使用 addListener 手动触发 setState:
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: ...)
..addListener(() {
setState(() {}); // 触发 rebuild
});
}
方式 重建范围 推荐度 addListener + setState整个 build 方法 仅适合简单场景 AnimatedBuilder仅 builder 函数内 ✅ 推荐,粒度更小 AnimationController + 直接读值不触发重建(手动控制) 进阶用法
四、隐式 vs 显式:如何选择?
决策树 需要动画吗?
│
▼
是否需要:
- 精确控制播放时机(暂停/恢复)?
- 循环/往返播放?
- 与手势交互联动(如拖拽进度)?
- 多个动画协调(交错动画)?
- 监听动画完成事件?
│
YES → 显式动画(AnimationController)
│
NO → 隐式动画(AnimatedXxx)
实践原则
优先考虑隐式动画 :代码量少,几乎不会有内存泄漏风险
当 AnimatedXxx 满足需求时,永远不要手写 AnimationController
显式动画必须成对处理生命周期 :initState 创建 → dispose 释放
五、TweenAnimationBuilder:隐式与显式的桥梁 TweenAnimationBuilder 是一个特殊的 Widget,它让你无需 State 就能使用 Tween ,是隐式动画的高级形式:
class ColorAnimationDemo extends StatefulWidget {
const ColorAnimationDemo({super.key});
@override
State<ColorAnimationDemo> createState() => _ColorAnimationDemoState();
}
class _ColorAnimationDemoState extends State<ColorAnimationDemo> {
Color _targetColor = Colors.blue;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<Color?>(
tween: ColorTween(begin: Colors.grey, end: _targetColor),
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
builder: (context, color, child) {
return Container(
width: 200,
height: 200,
color: color,
child: child,
);
},
child: TextButton(
onPressed: () => setState(() {
_targetColor = _targetColor == Colors.blue
? Colors.orange
: Colors.blue;
}),
child: const Text('切换颜色'),
),
);
}
}
TweenAnimationBuilder 的工作原理 :
当 tween 的 end 值变化时,自动从当前值动画到新的目标值
不需要 AnimationController 和 mixin
适合单次触发 的动画(进入动画、状态切换)
六、性能最佳实践
6.1 使用 const 构造函数减少重建 // ❌ 每帧都重建 Icon
AnimatedBuilder(
animation: _controller,
builder: (context, _) => Row(
children: [
Transform.rotate(
angle: _controller.value * 2 * pi,
child: Icon(Icons.refresh), // 每帧重建
),
],
),
);
// ✅ Icon 只构建一次
AnimatedBuilder(
animation: _controller,
child: const Icon(Icons.refresh), // 只构建一次
builder: (context, child) => Row(
children: [
Transform.rotate(
angle: _controller.value * 2 * pi,
child: child, // 复用已构建的 Widget
),
],
),
);
// ❌ AnimatedContainer 改变 size 会触发 layout pass
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: _expanded ? 200 : 100, // 触发重新布局
height: _expanded ? 200 : 100,
);
// ✅ Transform.scale 只在合成阶段处理,不触发 layout
AnimatedBuilder(
animation: _scaleAnimation,
child: Container(width: 200, height: 200, color: Colors.blue),
builder: (context, child) => Transform.scale(
scale: _scaleAnimation.value,
child: child, // 跳过 layout,直接在 GPU 合成
),
);
原则:优先使用 Transform、Opacity 等只影响合成阶段的属性 ,避免动画中触发 layout。
七、实战案例:卡片展开效果 综合运用隐式动画 + 显式动画实现一个带层次感的卡片展开:
class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key, required this.title, required this.content});
final String title;
final String content;
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard>
with SingleTickerProviderStateMixin {
bool _expanded = false;
late AnimationController _controller;
late Animation<double> _rotateAnimation; // 箭头旋转
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
_rotateAnimation = Tween<double>(begin: 0.0, end: 0.5).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_expanded = !_expanded;
_expanded ? _controller.forward() : _controller.reverse();
});
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
// 隐式动画:高度和圆角
height: _expanded ? 200 : 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(_expanded ? 0.15 : 0.05),
blurRadius: _expanded ? 20 : 4,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
InkWell(
onTap: _toggle,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// 显式动画:箭头旋转
RotationTransition(
turns: _rotateAnimation,
child: const Icon(Icons.keyboard_arrow_down),
),
],
),
),
),
// AnimatedOpacity:内容淡入淡出
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _expanded ? 1.0 : 0.0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(widget.content),
),
),
],
),
);
}
}
八、过关自测 问题一 :为什么 AnimationController 必须在 dispose 中调用 .dispose()?
AnimationController 内部持有一个 Ticker,Ticker 持有对 TickerProvider(即 State)的引用。如果不显式 dispose,即使 Widget 已经从树中移除,State 对象也不会被 GC,Ticker 会继续占用调度资源,最终导致内存泄漏和"A RenderObject was disposed with an active Ticker" 断言。
vsync 接收 TickerProvider,当 Widget 对应的 Route 不在前台(如被遮挡或切到后台)时,TickerProvider 会自动停止 Ticker,从而让 AnimationController 暂停,防止后台 UI 继续消耗 CPU/GPU 资源。
问题三 :AnimatedSwitcher 切换时子 Widget 没有动画,通常是什么原因?
最常见原因是缺少 key 。AnimatedSwitcher 通过对比新旧 child 的 runtimeType 和 key 来判断是否需要切换动画。若两个 child 是相同类型且没有 key,框架会认为是同一个 Widget 属性更新,不会触发切换动画。
总结 特性 隐式动画 显式动画 代码量 少 多 控制粒度 低(自动) 高(完全手动) 生命周期管理 框架负责 开发者负责 适合场景 状态变化触发的过渡 循环动画、手势联动、交错动画 内存泄漏风险 低 需手动 dispose 典型 API AnimatedContainer、AnimatedOpacityAnimationController、AnimatedBuilder
Flutter 动画的最高境界是混合使用 :在合适的地方用隐式动画降低复杂度,在需要精确控制的地方用显式动画发挥全部威力。下一篇我们将在这个基础上,探讨更复杂的交错动画与物理动画 。