显式动画
显式动画(Explicit Animation)需要你手动控制 AnimationController,适合需要精确控制的动画场景。
一句话理解显式动画
想象你在做定格动画——每一帧画面由你决定,播放、暂停、倒放、循环,全由你控制。显式动画就是这种"手动挡"模式。
本章内容
- 显式动画的基本结构(AnimationController + Tween + AnimatedBuilder)
- AnimationController 的常用方法
- Tween 类型与组合动画
- AnimatedWidget 简化写法
- 隐式动画 vs 显式动画的对比
学习建议
如果你还没看过 隐式动画,建议先学习那一章。隐式动画更简单,能帮你建立对 Flutter 动画的基本理解。只有当隐式动画满足不了需求时,才需要用到显式动画。
为什么需要显式动画?
隐式动画很方便,但它有明显的限制:
| 需求 | 隐式动画 | 显式动画 |
|---|---|---|
| 简单过渡(变大变小) | ✅ | ✅ |
| 循环播放(如旋转加载) | ❌ | ✅ |
| 精确控制播放/暂停/倒放 | ❌ | ✅ |
| 多个动画同步协调 | ❌ | ✅ |
| 监听动画的每一帧 | ❌ | ✅ |
| 自定义动画值的变化范围 | 有限 | ✅ |
结论:当隐式动画"力不从心"时,就该上显式动画了。
基本结构(四步走)
显式动画的代码结构可以归纳为四步:
第 1 步:混入 SingleTickerProviderStateMixin —— 提供 vsync 信号
第 2 步:创建 AnimationController —— 控制动画的播放
第 3 步:用 Tween 定义动画值范围 —— 定义从什么值变到什么值
第 4 步:用 AnimatedBuilder 构建 UI —— 每一帧重建 UI完整示例:点击放大缩小
class ScaleAnimationDemo extends StatefulWidget {
const ScaleAnimationDemo({super.key});
@override
State<ScaleAnimationDemo> createState() => _ScaleAnimationDemoState();
}
class _ScaleAnimationDemoState extends State<ScaleAnimationDemo>
with SingleTickerProviderStateMixin { // 第 1 步:混入
late AnimationController _controller; // 第 2 步:控制器
late Animation<double> _animation; // 第 3 步:动画对象
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // vsync 由 Mixin 提供
duration: const Duration(seconds: 1), // 动画时长
);
// 用 Tween 定义值范围:从 0.0 到 1.0
_animation = Tween<double>(begin: 0, end: 1).animate(_controller);
// 监听动画完成
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
print('动画播放完成');
}
});
}
@override
void dispose() {
_controller.dispose(); // 必须释放!
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedBuilder( // 第 4 步:构建 UI
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: _animation.value, // 使用动画值
child: child,
);
},
child: const Icon(Icons.star, size: 100, color: Colors.amber),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
if (_controller.isCompleted) {
_controller.reverse(); // 已完成 → 反向播放
} else {
_controller.forward(); // 未完成 → 正向播放
}
},
child: const Text('播放动画'),
),
],
);
}
}四步详解
第 1 步:SingleTickerProviderStateMixin
- 它的作用是给
AnimationController提供 vsync 信号(垂直同步) - 简单理解:它告诉系统"我在屏幕上,请给我刷新信号"
- 如果页面不可见(比如切到后台),它会自动暂停动画,避免浪费资源
第 2 步:AnimationController
- 它是动画的"遥控器"——播放、暂停、倒放、循环都由它控制
- 默认值范围是
0.0 ~ 1.0,代表动画的进度
第 3 步:Tween
- 它定义了动画值的起止范围,比如"从 0 变到 100"、"从红色变到蓝色"
- 它把 Controller 的
0.0~1.0映射到你的实际值范围
第 4 步:AnimatedBuilder
- 它监听动画的每一帧变化,自动调用
builder重建 UI child参数放不依赖动画的部分,避免不必要的重建(性能优化)
AnimationController 详解
常用方法
_controller.forward(); // 从头播放到结束
_controller.reverse(); // 从当前位置反向播放
_controller.repeat(); // 循环播放
_controller.stop(); // 停止(保持当前位置)
_controller.reset(); // 重置到起始位置(不播放)
_controller.dispose(); // 释放资源(必须在 dispose 中调用)控制器值与状态
_controller.value // 当前值(0.0 ~ 1.0)
_controller.isAnimating // 是否正在播放
_controller.isCompleted // 是否播放完成
_controller.isDismissed // 是否在起始位置事件监听
// 监听动画状态变化
_controller.addStatusListener((status) {
switch (status) {
case AnimationStatus.dismissed: // 在起始位置
break;
case AnimationStatus.forward: // 正在正向播放
break;
case AnimationStatus.reverse: // 正在反向播放
break;
case AnimationStatus.completed: // 播放完成
break;
}
});
// 监听每一帧(慎用,性能敏感)
_controller.addListener(() {
print('当前值: ${_controller.value}');
});addListener vs AnimatedBuilder
addListener+setState():手动刷新 UI,性能差,不推荐AnimatedBuilder:自动重建,性能好,推荐
除非你需要做非 UI 的事情(比如同步另一个控制器),否则优先使用 AnimatedBuilder。
自定义值范围
AnimationController 默认值范围是 0.0 ~ 1.0。你也可以自定义:
// 默认:0.0 ~ 1.0
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
// 自定义:0 ~ 100
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
lowerBound: 0,
upperBound: 100,
);推荐做法
更推荐保持默认的 0.0 ~ 1.0,然后用 Tween 来映射到实际值范围。这样代码更清晰,也更容易复用。
repeat() 进阶用法
// 基本循环
_controller.repeat();
// 循环 3 次后停止
_controller.repeat(count: 3);
// 来回循环(正播 → 反播 → 正播 → ...)
_controller.repeat(reverse: true);Tween 类型
Tween 定义了动画值从起点到终点的范围。不同类型的数据有不同的 Tween:
| Tween 类型 | 起止值类型 | 示例 |
|---|---|---|
Tween<double> | 数值 | Tween(begin: 0.0, end: 1.0) |
IntTween | 整数 | IntTween(begin: 0, end: 255) |
ColorTween | 颜色 | ColorTween(begin: Colors.red, end: Colors.blue) |
OffsetTween | 偏移 | OffsetTween(begin: Offset.zero, end: Offset(100, 100)) |
SizeTween | 尺寸 | SizeTween(begin: Size(50, 50), end: Size(200, 200)) |
RectTween | 矩形 | RectTween(begin: Rect.fromLTWH(0, 0, 50, 50), end: ...) |
AlignmentTween | 对齐 | AlignmentTween(begin: Alignment.topLeft, end: Alignment.bottomRight) |
颜色动画示例
late Animation<Color?> _colorAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
_colorAnimation = ColorTween(begin: Colors.red, end: Colors.blue)
.animate(_controller);
_controller.repeat(reverse: true); // 来回循环
}
// 在 build 中
AnimatedBuilder(
animation: _colorAnimation,
builder: (context, child) {
return Container(
width: 100,
height: 100,
color: _colorAnimation.value, // 颜色从红渐变到蓝
);
},
)组合动画
很多时候我们需要多个属性同时动画,而且每个属性用不同的曲线。这就需要 CurvedAnimation:
示例:大小和透明度同时变化,但曲线不同
late AnimationController _controller;
late Animation<double> _sizeAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
// 大小:先快后慢
_sizeAnimation = Tween<double>(begin: 50, end: 200).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOut, // 先快后慢
),
);
// 透明度:先慢后快
_opacityAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeIn, // 先慢后快
),
);
_controller.forward();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller, // 只需监听 controller
builder: (context, child) {
return Opacity(
opacity: _opacityAnimation.value,
child: SizedBox(
width: _sizeAnimation.value,
height: _sizeAnimation.value,
child: child,
),
);
},
child: const Icon(Icons.star, size: 100, color: Colors.amber),
);
}CurvedAnimation 的原理
CurvedAnimation 就像一个"滤镜"——它把 Controller 线性的 0→1 进度,通过曲线函数变换成非线性的进度。这样同一个 Controller 可以驱动多个动画,每个动画的"速度感"不同。
交错动画(Staggered Animation)
如果你想让多个动画依次开始(而不是同时),可以用 Interval:
// 第一个动画:0% ~ 60% 的时间内完成
_firstAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
),
);
// 第二个动画:40% ~ 100% 的时间内完成
// (和第一个有 20% 的重叠,产生交错效果)
_secondAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.4, 1.0, curve: Curves.easeOut),
),
);Interval(begin, end) 表示动画在总时间轴的哪个区间内播放。0.0 是起点,1.0 是终点。
AnimatedWidget 简化写法
如果整个 Widget 都依赖动画,可以继承 AnimatedWidget 来简化代码。它的好处是不需要手写 AnimatedBuilder:
// 定义一个旋转图标的 Widget
class SpinningIcon extends AnimatedWidget {
const SpinningIcon({super.key, required Animation<double> animation})
: super(listenable: animation);
Animation<double> get _progress => listenable as Animation<double>;
@override
Widget build(BuildContext context) {
return Transform.rotate(
angle: _progress.value * 2 * 3.14159, // 0 → 2π(一圈)
child: const Icon(Icons.refresh, size: 48),
);
}
}使用时,只需传入 animation:
class SpinningDemo extends StatefulWidget {
const SpinningDemo({super.key});
@override
State<SpinningDemo> createState() => _SpinningDemoState();
}
class _SpinningDemoState extends State<SpinningDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(); // 创建时直接循环
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SpinningIcon(animation: _controller); // 传入 controller 即可
}
}AnimatedBuilder vs AnimatedWidget 怎么选?
- AnimatedBuilder:动画逻辑和使用动画的 Widget 在同一个地方,适合简单场景,代码直观
- AnimatedWidget:把动画 Widget 封装成独立组件,适合复用场景,或者动画逻辑比较复杂时
初学阶段,推荐先用 AnimatedBuilder,等你发现某个动画组件需要复用时,再抽取成 AnimatedWidget。
多个动画控制器
如果你的页面需要多个独立的动画(比如一个旋转、一个缩放),需要用 TickerProviderStateMixin(注意不是 SingleTickerProviderStateMixin):
class MultiAnimationDemo extends StatefulWidget {
const MultiAnimationDemo({super.key});
@override
State<MultiAnimationDemo> createState() => _MultiAnimationDemoState();
}
class _MultiAnimationDemoState extends State<MultiAnimationDemo>
with TickerProviderStateMixin { // ← 注意:没有 Single
late AnimationController _scaleController;
late AnimationController _rotateController;
@override
void initState() {
super.initState();
_scaleController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_rotateController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
void dispose() {
_scaleController.dispose(); // 每个都要释放
_rotateController.dispose();
super.dispose();
}
// ...
}SingleTickerProviderStateMixin vs TickerProviderStateMixin
SingleTickerProviderStateMixin:只能创建一个 AnimationController,性能更好TickerProviderStateMixin:可以创建多个 AnimationController
如果你只需要一个控制器,用 Single 版本;需要多个,用非 Single 版本。不要混用。
隐式动画 vs 显式动画
| 特性 | 隐式动画 | 显式动画 |
|---|---|---|
| 控制方式 | 设置目标值,自动过渡 | 手动控制 Controller |
| 代码复杂度 | 简单 | 较复杂 |
| 控制精度 | 低 | 高 |
| 循环动画 | ❌ 不支持 | ✅ 支持 |
| 暂停/倒放 | ❌ 不支持 | ✅ 支持 |
| 多动画同步 | ❌ 不支持 | ✅ 支持 |
| 监听每帧 | ❌ 不支持 | ✅ 支持 |
| 适合场景 | 简单过渡效果 | 复杂动画、循环动画、精细控制 |
选择建议
能用隐式动画就用隐式动画,代码更少、更不容易出 bug。只有当隐式动画无法满足需求时,才使用显式动画。
完整示例:加载动画
下面是一个综合运用显式动画的例子——一个常见的加载指示器,包含旋转和缩放:
class LoadingIndicator extends StatefulWidget {
const LoadingIndicator({super.key});
@override
State<LoadingIndicator> createState() => _LoadingIndicatorState();
}
class _LoadingIndicatorState extends State<LoadingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotateAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
// 旋转:匀速一圈
_rotateAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.linear),
);
// 缩放:脉冲效果
_scaleAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.2), weight: 0.5),
TweenSequenceItem(tween: Tween(begin: 1.2, end: 1.0), weight: 0.5),
]).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.repeat(); // 循环播放
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotateAnimation.value * 2 * 3.14159,
child: child,
),
);
},
child: const Icon(Icons.refresh, size: 48, color: Colors.blue),
);
}
}TweenSequence
TweenSequence 可以定义多段动画,每段用 TweenSequenceItem 指定 Tween 和权重(weight)。上面的例子中,缩放动画分为两段:放大(1.0→1.2)和缩小(1.2→1.0),各占一半时间。
常见问题
1. vsync 是什么?
vsync(垂直同步)是一个 TickerProvider 对象,它告诉 AnimationController:"页面正在显示,请继续刷新动画"。当页面不可见时(如切到后台),它会自动暂停动画,避免无用的计算。
通过混入 SingleTickerProviderStateMixin,你的 State 就成了一个 TickerProvider,可以用 this 传入。
2. 忘记 dispose() 会怎样?
AnimationController 底层创建了一个 Ticker,如果不释放,即使页面销毁了,Ticker 仍在运行,导致内存泄漏。严重时可能造成应用卡顿甚至崩溃。
@override
void dispose() {
_controller.dispose(); // ← 永远不要忘记这行!
super.dispose();
}3. AnimatedBuilder 的 child 参数有什么用?
child 参数是性能优化的关键。把它放在 builder 外面,Flutter 只会构建一次,而不是每帧都重建:
// ✅ 推荐:child 放外面,只构建一次
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: child, // 复用 child
);
},
child: const Icon(Icons.star, size: 100), // 不依赖动画的部分
)
// ❌ 不推荐:所有内容都写在 builder 里,每帧都重建
AnimatedBuilder(
animation: _animation,
builder: (context, _) {
return Transform.scale(
scale: _animation.value,
child: const Icon(Icons.star, size: 100), // 每帧都重新创建
);
},
)4. 多个 Controller 怎么管理?
当页面有多个控制器时,建议统一管理:
late final AnimationController _rotateController;
late final AnimationController _scaleController;
late final AnimationController _fadeController;
@override
void dispose() {
_rotateController.dispose();
_scaleController.dispose();
_fadeController.dispose();
super.dispose();
}如果控制器太多,考虑用 AnimationController.unbounded() 或将动画逻辑抽取到单独的类中。
