Flutter 基础体系 · 第 45/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 隐式动画:Tween、曲线、状态切换和适用边界
Flutter 中的动画通常分为两类:
- 显式动画(explicit animation):开发者直接创建并管理
AnimationController,决定何时播放、暂停、反向、重复以及如何释放资源。 - 隐式动画(implicit animation):开发者只修改目标状态并提供
duration,组件负责创建控制器、驱动插值、重建界面和释放动画资源。
隐式动画并不意味着“没有动画控制器”,而是控制器的生命周期和播放过程由 Flutter 组件内部管理。开发者主要描述“从当前值变化到什么值”,而不是手动编写“每一帧如何变化”。
一、隐式动画的基本数据流
一个隐式动画通常包含以下几个步骤:
State中保存一个目标状态。- 事件处理函数调用
setState修改状态。 - Flutter 重新执行
build。 - 隐式动画组件发现某个动画属性发生变化。
- 组件从当前显示值向新的目标值插值。
- 每一帧重新构建或绘制相关内容。
- 动画完成后保持最终值。
例如:
class SizeDemo extends StatefulWidget {
const SizeDemo({super.key});
@override
State<SizeDemo> createState() => _SizeDemoState();
}
class _SizeDemoState extends State<SizeDemo> {
bool expanded = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 400),
width: expanded ? 240 : 120,
height: expanded ? 180 : 80,
color: expanded ? Colors.blue : Colors.grey,
child: const Center(
child: Text('Box'),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
expanded = !expanded;
});
},
child: const Text('切换'),
),
],
);
}
}
点击按钮时,代码没有调用“播放动画”的方法。它只修改了 expanded:
expanded = !expanded;
之后的动画由 AnimatedContainer 根据新旧属性自动完成。
这里的因果关系是:
用户点击
↓
setState 修改 expanded
↓
build 得到新的 width、height、color
↓
AnimatedContainer 发现目标值变化
↓
内部动画从当前值插值到新值
↓
每帧更新渲染结果
setState 本身不会生成动画。它只是通知 Flutter:当前 State 对应的界面描述已经变化,需要在后续帧重新构建。是否有动画,取决于重新构建后的组件是否具备动画能力。
二、Tween 是什么:从起点到终点的插值规则
2.1 Tween 不是动画本身
Tween<T> 表示一个从起点到终点的值映射:
final tween = Tween<double>(
begin: 0,
end: 100,
);
它描述的是:
进度为 0 时,值是 0
进度为 1 时,值是 100
中间进度时,根据插值规则计算值
可以把它形式化为:
其中:
- 是起点,也就是
begin - 是终点,也就是
end - 是进度,通常位于
0到1 - 是进度为 时的实际值
lerp是线性插值或某种类型对应的插值函数
对于 double,插值通常可以理解为:
例如:
a = 10
b = 30
p = 0.25
x(0.25) = 10 + (30 - 10) × 0.25
= 15
因此,Tween<double> 不是“每隔多少毫秒改变一次值”,也不负责启动或停止。它只负责回答:
当前动画进度对应的值是多少?
真正让进度从 0 变化到 1 的,是动画控制器或隐式动画组件内部的控制逻辑。
2.2 不同类型使用不同的 Tween
Flutter 根据值类型提供了不同的插值实现:
Tween<double>(begin: 0, end: 1)
ColorTween(begin: Colors.red, end: Colors.blue)
SizeTween(begin: Size.zero, end: const Size(100, 100))
EdgeInsetsTween(
begin: EdgeInsets.zero,
end: const EdgeInsets.all(24),
)
这些类型的插值逻辑并不相同:
double对数值做线性插值。ColorTween对颜色的颜色空间分量进行插值。SizeTween分别插值宽度和高度。EdgeInsetsTween分别插值四条边的间距。
因此,Tween 的关键不是名字,而是它定义了“两个值之间如何过渡”。
某些值不能自然插值,例如:
final a = TextStyle(fontWeight: FontWeight.w400);
final b = TextStyle(fontWeight: FontWeight.w900);
TextStyle 可以通过 TextStyle.lerp 处理许多属性,但并不是所有复杂对象都具备合理的插值规则。一个对象如果没有对应的 lerp 逻辑,就不能仅凭“值发生变化”自动产生平滑过渡。
2.3 Tween 的边界值与空值
Tween 的 begin 和 end 可以是空值,具体行为取决于 Tween 类型。例如 ColorTween 支持从透明或空颜色状态过渡,某些布局 Tween 也允许空值。
使用 TweenAnimationBuilder 时,若首次传入的 Tween 没有有效的 begin,动画通常会从 end 的当前值开始,因此首次构建可能看不到从某个默认值进入的动画。
如果需要明确的首次进入动画,应显式设置起点:
TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0,
end: 1,
),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: child,
);
},
child: const Text('淡入内容'),
)
这里的含义是:
首次动画开始时 opacity = 0
动画结束时 opacity = 1
三、曲线:改变时间分配,而不是改变目标值
3.1 线性进度并不等于线性视觉效果
如果一个 400 毫秒的动画在每个时间点都按相同比例前进,那么它使用的是线性曲线:
时间进度 p:0.00 → 0.25 → 0.50 → 0.75 → 1.00
实际进度: 0.00 → 0.25 → 0.50 → 0.75 → 1.00
这意味着速度基本恒定,开始和结束都没有减速。
曲线 Curve 的作用是把原始时间进度转换为实际插值进度:
然后 Tween 使用 ,而不是直接使用 :
其中:
- 是经过时间归一化得到的原始进度。
- 是曲线函数。
- 是经过曲线变换后的实际进度。
- 是最终显示值。
例如,假设目标值从 0 变为 100:
原始进度 p = 0.25
曲线变换 C(p) = 0.10
实际值 = 0 + (100 - 0) × 0.10
= 10
如果使用线性曲线,同一时刻的值是 25;使用缓出曲线时,动画可能只前进到 10,表示开始阶段更慢,后续再加速或根据曲线继续变化。
3.2 常见曲线的行为
AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
width: expanded ? 300 : 100,
height: 100,
color: Colors.blue,
)
常见曲线可以按运动特征理解:
Curves.linear:速度近似恒定。Curves.easeIn:开始慢,随后加速。Curves.easeOut:开始快,随后减速。Curves.easeInOut:开始和结束都较缓。Curves.fastOutSlowIn:常用于界面状态转换。Curves.bounceOut:结束阶段出现弹跳效果。Curves.elasticOut:可能越过终点后回弹。
曲线不是装饰参数,而是在改变“时间如何分配给空间变化”。同样的起点、终点和持续时间,曲线不同,用户感知到的速度就不同。
3.3 曲线可能产生越界值
许多开发者默认曲线输出一定在 [0, 1] 内,但具有回弹或弹性效果的曲线可能暂时超出这个范围。
若:
begin = 0
end = 100
curve(t) = 1.1
则:
这就是“越过终点后回弹”的数学基础。
这种行为对位移、缩放等效果通常是合理的,但对某些属性可能造成问题:
- 缩放值暂时大于预期。
- 边距短暂变成负数。
- 透明度可能超出合法范围,具体组件可能进行限制或产生异常视觉效果。
- 宽高越界导致布局溢出。
因此,使用弹性曲线时,必须确认目标属性能够接受超调。对于必须严格限制在合法范围内的值,应使用有界曲线,或在插值结果上进行约束。
四、常用隐式动画组件
Flutter 的隐式动画组件大致可以分为三类:
- 对常用属性提供封装。
- 对状态切换提供过渡。
- 提供通用 Tween 插值能力。
4.1 AnimatedContainer:同时动画多个容器属性
AnimatedContainer 适合以下属性的变化:
- 宽度和高度
- 内边距
- 外边距
- 对齐方式
- 背景颜色
- 装饰对象
- 圆角
- 阴影
- 变换等
示例:
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
padding: selected
? const EdgeInsets.all(24)
: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: selected ? Colors.blue : Colors.white,
borderRadius: BorderRadius.circular(selected ? 24 : 8),
boxShadow: selected
? const [
BoxShadow(
color: Colors.black26,
blurRadius: 12,
offset: Offset(0, 4),
),
]
: const [],
),
child: const Text('可选卡片'),
)
它并不是把整个 BoxDecoration 当作一个普通对象直接做引用替换,而是通过 Flutter 对相关类型提供的插值能力计算中间状态。
不过,AnimatedContainer 的能力边界也很明确:
- 不能自动动画任意自定义对象。
- 不能保证所有复杂 decoration 属性都以符合产品意图的方式过渡。
- 布局属性变化可能触发父子布局重新计算。
- 子树内容本身不会因为放在
AnimatedContainer中就自动淡入或切换。
例如,下面这段代码可以动画容器颜色,但不会让 child 中的文字内容自动淡出再换入:
AnimatedContainer(
duration: const Duration(milliseconds: 300),
color: active ? Colors.green : Colors.red,
child: Text(active ? '启用' : '停用'),
)
颜色会变化,文字会在某次重建时直接替换。如果需要文字切换动画,应使用 AnimatedSwitcher。
4.2 AnimatedOpacity:透明度变化
AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
child: const Icon(Icons.info),
)
透明度为 0 时,子组件通常仍然参与布局,也可能仍然响应命中测试。因此,隐藏一个按钮不能只依靠透明度:
AnimatedOpacity(
opacity: enabled ? 1 : 0,
child: ElevatedButton(
onPressed: onPressed,
child: const Text('提交'),
),
)
这里按钮虽然看不见,但仍可能占据空间并参与交互。常见处理方式是结合状态控制:
IgnorePointer(
ignoring: !enabled,
child: AnimatedOpacity(
opacity: enabled ? 1 : 0,
duration: const Duration(milliseconds: 250),
child: const Text('内容'),
),
)
如果连布局空间也不应保留,则需要使用条件构建、AnimatedSize、AnimatedSwitcher 或其他适合布局变化的组件。
4.3 AnimatedDefaultTextStyle:文本样式变化
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: selected
? const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.blue,
)
: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
color: Colors.black,
),
child: const Text('状态标题'),
)
它适合动画化字体大小、颜色、字重等可插值的文本样式属性。
但文本样式变化可能导致文本重新排版:
- 字体变大后可能换行。
- 文本高度变化可能推动其他组件移动。
- 不同平台的字体和字形度量不同,最终布局不一定完全一致。
因此,文本动画不能只观察文字本身,还要检查父布局是否允许它在过渡过程中改变尺寸。
4.4 AnimatedPositioned 与 AnimatedAlign:布局位置变化
AnimatedPositioned 需要作为 Stack 的子组件使用:
Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 400),
left: selected ? 160 : 20,
top: 20,
child: const CircleAvatar(),
),
],
)
它改变的是布局位置,通常会触发布局过程。
如果只是希望把一个已经布局完成的对象平移,可以考虑 AnimatedSlide:
AnimatedSlide(
duration: const Duration(milliseconds: 400),
offset: selected ? const Offset(1, 0) : Offset.zero,
child: const SizedBox(
width: 80,
height: 80,
child: ColoredBox(color: Colors.blue),
),
)
两者的核心差别是:
AnimatedPositioned改变布局位置,可能影响布局计算。AnimatedSlide通常通过绘制阶段的偏移实现视觉移动,不等价于改变布局占位。
如果后续组件不应受到移动对象位置变化的影响,AnimatedSlide 往往更符合语义;如果对象在 Stack 中的布局位置本身就是状态的一部分,AnimatedPositioned 更合适。
五、TweenAnimationBuilder:通用的隐式 Tween 动画
当现成的 AnimatedContainer、AnimatedOpacity 等组件无法表达需求时,可以使用 TweenAnimationBuilder<T>。
完整示例:
import 'dart:math' as math;
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(
home: TweenDemoPage(),
));
}
class TweenDemoPage extends StatefulWidget {
const TweenDemoPage({super.key});
@override
State<TweenDemoPage> createState() => _TweenDemoPageState();
}
class _TweenDemoPageState extends State<TweenDemoPage> {
bool active = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('TweenAnimationBuilder')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0,
end: active ? 1 : 0,
),
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutBack,
builder: (context, value, child) {
return Transform.rotate(
angle: value * math.pi * 2,
child: Transform.scale(
scale: 0.8 + value * 0.2,
child: child,
),
);
},
child: const Icon(
Icons.favorite,
size: 96,
color: Colors.red,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
setState(() {
active = !active;
});
},
child: Text(active ? '反向目标' : '播放'),
),
],
),
),
);
}
}
运行条件:
- 创建一个 Flutter 项目。
- 将代码放入
lib/main.dart。 - 执行
flutter run。 - 当前稳定 Flutter 和 Dart 3 均支持该写法。
动画过程可以形式化为:
active = false
Tween: begin = 0, end = 0
builder 得到 value = 0
点击按钮
active = true
Tween: begin = 0, end = 1
动画进度从 0 变化到 1
builder 每帧得到新的 value
点击后,builder 中的 value 不是布尔值,而是 Tween 在当前曲线进度下计算出的 double。它可以用于:
- 旋转角度
- 缩放比例
- 自定义绘制参数
- 渐变位置
- 自定义颜色或尺寸
- 多个属性的联动
child 参数用于放置不依赖动画值的子树:
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: child,
);
},
child: const ExpensiveWidget(),
这样做的语义是:动画值变化时,builder 会重新执行,但传入的 child 可以复用,不必在 builder 中重复创建不随动画变化的子树。
5.1 不要复用或修改同一个 Tween 实例
TweenAnimationBuilder 会管理传入 Tween 的动画过程,并可能修改 Tween 的起点以实现连续追踪。因此,不应把同一个可变 Tween 实例在多个组件之间复用,也不应在外部继续修改它。
推荐这样写:
TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0,
end: progress,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, child) {
return LinearProgressIndicator(value: value);
},
)
不应把一个全局 Tween 实例同时交给多个动画组件:
// 不推荐
final sharedTween = Tween<double>(begin: 0, end: 1);
因为 Tween 是可变对象,不是不可变配置值。共享它会使不同动画之间产生隐蔽的数据耦合。
六、状态切换:为什么需要 Key
隐式动画不仅用于数值变化,也经常用于两个不同子组件之间的切换。此时最重要的概念是 Widget identity,也就是组件身份。
6.1 没有 Key 时,Flutter 可能认为还是同一个子组件
下面的代码切换了文字内容:
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
active ? '已开启' : '已关闭',
),
)
如果两个分支最终生成的组件类型和身份不能被区分,Flutter 可能把它们视为同一个 Text 组件的属性更新,而不是“旧子树退出、新子树进入”。
为了明确告诉 Flutter 这是两个不同的状态子树,应使用不同的 Key:
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: child,
);
},
child: Text(
active ? '已开启' : '已关闭',
key: ValueKey(active),
),
)
状态变化过程可以表示为:
active = false
child = Text('已关闭', key: ValueKey(false))
点击后
active = true
child = Text('已开启', key: ValueKey(true))
AnimatedSwitcher 发现 key 不同:
旧 child 执行退出过渡
新 child 执行进入过渡
两者在过渡阶段可能同时存在
ValueKey(active) 的作用不是“开启动画”,而是区分两个子树的身份。AnimatedSwitcher 根据身份差异决定是否将子树视为切换对象。
6.2 AnimatedSwitcher 的完整状态切换示例
class StatusCard extends StatefulWidget {
const StatusCard({super.key});
@override
State<StatusCard> createState() => _StatusCardState();
}
class _StatusCardState extends State<StatusCard> {
bool loading = false;
bool success = false;
Future<void> submit() async {
setState(() {
loading = true;
success = false;
});
try {
await Future<void>.delayed(const Duration(seconds: 1));
if (!mounted) {
return;
}
setState(() {
loading = false;
success = true;
});
} catch (_) {
if (!mounted) {
return;
}
setState(() {
loading = false;
success = false;
});
}
}
@override
Widget build(BuildContext context) {
final Widget content;
if (loading) {
content = const SizedBox(
key: ValueKey('loading'),
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
);
} else if (success) {
content = const Text(
'提交成功',
key: ValueKey('success'),
);
} else {
content = ElevatedButton(
key: const ValueKey('button'),
onPressed: submit,
child: const Text('提交'),
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: content,
);
}
}
这里有三个需要区分的层次:
loading和success是业务状态。content是根据业务状态生成的当前 UI 子树。ValueKey让AnimatedSwitcher能够区分按钮、加载指示器和成功文本。
mounted 检查用于处理异步任务返回时页面已经被移除的情况。如果异步任务完成后直接调用 setState,可能出现:
State 被 dispose
异步操作稍后完成
回调仍调用 setState
Flutter 抛出 setState() called after dispose()
这不是动画特有的问题,但状态切换经常与异步请求结合,因此必须在异步回调中处理生命周期。
6.3 AnimatedSwitcher 在快速切换时的行为
如果状态在动画结束前再次变化,旧子树可能还没有退出完成,新子树就已经进入。AnimatedSwitcher 可以在过渡期间同时管理多个子树。
因此,下面这种状态变化:
A → B → C
不一定只有一个旧组件和一个新组件。快速变化时,过渡阶段可能短时间存在多个子树,实际数量取决于切换速度和动画持续时间。
这会带来两个后果:
- 过渡内容复杂时,短时间内的布局和绘制成本会上升。
- 如果使用没有唯一身份的 Key,多个逻辑状态可能被错误地复用为同一个子树。
Key 应该代表业务上可区分的内容身份,例如:
ValueKey(message.id)
而不是随意在每次 build 中生成随机 Key。随机 Key 会导致 Flutter 每次都认为是全新的子树,可能破坏状态保留并增加重建成本。
七、隐式动画被中断时发生什么
隐式动画不是只处理“静止状态 A 到静止状态 B”。真实应用中,目标值可能在动画完成前再次变化:
目标:100
动画进行到 40
用户再次操作
新目标:0
合理的隐式动画应从当前视觉值附近向新目标过渡,而不是强制回到旧的起点。
可以把过程抽象为:
初始目标:0 → 100
当前显示值:约 40
新目标:0
新的过渡:约 40 → 0
Flutter 的隐式动画状态会在属性更新时根据当前动画值重新建立过渡关系。这样用户快速点击展开、收起时,界面通常表现为连续反向,而不是突然跳回原点。
但要注意以下边界:
- 目标值连续变化时,动画可能始终处于“追赶目标”的状态。
onEnd只适用于真正完成某次动画的场景;如果动画不断被新状态打断,旧目标对应的完成回调不一定按业务期待执行。- 动画回调中修改状态时,需要避免形成无限状态更新。
- 异步任务可能以旧顺序返回,动画组件不会替业务层解决请求竞态。
例如搜索联想中,用户先输入 a,再输入 ab:
请求 a 发出
请求 ab 发出
ab 先返回
a 后返回
此时即使 UI 使用了隐式动画,仍然可能被旧请求结果覆盖。动画只负责视觉过渡,不能代替请求序列号、取消请求或结果校验。
八、生命周期:隐式组件替你管理了什么
隐式动画组件一般基于 ImplicitlyAnimatedWidget 体系实现。其状态对象内部通常会管理:
- 动画控制器。
- 动画时长。
- 曲线转换。
- 新旧属性的比较。
- Tween 的更新。
- 动画完成回调。
dispose时的资源释放。
因此,与显式动画相比,开发者不需要手动写:
late AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(...);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
但是,隐式动画仍然依赖 StatefulElement 的身份保持。如果组件被新的 Key 替换:
AnimatedContainer(
key: ValueKey(screenId),
duration: const Duration(milliseconds: 300),
width: width,
)
当 screenId 改变时,Flutter 可能销毁旧元素并创建新元素。旧动画状态也就不会以连续方式保留。
所以,动画是否连续不仅由目标值决定,还由组件是否保持同一个 Element 身份决定。
九、完整组合示例:尺寸、颜色、旋转和文本切换
下面的示例同时展示:
AnimatedContainer处理尺寸与颜色。AnimatedDefaultTextStyle处理文字样式。TweenAnimationBuilder处理旋转。AnimatedSwitcher处理不同文本子树切换。setState驱动整个状态变化。
import 'dart:math' as math;
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(
home: ImplicitAnimationPage(),
));
}
class ImplicitAnimationPage extends StatefulWidget {
const ImplicitAnimationPage({super.key});
@override
State<ImplicitAnimationPage> createState() =>
_ImplicitAnimationPageState();
}
class _ImplicitAnimationPageState extends State<ImplicitAnimationPage> {
bool expanded = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('隐式动画示例'),
),
body: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 450),
curve: Curves.easeInOut,
width: expanded ? 300 : 220,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: expanded ? Colors.blue.shade50 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(expanded ? 28 : 12),
border: Border.all(
color: expanded ? Colors.blue : Colors.grey,
width: expanded ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: 0,
end: expanded ? 1 : 0,
),
duration: const Duration(milliseconds: 450),
curve: Curves.easeInOut,
builder: (context, value, child) {
return Transform.rotate(
angle: value * math.pi,
child: child,
);
},
child: Icon(
expanded ? Icons.expand_less : Icons.expand_more,
size: 48,
color: Colors.blue,
),
),
const SizedBox(height: 12),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 300),
style: TextStyle(
fontSize: expanded ? 24 : 18,
fontWeight:
expanded ? FontWeight.bold : FontWeight.normal,
color: expanded ? Colors.blue.shade900 : Colors.black87,
),
child: const Text('设置'),
),
const SizedBox(height: 12),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
expanded ? '这里是展开后的详细内容。' : '点击按钮查看详情。',
key: ValueKey(expanded),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
expanded = !expanded;
});
},
child: Text(expanded ? '收起' : '展开'),
),
],
),
),
),
);
}
}
这个例子的状态数据流如下:
expanded = false
├─ 容器较小、浅灰背景
├─ 图标旋转值为 0
├─ 标题字号较小
└─ AnimatedSwitcher 显示收起文本
expanded = true
├─ 容器变大、背景和圆角变化
├─ 图标旋转到 π
├─ 标题字号变大
└─ AnimatedSwitcher 切换到展开文本
同一次 setState 可以驱动多个独立的隐式动画。它们的持续时间和曲线可以相同,也可以不同。Flutter 并不会把这些组件自动合并为一个控制器;每个隐式组件仍然维护自己的动画状态。
十、隐式动画的适用边界
10.1 适合属性从旧值过渡到新值
隐式动画最适合这种问题:
当前状态:卡片收起
目标状态:卡片展开
需要:宽度、颜色、间距、圆角平滑变化
代码只需要描述目标状态:
AnimatedContainer(
width: expanded ? 320 : 160,
duration: const Duration(milliseconds: 300),
)
这类动画的特点是:
- 由状态决定目标值。
- 动画通常只在目标变化时播放。
- 不需要外部手动控制进度。
- 生命周期相对简单。
10.2 不适合复杂时间线
如果需求包含多个严格编排的阶段:
先淡出
再移动
再缩放
最后显示成功图标
或者需要:
- 暂停和恢复。
- 反向播放。
- 循环。
- 根据手势实时控制进度。
- 拖拽过程中逐帧映射手指位置。
- 多个动画共享同一个精确时间轴。
- 监听动画进度并在特定区间触发逻辑。
此时显式动画通常更合适:
class ExplicitDemo extends StatefulWidget {
const ExplicitDemo({super.key});
@override
State<ExplicitDemo> createState() => _ExplicitDemoState();
}
class _ExplicitDemoState extends State<ExplicitDemo>
with SingleTickerProviderStateMixin {
late final AnimationController controller;
late final Animation<double> opacity;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
opacity = CurvedAnimation(
parent: controller,
curve: Curves.easeOut,
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: opacity,
child: const Text('显式动画'),
);
}
}
显式动画增加了控制器和生命周期管理成本,但换来了对时间轴的直接控制。不能因为隐式动画代码更短,就把所有动画都强行建模成属性变化。
10.3 布局动画与绘制动画的边界
动画一个布局属性,可能导致整棵相关布局重新计算。例如:
AnimatedContainer(
height: expanded ? 300 : 100,
child: child,
)
高度变化会影响父布局和兄弟组件的位置。
而平移通常可以使用绘制阶段的组件:
AnimatedSlide(
offset: offset,
duration: const Duration(milliseconds: 300),
child: child,
)
两者在视觉上都可能表现为“移动”,但布局语义不同:
- 布局动画:对象的位置或尺寸参与重新布局。
- 绘制动画:布局结果基本不变,只改变绘制位置或变换。
如果动画对象的变化会导致其他内容重新排版,应接受布局成本并检查溢出;如果只是装饰性移动,优先考虑不改变布局占位的方案。
10.4 大型或复杂子树的性能边界
隐式动画每帧可能导致:
build重新执行。- 布局重新计算。
- 绘制重新执行。
- 复杂阴影、模糊、裁剪或文本排版重复处理。
这并不表示隐式动画一定性能差,而是说明动画属性应与影响范围匹配。
例如,给一个包含大型列表的祖先容器做尺寸动画,可能使大量内容参与布局;而只对一个图标做 Transform,影响范围通常更小。
诊断时可以:
- 在 DevTools 的 Performance 页面观察帧耗时。
- 检查是否出现 UI 线程或 Raster 线程帧超时。
- 暂时移除阴影、模糊和复杂裁剪,确认瓶颈来自布局还是绘制。
- 用
RepaintBoundary分隔适合独立重绘的区域,但不要把它当作无条件的性能修复。
性能问题必须通过实际帧分析确认,不能仅凭组件名称判断。
十一、常见失败表现与诊断方法
11.1 属性变化但没有动画
常见原因包括:
duration 为零
AnimatedContainer(
duration: Duration.zero,
width: width,
)
这表示立即到达目标值,不会产生可见过渡。
组件身份被替换
如果每次构建都使用变化的 Key:
AnimatedContainer(
key: UniqueKey(),
duration: const Duration(milliseconds: 300),
width: width,
)
Flutter 可能每次都创建新的状态对象,组件没有机会从旧动画状态继续过渡。
使用了不支持插值的属性
组件可能只对特定属性提供动画。某些自定义对象或不具备合理 lerp 规则的值,只能直接跳变。
实际变化发生在组件外部
例如外层父组件每次都替换了整个页面,内部的隐式动画状态随之丢失。
排查顺序应是:
确认状态确实改变
↓
确认 setState 在仍然 mounted 时调用
↓
确认 duration 非零
↓
确认组件 Element 身份保持
↓
确认目标属性支持插值
↓
检查父布局是否裁剪或覆盖了动画结果
11.2 AnimatedSwitcher 内容直接替换
首先检查子组件是否有不同的 Key:
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
value,
key: ValueKey(value),
),
)
如果多个状态最终使用同一个 Key,AnimatedSwitcher 会认为它们是同一个子树,可能只更新属性而不执行切换。
另外,Key 应该稳定地表示内容身份。对同一业务状态反复生成新的 UniqueKey,会导致每次构建都触发切换,造成不必要的动画。
11.3 动画期间出现溢出
例如:
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: expanded ? 500 : 100,
child: const Text('一段较长的文本'),
)
在窄屏幕上,目标宽度、文本约束、父组件约束可能互相冲突。动画只是连续地经过中间宽度,并不会自动解决约束问题。
应检查:
- 父组件提供的最大宽度。
- 文本是否允许换行。
- 是否使用了固定宽高。
- 是否在
Row中需要Expanded或Flexible。 - 曲线是否产生了超调。
- Web、桌面窗口缩放时约束是否发生变化。
开发阶段的 RenderFlex overflowed 警告通常不是动画组件本身的错误,而是动画过程中暴露了原本不稳定的布局约束。
11.4 异步回调导致状态错误
动画通常由状态驱动,而状态可能来自异步任务。以下顺序可能出现问题:
请求 A 发出
请求 B 发出
B 先返回并更新 UI
A 后返回并覆盖 UI
解决办法可以是:
- 为每次请求分配递增序号。
- 只接受最新序号的结果。
- 使用可取消的请求机制。
- 在回调中检查
mounted。 - 将业务状态和动画状态分开建模。
动画只能展示当前状态的变化,不能保证状态来源本身是正确的。
十二、平台差异
Flutter 的隐式动画 API 由 Flutter 框架统一提供,Android、iOS、桌面和 Web 通常使用相同的 Dart 代码。差异主要出现在动画所作用的渲染环境:
Android 与 iOS
- 字体、字重和文本度量可能不同。
- 系统无障碍设置可能表达用户对动画的偏好。
- 触摸、滚动物理和系统返回手势的交互行为不同。
- 不同设备刷新率可能不同,动画不应依赖固定帧数。
桌面
- 窗口可以被用户连续调整大小。
- 鼠标悬停、焦点切换和键盘操作更常见。
- 窗口尺寸较大,动画中的布局变化可能影响更多内容。
- 多窗口和窗口切换可能使动画被暂停、延迟或重新获得调度。
Web
- 浏览器标签页切后台时,定时器和渲染调度可能被节流。
- 浏览器窗口尺寸和设备像素比可能动态变化。
- 文本渲染和字体加载可能导致布局结果与移动端不同。
- 浏览器环境可能有不同的无障碍和减少动效设置支持方式。
因此,动画时长和曲线在各平台可以复用,但最终视觉效果仍应在目标平台验证。Flutter 保证的是框架 API 和渲染模型,不保证不同平台的字体、窗口约束、刷新调度和光栅化结果完全一致。
十三、减少动效与可访问性
动画可能给部分用户造成不适,或在特定场景中增加认知负担。应用可以根据无障碍环境减少非必要动画。
例如:
final mediaQuery = MediaQuery.of(context);
final disableAnimations = mediaQuery.disableAnimations;
AnimatedContainer(
duration: disableAnimations
? Duration.zero
: const Duration(milliseconds: 300),
curve: Curves.easeOut,
width: expanded ? 300 : 120,
)
需要注意:
MediaQuery.disableAnimations表示当前媒体环境提供的动画禁用偏好。- 不同平台和宿主环境对系统设置的传递能力可能不同。
- 将时长设为零可以避免可见动画,但业务上仍应保证状态直接到达最终值。
- 不能只删除动画而破坏焦点、语义、交互反馈或布局稳定性。
对于关键状态反馈,不能把动画作为唯一信息来源。例如加载状态、成功状态和错误状态仍应有文本、语义标签或明确控件状态。
十四、隐式动画的选择原则
可以用下面的决策方式判断是否适合隐式动画:
只是某些属性从旧值变化到新值?
├─ 是:优先考虑 AnimatedContainer、AnimatedOpacity 等
└─ 否
是否需要自定义一个值的插值过程?
├─ 是:考虑 TweenAnimationBuilder
└─ 否
是否需要精确控制时间轴、手势进度、暂停、反向或循环?
├─ 是:使用显式动画
└─ 否:重新拆分状态和过渡边界
隐式动画最重要的工程边界是:
它适合描述“状态变化后的视觉过渡”,不适合代替复杂的动画编排器、手势进度控制器或业务状态机。
当状态清晰、目标值明确、过渡相对独立时,隐式动画能减少控制器和生命周期代码;当动画本身成为交互逻辑的一部分时,应使用显式动画或更明确的状态模型。
最终可以将隐式动画概括为:
其中:
- 状态决定终点。
- Tween 决定值如何插值。
- Curve 决定时间如何分配。
- 隐式组件负责控制器、生命周期和逐帧更新。
理解这四个环节后,AnimatedContainer、AnimatedSwitcher 和 TweenAnimationBuilder 的差异就不再只是 API 记忆问题,而可以根据布局、绘制、状态身份和时间控制需求做出明确选择。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter CustomPainter:Canvas、坐标、重绘、命中和性能
- 下一篇:Flutter 显式动画:Controller、Ticker、组合、清理和测试
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论