Flutter 基础体系 · 第 46/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 显式动画:Controller、Ticker、组合、清理和测试
显式动画(explicit animation)是指开发者直接管理动画的时间轴、播放方向、状态和生命周期。Flutter 中最核心的三个对象是:
Ticker:按帧产生时间回调。AnimationController:把时间转换为动画值,并提供启动、暂停、反向、重复等控制方法。Animation<T>:把控制器的数值映射为某种动画数据,例如透明度、位移、颜色或圆角。
显式动画适合以下场景:
- 动画由手势、网络结果或业务状态主动触发;
- 多个属性需要共享同一个时间轴;
- 需要监听动画开始、完成、反向和取消;
- 需要精确控制动画的暂停、恢复、重复和测试过程。
如果只需要“属性从旧值平滑过渡到新值”,AnimatedContainer、AnimatedOpacity 等隐式动画组件通常更简单。显式动画的代价是必须自己处理 Ticker、AnimationController 和 dispose。
一、先建立动画模型:时间、值和绘制
一个动画可以抽象为一个随时间变化的函数:
其中:
- 是从动画开始起经过的时间;
- 是时间到动画值的映射;
- 是当前动画值。
在最常见的情况下,AnimationController 产生一个从 0.0 到 1.0 的值:
D 是动画时长。随后,Curve 可以改变时间进度:
例如 Curves.easeOut 会让动画开始较快、结束较慢。最后,Tween<T> 将进度映射到实际属性:
以位移为例:
final position = Tween<double>(
begin: 0,
end: 120,
).animate(
CurvedAnimation(
parent: controller,
curve: Curves.easeOut,
),
);
此时:
controller.value提供基础进度;CurvedAnimation将线性进度转换为缓动进度;Tween<double>将0.0..1.0映射为0..120;- 组件在每次动画值变化后重新构建或重绘。
这几个对象的职责不能混淆:Tween 不负责计时,Curve 不负责启动动画,AnimationController 也不知道最终要改变哪个 Widget 属性。
二、Ticker:动画为什么能够逐帧运行
2.1 Ticker 的定义
Ticker 是一个逐帧回调器。它在 Flutter 的帧调度机制下运行,每次收到新的时间戳时调用回调:
Ticker((Duration elapsed) {
// elapsed 表示从 ticker 启动以来经过的时间
});
Ticker 本身不理解“透明度”或“位移”。它只提供:
- 开始;
- 停止;
- 按帧通知;
- 从启动点计算经过时间。
可以把它看成动画的“时钟”,而不是动画本身。
在 Flutter 中,Ticker 通常不应直接手动创建。Widget 应通过 TickerProvider 创建它,以便 Flutter 能够知道这个 ticker 属于哪个 State,以及该 State 是否位于被 TickerMode 禁用的子树中。
2.2 TickerProvider 的作用
TickerProvider 的核心方法是:
Ticker createTicker(TickerCallback onTick);
State 常用的两个 mixin 是:
SingleTickerProviderStateMixin
TickerProviderStateMixin
SingleTickerProviderStateMixin 适用于一个 State 只创建一个 ticker 的情况:
class _ExampleState extends State<Example>
with SingleTickerProviderStateMixin {
late final AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
TickerProviderStateMixin 适用于同一个 State 需要创建多个 ticker 的情况,例如多个独立的 AnimationController:
class _DashboardState extends State<Dashboard>
with TickerProviderStateMixin {
late final AnimationController headerController;
late final AnimationController chartController;
@override
void initState() {
super.initState();
headerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
chartController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
}
@override
void dispose() {
chartController.dispose();
headerController.dispose();
super.dispose();
}
}
如果明确知道 State 只需要一个控制器,优先使用 SingleTickerProviderStateMixin。如果使用了多个控制器却仍使用单 ticker mixin,通常会在运行时触发断言。
2.3 为什么需要 vsync
vsync 是 AnimationController 用来获得 Ticker 的对象。它带来两个重要效果:
- 动画回调与 Flutter 帧调度同步;
- 当对应 Widget 子树不需要动画时,可以暂停 ticker 的回调。
如果不使用 vsync,而是自行用定时器驱动动画,会遇到几个问题:
- 回调可能与屏幕帧不同步;
- 页面不可见时仍然执行无意义的工作;
- 可能出现一帧多次更新或错过绘制时机;
- 难以与 Flutter 的测试时钟和生命周期整合。
因此,AnimationController 的 vsync 不是一个可有可无的参数,而是显式动画接入 Flutter 渲染时序的入口。
三、AnimationController:把时间变成可控制的进度
3.1 控制器的状态
AnimationController 同时具有两个角色:
- 它是一个
Animation<double>,可以被其他动画对象监听; - 它是一个控制器,能够修改当前动画的运行状态。
常用属性包括:
controller.value
controller.status
controller.isAnimating
controller.duration
controller.reverseDuration
controller.lowerBound
controller.upperBound
默认范围是 0.0..1.0。可以通过 lowerBound 和 upperBound 改变范围:
final controller = AnimationController(
vsync: this,
lowerBound: -1,
upperBound: 1,
duration: const Duration(milliseconds: 400),
);
绝大多数 UI 动画使用 0..1 更容易组合。实际属性范围应交给 Tween 处理,而不是滥用控制器的边界。
3.2 播放方法的语义
常用控制方法如下:
controller.forward();
controller.reverse();
controller.forward(from: 0);
controller.reverse(from: 1);
controller.stop();
controller.reset();
controller.repeat();
controller.animateTo(0.7);
controller.animateBack(0.2);
它们的区别如下:
forward():向upperBound播放;reverse():向lowerBound播放;forward(from: 0):先将当前值设为0,再向前播放;stop():停止当前 ticker,保留当前值;reset():停止并将值设置为lowerBound;repeat():到达边界后重新开始;animateTo(target):在指定时间内移动到目标值;animateBack(target):以反向语义移动到目标值。
forward() 和 reverse() 返回 TickerFuture。如果需要等待动画完成,可以写:
await controller.forward();
如果控制器在等待期间被 dispose,这个 future 默认不会正常完成。需要把异常传播出来时使用:
try {
await controller.forward().orCancel;
} on TickerCanceled {
// Widget 已被销毁,动画等待被取消。
}
这在异步业务流程中很重要。例如:
Future<void> showSuccess() async {
try {
await controller.forward().orCancel;
if (!mounted) {
return;
}
setState(() {
message = '动画完成';
});
} on TickerCanceled {
// 页面离开时属于正常取消,不再更新状态。
}
}
mounted 检查和 TickerCanceled 处理解决的是两个不同问题:
TickerCanceled表示等待中的动画已取消;mounted表示异步回调返回时 State 是否仍然存在。
3.3 AnimationStatus 与数值变化
AnimationController 有四种主要状态:
AnimationStatus.dismissed
AnimationStatus.forward
AnimationStatus.reverse
AnimationStatus.completed
状态表示运行方向和边界状态,不等同于“当前值大于还是小于某个阈值”。
例如:
value == 0通常对应dismissed;- 从
0向1播放时是forward; value == 1通常对应completed;- 从
1向0播放时是reverse。
可以分别监听数值和状态:
controller.addListener(() {
debugPrint('value = ${controller.value}');
});
controller.addStatusListener((status) {
debugPrint('status = $status');
});
数值监听适合驱动属性;状态监听适合处理“播放完成后执行什么”。
四、一个完整的显式动画组件
下面的组件实现了:
- 点击后从头播放;
- 同一个控制器同时驱动透明度、位移和缩放;
CurvedAnimation提供缓动;AnimatedBuilder触发重建;child参数避免静态子树重复构建;- 销毁时释放控制器。
import 'package:flutter/material.dart';
class ExplicitEntranceCard extends StatefulWidget {
const ExplicitEntranceCard({
super.key,
this.title = '显式动画',
});
final String title;
@override
State<ExplicitEntranceCard> createState() => _ExplicitEntranceCardState();
}
class _ExplicitEntranceCardState extends State<ExplicitEntranceCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _opacity;
late final Animation<Offset> _offset;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
debugLabel: 'explicit-entrance-card',
);
final curved = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_opacity = Tween<double>(
begin: 0,
end: 1,
).animate(curved);
_offset = Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(curved);
_scale = Tween<double>(
begin: 0.92,
end: 1,
).animate(curved);
}
void _play() {
_controller.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _controller,
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(widget.title),
),
),
builder: (context, child) {
return Opacity(
opacity: _opacity.value,
child: FractionalTranslation(
translation: _offset.value,
child: Transform.scale(
scale: _scale.value,
child: child,
),
),
);
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _play,
child: const Text('播放'),
),
],
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
运行方式:
void main() {
runApp(
const MaterialApp(
home: Scaffold(
body: Center(
child: ExplicitEntranceCard(),
),
),
),
);
}
这段代码中,AnimatedBuilder.animation 接收的是 _controller,而不是必须接收 _opacity、_offset 或 _scale。因为这些动画都由同一个父控制器驱动,控制器值变化时它们都会通知监听者。
AnimatedBuilder 的 child 参数用于承载不依赖动画值的子树。上例中的 Card 和 Text 不需要每帧重新创建,只有外层的变换 Widget 根据动画值更新。这是减少无意义构建的结构性方法,但它不会阻止布局或绘制本身随动画发生。
五、组合动画:一个时间轴驱动多个属性
5.1 共享控制器与不同 Tween
同一个控制器可以驱动任意数量的动画:
final curved = CurvedAnimation(
parent: controller,
curve: Curves.easeOut,
);
final opacity = Tween<double>(
begin: 0,
end: 1,
).animate(curved);
final color = ColorTween(
begin: Colors.grey,
end: Colors.blue,
).animate(curved);
final radius = Tween<double>(
begin: 0,
end: 24,
).animate(curved);
它们共享:
- 开始时间;
- 播放方向;
- 暂停和停止;
- 动画时长。
但每个属性拥有自己的值映射。这样可以保证多个视觉变化严格同步,而不是分别启动多个可能产生时序误差的控制器。
5.2 Interval:在一个时间轴上分段
Interval 将父动画的 0..1 区间映射到某个子区间。
例如:
final first = CurvedAnimation(
parent: controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
);
final second = CurvedAnimation(
parent: controller,
curve: const Interval(0.4, 1.0, curve: Curves.easeIn),
);
对 first 而言:
- 父值
0.0..0.5被映射为子动画0.0..1.0; - 父值大于
0.5后,子动画保持在1.0。
对 second 而言:
- 父值小于
0.4时保持在0.0; - 父值
0.4..1.0被映射为0.0..1.0。
两个区间重叠 0.4..0.5,因此可以产生交错效果。如果希望严格先后执行,应使用不重叠区间,例如 0.0..0.5 和 0.5..1.0。
完整例子:
class StaggeredPanel extends StatefulWidget {
const StaggeredPanel({super.key});
@override
State<StaggeredPanel> createState() => _StaggeredPanelState();
}
class _StaggeredPanelState extends State<StaggeredPanel>
with SingleTickerProviderStateMixin {
late final AnimationController controller;
late final Animation<double> titleOpacity;
late final Animation<double> bodyOpacity;
late final Animation<Offset> bodyOffset;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
titleOpacity = CurvedAnimation(
parent: controller,
curve: const Interval(0, 0.45, curve: Curves.easeOut),
);
final bodyProgress = CurvedAnimation(
parent: controller,
curve: const Interval(0.35, 1, curve: Curves.easeOutCubic),
);
bodyOpacity = bodyProgress;
bodyOffset = Tween<Offset>(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(bodyProgress);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
return Column(
children: [
Opacity(
opacity: titleOpacity.value,
child: const Text('标题'),
),
FractionalTranslation(
translation: bodyOffset.value,
child: Opacity(
opacity: bodyOpacity.value,
child: const Text('正文'),
),
),
],
);
},
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
这里的“交错”不是多个异步任务排队,而是一个父进度经过不同区间函数转换后的结果。因此它的时间关系是确定的,也更容易测试。
5.3 多个控制器:什么时候需要
多个控制器适用于动画生命周期真正独立的情况,例如:
- 顶部导航栏可以独立展开;
- 图表加载动画可以独立循环;
- 用户拖拽过程和提示气泡各自拥有不同的控制逻辑。
如果只是同一交互中的多个属性,不应为了每个属性都创建一个控制器。多个控制器会增加:
- 生命周期清理数量;
- 状态组合复杂度;
- 不同控制器启动时间不一致的可能性;
- 测试中需要推进和断言的时钟状态。
可以用如下方式组合多个动画监听对象:
AnimatedBuilder(
animation: Listenable.merge([
headerController,
chartController,
]),
builder: (context, child) {
return buildContent();
},
);
Listenable.merge 只负责把通知合并,并不会同步控制器,也不会改变任一控制器的播放状态。如果两个控制器必须严格同步,应优先使用同一个控制器和多个 Tween 或 Interval。
六、动画驱动 UI 的三种常见方式
6.1 直接监听并 setState
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addListener(() {
setState(() {});
});
}
然后在 build 中读取 controller.value。
这种写法直观,但会让整个 State 对每个动画帧调用 build。当 State 很小、代码需要快速验证时可以使用;复杂页面更适合 AnimatedBuilder 或 AnimatedWidget,把重建范围限制在动画子树。
6.2 AnimatedBuilder
AnimatedBuilder 监听任意 Listenable:
AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.rotate(
angle: animation.value,
child: child,
);
},
child: const Icon(Icons.refresh),
);
它不会自动决定如何变换界面,只负责:
- 监听
animation; - 动画通知时调用
builder; - 将静态的
child原样传回。
6.3 AnimatedWidget
如果某个动画组件具有稳定、可复用的结构,可以继承 AnimatedWidget:
class RotatingIcon extends AnimatedWidget {
const RotatingIcon({
super.key,
required Animation<double> animation,
}) : super(listenable: animation);
Animation<double> get animation =>
listenable as Animation<double>;
@override
Widget build(BuildContext context) {
return Transform.rotate(
angle: animation.value,
child: const Icon(Icons.refresh),
);
}
}
AnimatedWidget 适合将“动画监听 + 固定渲染逻辑”封装成组件;AnimatedBuilder 更适合局部、灵活的组合。
七、生命周期与清理:为什么 dispose 不是可选项
7.1 控制器拥有 ticker
当 AnimationController 被创建时,它会通过传入的 TickerProvider 创建 ticker。控制器销毁时必须调用:
controller.dispose();
否则可能出现:
- 页面离开后 ticker 仍在调度;
- 测试结束时出现 ticker 泄漏错误;
- 后续帧访问已经失效的 State;
- 控制器和监听器长期持有对象,导致资源不能回收。
标准顺序是:
@override
void dispose() {
controller.dispose();
super.dispose();
}
如果手动添加了外部监听器,还应移除那些由自己注册的监听器:
late final VoidCallback listener;
@override
void initState() {
super.initState();
listener = () {
// ...
};
controller.addListener(listener);
}
@override
void dispose() {
controller.removeListener(listener);
controller.dispose();
super.dispose();
}
通常 AnimationController.dispose() 会清理控制器内部对 ticker 的管理,但它不会替开发者清理其他对象上注册的监听器。
7.2 组件更新时的参数变化
如果动画时长来自 Widget 参数,不能只在 initState 读取一次:
class ProgressAnimation extends StatefulWidget {
const ProgressAnimation({
super.key,
required this.duration,
});
final Duration duration;
@override
State<ProgressAnimation> createState() => _ProgressAnimationState();
}
class _ProgressAnimationState extends State<ProgressAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: widget.duration,
);
}
@override
void didUpdateWidget(covariant ProgressAnimation oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.duration != widget.duration) {
controller.duration = widget.duration;
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LinearProgressIndicator(value: controller.value);
}
}
didUpdateWidget 只在同一个 State 被复用、但 Widget 配置发生变化时调用。这里更新的是控制器配置,不需要重新创建控制器,也不能在每次 build 中重新创建控制器。
7.3 异步回调与已销毁 State
动画常与异步操作组合:
Future<void> runAnimationThenSave() async {
try {
await controller.forward().orCancel;
if (!mounted) {
return;
}
await saveResult();
} on TickerCanceled {
// 页面销毁导致动画取消。
}
}
不要在 dispose 之后继续调用控制器方法。也不要只依赖 mounted 来掩盖所有异步资源问题:网络请求、订阅、定时器和动画 future 仍然应该分别管理。
八、TickerMode、页面不可见和系统减弱动画设置
8.1 TickerMode 的作用
TickerMode 控制子树中的 ticker 是否启用:
TickerMode(
enabled: false,
child: SomeAnimatedWidget(),
)
禁用后,相关 ticker 不再产生正常的逐帧回调,因此动画驱动的界面不会继续重建。AnimationController 的时间逻辑在实现上仍可能跨过这段时间;重新启用后,动画通常会按照已经经过的时间继续到相应进度,而不是把暂停区间当成真正的时间暂停。
因此:
TickerMode适合减少不可见子树的逐帧工作;- 它不等价于
controller.stop(); - 如果业务语义要求“暂停后从当前位置继续”,应显式调用
stop(),并根据需要重新启动; - 不要把动画帧回调当作精确业务计时器。
页面路由、Tab、可见性组件可能改变 TickerMode。具体页面是否自动禁用 ticker 取决于使用的组件和树结构,不能假设所有不可见页面都自动停止。
8.2 无障碍减弱动画
系统可能启用“减少动画”或“禁用动画”设置。AnimationController 有 animationBehavior,用于描述在 AccessibilityFeatures.disableAnimations 生效时的行为:
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
animationBehavior: AnimationBehavior.normal,
);
常见语义是:
AnimationBehavior.normal:在系统要求禁用动画时,控制器可跳到目标边界;AnimationBehavior.preserve:尽量保持动画过程。
具体视觉效果还取决于 Widget 如何消费动画值。无障碍策略不能简单理解为“所有动画都必须删除”:淡入、位移、缩放、循环动画对用户的影响不同。生产代码应避免把无限循环动画作为唯一的信息表达方式,并验证系统无障碍设置下的状态是否仍然可理解。
8.3 平台差异
动画 API 本身在 Android、iOS、桌面和 Web 上保持 Flutter 层的一致性,但运行环境存在差异:
- Android 和 iOS 通常有成熟的系统减弱动画设置;
- 桌面平台的窗口遮挡、最小化和刷新率可能影响实际帧调度;
- Web 页面切到后台后,浏览器可能降低或暂停帧调度;
- 不同设备刷新率会影响每秒收到的帧数,但控制器的时间进度仍以时间戳为基础;
- Web 受浏览器标签页生命周期和资源策略影响,不能把
Ticker当作后台任务调度器。
因此,动画应表达视觉状态变化,而不是承担轮询、超时、重试或后台计时职责。
九、动画值、Curve 和 Tween 的边界
9.1 Tween 不会限制输入范围
很多开发者认为 Tween<double>(begin: 0, end: 1) 会把结果限制在 0..1。实际上,Tween 通常只是按输入进度插值:
如果 t = 1.2,结果也可能是 1.2。这在 fling、自定义控制器边界或某些组合动画中可能发生。
例如:
final value = Tween<double>(
begin: 0,
end: 100,
).evaluate(
const AlwaysStoppedAnimation<double>(1.2),
);
// value 可能为 120。
如果目标属性要求严格范围,应显式约束:
final opacity = controller.value.clamp(0.0, 1.0);
不过对于标准 AnimationController 的普通 forward(),值通常在控制器的边界内。不要为了理论上的越界而到处加 clamp,应该先明确动画输入是否可能越界。
9.2 Curve 不是物理模拟
Curves.easeOut 等曲线是对进度的函数变换,不代表真实物理。需要速度、阻尼或弹簧效果时,可以使用 CurvedAnimation 配合弹簧相关动画方案,或使用 Flutter 提供的物理模拟 API。
曲线还可能改变单调性。例如某些弹性曲线会产生超过终点或反弹的效果。若目标属性不能接受越界值,就必须验证具体曲线的输出和属性约束。
十、错误路径和常见失败表现
10.1 在 build 中创建控制器
错误示例:
@override
Widget build(BuildContext context) {
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
return Container();
}
build 可能被调用很多次。这样会重复创建控制器和 ticker,并且新创建的对象没有稳定的生命周期。正确做法是在 initState 创建,在 dispose 销毁。
10.2 忘记使用正确的 mixin
如果 State 没有实现 TickerProvider,下面代码无法成立:
AnimationController(
vsync: this,
);
应使用:
class _State extends State<MyWidget>
with SingleTickerProviderStateMixin {
// ...
}
或:
class _State extends State<MyWidget>
with TickerProviderStateMixin {
// ...
}
10.3 控制器已销毁后仍然播放
常见表现包括断言失败、TickerFuture 被取消、异步回调访问失效 State。诊断时检查:
dispose是否调用了控制器的dispose;- 是否保存并继续使用了已销毁 State 的回调;
- 是否在异步
await后缺少mounted检查; - 是否把控制器放在了比 Widget 更长的对象中,却没有建立清晰的所有权关系。
10.4 对无限动画使用 pumpAndSettle
测试中的 pumpAndSettle() 会持续推进时间,直到没有待处理帧。如果被测组件使用:
controller.repeat();
它可能永远有新帧,测试就会超时。
对于循环动画,应固定推进时间:
await tester.pump(const Duration(milliseconds: 200));
并断言某个时刻的状态,而不是等待“稳定”。
10.5 误解 reverseCurve
final animation = CurvedAnimation(
parent: controller,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
);
reverseCurve 只在动画反向运行时使用。它不会改变正向播放,也不会自动让 Tween 反转;反转由父控制器的播放方向决定。
十一、可测试的显式动画
显式动画的优势之一是时间轴可以被测试控制。Flutter Widget 测试中的 WidgetTester.pump 会推进测试时钟并触发必要的帧。
为了测试内部控制器,可以通过 GlobalKey 暴露 State。示例组件:
import 'package:flutter/material.dart';
class TestableFadeBox extends StatefulWidget {
const TestableFadeBox({
super.key,
required this.onFinished,
});
final VoidCallback onFinished;
@override
TestableFadeBoxState createState() => TestableFadeBoxState();
}
class TestableFadeBoxState extends State<TestableFadeBox>
with SingleTickerProviderStateMixin {
late final AnimationController controller;
late final Animation<double> opacity;
TestableFadeBoxState();
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
opacity = CurvedAnimation(
parent: controller,
curve: Curves.linear,
);
controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
widget.onFinished();
}
});
}
void start() {
controller.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: opacity,
child: const SizedBox(
key: ValueKey('fade-box'),
width: 100,
height: 100,
),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
对应测试:
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
void main() {
testWidgets('动画在指定时间内从 0 运行到 1', (tester) async {
var finished = 0;
final key = GlobalKey<TestableFadeBoxState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TestableFadeBox(
key: key,
onFinished: () {
finished++;
},
),
),
),
);
expect(key.currentState!.controller.value, 0);
key.currentState!.start();
// 只推进一半时长,动画尚未完成。
await tester.pump(const Duration(milliseconds: 200));
final middleValue = key.currentState!.controller.value;
expect(middleValue, greaterThan(0));
expect(middleValue, lessThan(1));
expect(finished, 0);
// 再推进剩余时间。
await tester.pump(const Duration(milliseconds: 200));
expect(key.currentState!.controller.value, 1);
expect(finished, 1);
});
}
测试过程的因果关系是:
- 初始构建完成,控制器值为
0; start()调用forward(from: 0);pump(200ms)让测试时钟前进 200 毫秒;- 总时长为 400 毫秒,因此控制器处于中间进度;
- 再推进 200 毫秒到达终点;
- 控制器发出
completed状态,完成回调执行一次。
这里使用线性曲线,便于验证中间值。如果测试的是 easeOut,不应断言中间值等于 0.5,因为曲线已经改变了进度映射;可以断言它位于合法范围,或针对曲线本身写更精确的测试。
11.1 测试 UI 结果而不是实现细节
如果只关心用户可见结果,可以寻找语义或结构变化:
testWidgets('动画完成后显示成功状态', (tester) async {
await tester.pumpWidget(const MaterialApp(
home: SomePage(),
));
await tester.tap(find.text('播放'));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('完成'), findsOneWidget);
});
如果组件内部控制器只是实现细节,直接暴露 State 会增加测试与实现的耦合。更稳妥的方式是给最终结果添加:
Key;Semantics;- 可观察文本;
- 业务回调。
但在测试动画时间、方向和边界时,读取控制器值是合理的,因为这些正是显式动画的核心行为。
11.2 测试销毁路径
至少应验证组件移除后没有持续动画或异常:
testWidgets('移除组件后可以安全清理动画', (tester) async {
final key = GlobalKey<TestableFadeBoxState>();
var show = true;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
home: show
? TestableFadeBox(
key: key,
onFinished: () {},
)
: const SizedBox(),
);
},
),
);
key.currentState!.start();
await tester.pump(const Duration(milliseconds: 50));
show = false;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
home: show
? TestableFadeBox(
key: key,
onFinished: () {},
)
: const SizedBox(),
);
},
),
);
await tester.pump();
});
测试框架在测试结束阶段通常会帮助发现未清理的 ticker。若测试报 ticker 泄漏,应优先检查所有创建控制器的 State 是否实现了 dispose,而不是简单增加等待时间。
十二、显式动画的选择边界
可以用以下因果关系选择方案:
- 只有属性变化,没有主动控制需求:优先隐式动画;
- 需要点击后播放、反向、暂停或重复:使用
AnimationController; - 多个属性必须共享时间轴:一个控制器配多个
Tween; - 多个阶段需要交错:一个控制器配多个
Interval; - 多个动画生命周期互相独立:多个控制器,并使用
TickerProviderStateMixin; - 动画只影响局部 UI:使用
AnimatedBuilder或AnimatedWidget; - 动画需要与异步流程串联:使用
TickerFuture,并处理TickerCanceled和mounted; - 动画无法停止或测试超时:检查是否使用了
repeat()、TickerMode或错误的pumpAndSettle()。
显式动画的核心并不是“手动调用某个播放函数”,而是建立一条完整的数据流:
Ticker
↓ 每帧时间通知
AnimationController
↓ 产生 0..1 进度和状态
Curve / Interval
↓ 改变时间分布
Tween<T>
↓ 映射到实际属性
AnimatedBuilder / Transition / 自定义绘制
↓
Widget 树、RenderObject 和最终帧
生命周期则是另一条必须闭合的路径:
initState 创建控制器
↓
用户操作或业务事件启动动画
↓
Ticker 按帧驱动控制器
↓
动画监听者更新界面
↓
dispose 停止并释放控制器
只要其中任一环缺失,就会出现对应故障:没有 vsync 无法接入帧调度,没有监听者界面不会变化,没有正确组合会产生错误时序,没有 dispose 会造成 ticker 泄漏,没有测试时钟控制则难以稳定验证动画行为。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 隐式动画:Tween、曲线、状态切换和适用边界
- 下一篇:Flutter Hero 与页面转场:匹配、飞行、路由和视觉连续性
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论