AtomGit Flutter 鸿蒙客户端: AnimatedScale 与 AnimatedContainer 联合实战
隐式动画是 Flutter 的"甜点层"——不需要 AnimationController,改个值就能动。本文分析 E-Brufen 中两种隐式动画的实战应用。
一、隐式动画 vs 显式动画
| 特性 | 隐式动画 | 显式动画 |
|---|---|---|
| 需要 Controller? | ❌ 不需要 | ✅ 需要 AnimationController |
| 触发方式 | 改变属性值自动过渡 | 调用 forward/reverse/repeat |
| 循环动画? | ❌ 不支持 | ✅ 支持 |
| 代码量 | 极少 | 较多 |
| 适用场景 | 状态切换(A→B) | 连续动画(呼吸、脉冲) |
二、Animated* 隐式动画家族全景
Flutter 提供了十几个 Animated* 组件,覆盖了大部分日常动画需求。了解它们各自的职责,可以避免"手里只有锤子,看什么都是钉子"。
| 组件 | 动画属性 | 典型场景 |
|---|---|---|
AnimatedContainer |
width, height, padding, margin, decoration, alignment… | 容器尺寸、背景色过渡 |
AnimatedScale |
scale (double) | 点击缩放反馈、选中态放大 |
AnimatedOpacity |
opacity (0.0~1.0) | 淡入淡出、加载状态切换 |
AnimatedPadding |
padding (EdgeInsets) | 列表项展开缩进、键盘弹起适配 |
AnimatedAlign |
alignment (Alignment) | 浮动按钮位置切换、拖拽松手回弹 |
AnimatedDefaultTextStyle |
style (TextStyle) | 文字颜色/大小/粗细渐变 |
AnimatedPositioned |
top, left, right, bottom, width, height | Stack 内元素位移(需配合 Stack) |
AnimatedRotation |
turns (double) | 图标旋转、加载指示器(非循环) |
AnimatedSwitcher |
child (Widget) | 两套 UI 之间切换(自带淡入淡出) |
选择心法:
- 只改透明度?用
AnimatedOpacity。 - 只改大小?用
AnimatedScale。 - 改多个盒子属性(宽高、颜色、边距)?用
AnimatedContainer——它是"万能选手",但也是有代价的(见后文性能小节)。 - 切换完全不同的 Widget?用
AnimatedSwitcher,它自带交叉淡入淡出。
★ Insight ─────────────────────────────────────AnimatedContainer之所以"万能",是因为它内部重绘了整个Container——这意味着每次动画帧都会触发 layout + paint。如果只是改透明度,用AnimatedOpacity只需在 compositing 层修改 alpha 值,开销远小于AnimatedContainer。选对组件就是选对性能路径。─────────────────────────────────────────────────
三、MoodPicker 的 AnimatedScale

// mood_picker.dart
GestureDetector(
onTap: () => onChanged(mood),
child: AnimatedScale(
scale: isSelected ? 1.3 : 1.0, // 选中放大 30%
duration: const Duration(milliseconds: 200),
child: Column(
children: [
Text(mood.emoji,
style: TextStyle(
fontSize: isSelected ? 44 : 36,
)),
Text(mood.label,
style: TextStyle(
color: isSelected ? AppTheme.primaryGreen : Colors.grey,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
)),
],
),
),
)
AnimatedScale 做了什么?
当 isSelected 从 false 变为 true:
- 检测到
scale从 1.0 变为 1.3 - 在 200ms 内插值:1.00 → 1.06 → 1.12 → … → 1.30
- 将插值结果赋值给
Transform.scale
当另一个 mood 被选中时:
- 旧的 AnimatedScale 检测到 scale 从 1.3 变为 1.0
- 新的 AnimatedScale 检测到 scale 从 1.0 变为 1.3
- 两个动画同时进行!
★ Insight ─────────────────────────────────────
AnimatedScale 是 Transform.scale 的动画版。如果不用 AnimatedScale,你需要手动创建 AnimationController、Tween、addListener、setState——至少 15 行代码。AnimatedScale 把这一切压缩成了 2 个参数:scale和duration。─────────────────────────────────────────────────
四、MoodChart 的 AnimatedContainer
// mood_chart.dart
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: barHeight, // 从 0 → 计算高度
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
)
动画触发时机:
- 首次加载时(柱子从 0 "生长"到目标高度)
- 切换周时(旧柱子消失、新柱子生长)
- 新增记录时(某一天的柱子变高)
// 柱子的高度计算
final barHeight = chartMax > 0 ? (avg / chartMax * 120) : 0.0;
500ms 的 duration 比 MoodPicker 的 200ms 更长——图表数据的变化需要更"沉稳"的动画感受。
五、自定义隐式动画:TweenAnimationBuilder
Flutter 的 Animated* 家族覆盖了常见属性,但如果需要动画化的属性没有对应的 Animated* 组件怎么办?比如:让一个自定义 CustomPainter 的进图百分比动起来,或者让 LinearProgressIndicator 的值平滑过渡。
这时 TweenAnimationBuilder 就是你的救星。它相当于隐式动画的"空白模板"——你指定一个起始值和目标值,然后在 builder 里用插值结果构建 Widget。
// 示例:让进度条的值平滑过渡
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: progress), // 0 → progress
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Column(
children: [
LinearProgressIndicator(value: value),
SizedBox(height: 8),
Text('${(value * 100).toStringAsFixed(0)}%'),
],
);
},
)
TweenAnimationBuilder vs 显式动画 vs Animated*:
| 方式 | 灵活度 | 代码量 | 循环动画 |
|---|---|---|---|
AnimatedContainer 等 |
低(固定属性) | 最少 | 不支持 |
TweenAnimationBuilder |
高(任意值→任意 Widget) | 中等 | 不支持 |
AnimatedBuilder + Controller |
最高 | 最多 | 支持 |
TweenAnimationBuilder 的杀手锏在于:动画值可以是任何类型——double、Color、Offset、甚至自定义的类,只要你能为它写一个 Tween。在 E-Brufen 中,如果未来要给呼吸训练的进度环加动画,用 TweenAnimationBuilder<double> 驱动 CustomPainter 重绘比手写 Controller 简洁得多。
★ Insight ─────────────────────────────────────TweenAnimationBuilder和AnimatedBuilder名字很像,但本质不同:前者是隐式动画(内部自动管理 Controller,改值触发),后者是显式动画(需要你传入一个外部的Listenable)。如果你看到TweenAnimationBuilder代码里没有AnimationController,那不是漏写了——它确实不需要。─────────────────────────────────────────────────
六、对比总结
| 组件 | 动画属性 | 时长 | 触发条件 |
|---|---|---|---|
| AnimatedScale | scale (1.0↔1.3) | 200ms | 用户选择情绪 |
| AnimatedContainer | height (可变) | 500ms | 数据变化 |
七、动画曲线——给动画注入灵魂
隐式动画默认使用线性插值(Curves.linear),这意味着动画以恒定速度从头到尾运行。听起来很"公平",但在人眼看来,线性动画是最不自然的——现实世界中没有物体能瞬间达到匀速。
Flutter 的 Curves 类提供了几十种缓动曲线,隐式动画通过 curve 参数即可指定:
AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic, // 快进慢出
height: barHeight,
decoration: BoxDecoration(...),
)
常用曲线及其心理感受:
| 曲线 | 行为 | 心理感受 | 推荐场景 |
|---|---|---|---|
Curves.easeOutCubic |
开始快,结束慢 | 沉稳、有质感 | 数据图表、卡片出现 |
Curves.easeInCubic |
开始慢,结束快 | 加速感、紧迫 | 删除操作、警告动画 |
Curves.easeInOut |
两头慢,中间快 | 自然、流畅 | 页面过渡、弹窗 |
Curves.bounceOut |
结束时弹跳 | 活泼、有趣 | 游戏化 UI、成就弹窗 |
Curves.elasticOut |
结束时弹性振荡 | 灵动、有弹性 | 拖拽松手回弹 |
Curves.fastOutSlowIn |
标准 Material 曲线 | 专业、克制 | Material Design 标准过渡 |
选曲线的心法:
- 入场动画用 ease-out(快进慢出)——元素"快速进入视野,然后优雅地停在终点"。比如 MoodChart 的柱子生长。
- 退场动画用 ease-in(慢进快出)——元素"犹豫一下,然后快速离开"。比如删除确认。
- 状态切换用 ease-in-out——对称的过渡最不容易引起注意,适合频繁的开关切换。
★ Insight ─────────────────────────────────────
为什么 E-Brufen 的 MoodPicker(200ms)和 MoodChart(500ms)用了不同的时长?除了功能差异外,还有一个认知心理学的原因:缩放反馈需要更短的时长才能产生"即时响应"的感觉——超过 300ms 的缩放动画会让用户觉得"卡顿"。而柱状图生长是数据可视化的"揭示"动画,稍长的时长能增加数据重要性的感知,让用户觉得"这些数据值得等待"。─────────────────────────────────────────────────
八、隐式动画的原理
// 简化版的 AnimatedScale 内部原理
class AnimatedScale extends StatefulWidget {
final double scale;
final Duration duration;
State<AnimatedScale> createState() => _AnimatedScaleState();
}
class _AnimatedScaleState extends State<AnimatedScale>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double? _oldScale;
void didUpdateWidget(AnimatedScale oldWidget) {
if (widget.scale != oldWidget.scale) {
_oldScale = oldWidget.scale;
_controller.forward(from: 0);
}
}
// ... 内部维护 AnimationController 和 Tween
}
隐式动画的本质是:Flutter 在背后帮你管理了 AnimationController 和 Tween。
九、链式组合——让隐式动画"排队"
单个隐式动画无法实现"先放大,再变色,最后旋转"这样的序列效果——因为所有属性变化共享同一个 duration,同时开始、同时结束。
但这并不意味着隐式动画不能做序列效果。通过错开 setState 调用时机,我们可以用多条隐式动画串联出丰富的动画流程:
void _onSelect() async {
// 第一步:放大(200ms)
setState(() { _isSelected = true; });
// 等待动画完成
await Future.delayed(const Duration(milliseconds: 220));
// 第二步:改变颜色和高度(500ms)
setState(() { _isExpanded = true; });
await Future.delayed(const Duration(milliseconds: 520));
// 第三步:旋转图标(300ms)
setState(() { _arrowTurns = 0.5; });
}
对应的 Widget 树:
AnimatedScale(
scale: _isSelected ? 1.3 : 1.0,
duration: const Duration(milliseconds: 200),
child: AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
height: _isExpanded ? 200 : 80,
color: _isExpanded ? Colors.blue : Colors.grey,
child: AnimatedRotation(
turns: _arrowTurns,
duration: const Duration(milliseconds: 300),
child: Icon(Icons.arrow_drop_down),
),
),
)
这种模式的精髓在于:用 await Future.delayed 替代复杂的 AnimationController 状态机。每一步都是独立的隐式动画,但通过时间差实现了出场的先后顺序。
但要注意两点:
Future.delayed的时长应略大于动画 duration(加 20-40ms 缓冲),确保前一步渲染完毕- 如果 Widget 在动画中途被 dispose(比如用户快速切换页面),
setState会报错——需要加mounted检查
十、何时用哪种动画?
需要连续循环?
├─ 是 → AnimatedBuilder + AnimationController(显式)
└─ 否 → 是简单的属性过渡?
├─ 是 → AnimatedScale/Container/Opacity(隐式)
└─ 否 → AnimatedBuilder(自定义显式)
十一、性能考量——隐式动画的边界
隐式动画虽好,但绝不免费。每个 Animated* 组件内部都维护着一个 AnimationController,而每个 Controller 默认每 16ms 生成一帧(60fps)。当页面上同时存在大量隐式动画时,性能就会亮红灯。
什么时候需要警惕?
- 列表中的 AnimatedContainer:如果
ListView的每个 Item 都包了一个AnimatedContainer,滚动时可能出现掉帧——因为每个 Item 的动画都在 tick。 - 嵌套的隐式动画:三层以上的
Animated*嵌套(比如AnimatedScale > AnimatedContainer > AnimatedOpacity),每帧需要逐层 rebuild。 - 过短的 duration:50ms 以下的动画虽然"快",但帧数太少,动画看起来像"跳"而不是"过渡",反而让用户感觉不流畅。
性能剖析工具:
Flutter DevTools 的 Performance 面板可以直观地看到每一帧的构建耗时。操作步骤:
flutter run --profile启动应用- 打开 DevTools → Performance → 录制
- 触发动画,停止录制
- 检查 UI 线程的帧耗时——超过 16ms 的帧会标红
优化策略:
// ❌ 差:ListView 中每个 Item 都做隐式动画
ListView.builder(
itemBuilder: (context, index) {
return AnimatedContainer( // 100 个 Item = 100 个 Controller
duration: Duration(milliseconds: 300),
height: _expandedIndex == index ? 120 : 60,
child: ...
);
},
)
// ✅ 好:只对当前选中项做动画,其余保持静态
ListView.builder(
itemBuilder: (context, index) {
if (_expandedIndex == index) {
return AnimatedContainer(
duration: Duration(milliseconds: 300),
height: 120,
child: ...
);
}
return SizedBox(height: 60, child: ...); // 静态 Item
},
)
核心原则:只在需要动画的 Widget 上使用 Animated* 组件,静态 UI 就保持静态。一个页面如果有 50 个 AnimatedContainer,即使它们当前的属性值没有变化,每个框架仍会进行"值是否变化"的 diff 判断,徒增开销。
小结
隐式动画是 Flutter 生产力的秘密武器。E-Brufen 中,MoodPicker 用 AnimatedScale 实现选择反馈,MoodChart 用 AnimatedContainer 实现柱子生长——两处加起来不到 10 行动画代码,效果却非常自然流畅。
作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化,项目地址:AtomGit - E-Brufen。
更多推荐


所有评论(0)