动画
这一课你将能做到
- 用
AnimatedContainer 等隐式动画 Widget 一行实现属性过渡
- 用
AnimationController + Tween + AnimatedBuilder 写显式动画,并控制曲线、时长、重复
- 实现 Hero 跨页面共享元素动画,以及交错(Staggered)动画
为什么要学
现代 App 的「高级感」很大程度来自动画。Flutter 动画体系分两层:隐式动画(简单属性变化)和显式动画(自己控制 AnimationController)。不会显式动画,就只能做静态页面。
人话:隐式动画像「自动变速」,告诉它终点就行;显式动画像「手动挡」,你自己控制时间和曲线,能力更强但要管更多。
先建立图像
隐式动画 显式动画
AnimatedContainer AnimationController (时钟,0→1)
AnimatedOpacity │
AnimatedPositioned ▼
↓ Tween (范围映射:0→1 映射到 100→200)
内部自动补间 │
▼
Animation<double> (当前值)
│
▼
AnimatedBuilder / addListener → setState
跟着做
步骤 1:隐式动画 5 分钟上手
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ImplicitDemo()));
class ImplicitDemo extends StatefulWidget {
const ImplicitDemo({super.key});
@override
State<ImplicitDemo> createState() => _ImplicitDemoState();
}
class _ImplicitDemoState extends State<ImplicitDemo> {
bool _big = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: GestureDetector(
onTap: () => setState(() => _big = !_big),
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
width: _big ? 240 : 120,
height: _big ? 240 : 120,
decoration: BoxDecoration(
color: _big ? Colors.orange : Colors.blue,
borderRadius: BorderRadius.circular(_big ? 60 : 12),
),
child: const Icon(Icons.favorite, color: Colors.white, size: 48),
),
),
),
);
}
}
点一下,方块在 400ms 内平滑变大、变色、变圆。只改字段值,框架自动补间。
常用隐式动画 Widget:AnimatedOpacity、AnimatedPadding、AnimatedAlign、AnimatedPositioned、AnimatedSwitcher、AnimatedCrossFade、。
AnimatedDefaultTextStyle
步骤 2:显式动画三件套
显式动画 = AnimationController(时钟) + Tween(取值范围) + AnimatedBuilder(重建)。
class SpinDemo extends StatefulWidget {
const SpinDemo({super.key});
@override
State<SpinDemo> createState() => _SpinDemoState();
}
class _SpinDemoState extends State<SpinDemo> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _rotation;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(); // 循环
_rotation = Tween<double>(begin: 0, end: 6.283).animate(
CurvedAnimation(parent: _ctrl, curve: Curves.linear),
);
}
@override
void dispose() {
_ctrl.dispose(); // 必须 dispose
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: AnimatedBuilder(
animation: _rotation,
builder: (ctx, child) =>
Transform.rotate(angle: _rotation.value, child: child),
child: const Icon(Icons.refresh, size: 64, color: Colors.blue),
),
),
);
}
}
with SingleTickerProviderStateMixin 给 State 提供 vsync,避免离屏动画浪费帧
CurvedAnimation 包一层可以得到非线性曲线
AnimatedBuilder 的 child 参数:不变的子树通过 child 传入,避免每帧重建
步骤 3:动画控制 — 前进 / 反转 / 停止
_ctrl.forward(); // 0 → 1
_ctrl.reverse(); // 1 → 0
_ctrl.stop();
_ctrl.reset(); // 回到 0
_ctrl.repeat(reverse: true); // 来回循环
_ctrl.animateTo(0.6); // 跑到指定值
// 监听状态
_ctrl.addStatusListener((s) {
if (s == AnimationStatus.completed) _ctrl.reverse();
if (s == AnimationStatus.dismissed) _ctrl.forward();
});
步骤 4:Hero 跨页面动画
// 列表页
class ListPage extends StatelessWidget {
const ListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('列表')),
body: ListView.builder(
itemCount: 10,
itemBuilder: (ctx, i) => ListTile(
leading: Hero(
tag: 'avatar-$i',
child: CircleAvatar(child: Text('$i')),
),
title: Text('用户 $i'),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailPage(index: i)),
),
),
),
);
}
}
// 详情页
class DetailPage extends StatelessWidget {
final int index;
const DetailPage({super.key, required this.index});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Hero(
tag: 'avatar-$index', // 同一个 tag
child: CircleAvatar(radius: 80, child: Text('$index', style: const TextStyle(fontSize: 48))),
),
),
);
}
}
规则:两个页面的 Hero 用同一个 tag,框架自动过渡。可以在 Hero 外套 Material 避免文字下划线闪烁。
步骤 5:交错动画(Staggered)
class StaggeredDemo extends StatefulWidget {
const StaggeredDemo({super.key});
@override
State<StaggeredDemo> createState() => _StaggeredDemoState();
}
class _StaggeredDemoState extends State<StaggeredDemo>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..forward();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
Animation<double> _item(double begin, double end) =>
Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _ctrl,
curve: Interval(begin, end, curve: Curves.easeOut),
),
);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < 4; i++)
FadeTransition(
opacity: _item(i * 0.2, i * 0.2 + 0.4),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _ctrl,
curve: Interval(i * 0.2, i * 0.2 + 0.4, curve: Curves.easeOut),
)),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: CircleAvatar(radius: 24, child: Text('$i')),
),
),
),
],
),
),
);
}
}
Interval(begin, end) 把 controller 的 0-1 切成不同时间窗,让一个 controller 驱动多个动画错开播放。
步骤 6:页面切换过渡
Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 350),
pageBuilder: (_, __, ___) => const DetailPage(index: 0),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(begin: const Offset(0, 0.1), end: Offset.zero)
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: child,
),
);
},
),
);
讲清楚要点
- 隐式 vs 显式:属性变化简单 → 隐式;要循环、要精细控制、要多动画编排 → 显式。
- AnimationController 是心脏:
vsync 提供帧同步,duration 决定速度;范围默认 0.0-1.0。
- Tween 把 0-1 映射到任意值:
Tween<double>(0, 200)、ColorTween(begin: red, end: blue)。
- Curves 决定「手感」:
Curves.easeOut(先快后慢,最常见)、Curves.elasticOut(弹性)、Curves.bounceOut。
- 必须 dispose controller:忘记 dispose 是常见内存泄漏源。
你可能会懵的地方
-
vsync: this 是干嘛的?
this 来自 SingleTickerProviderStateMixin,提供 Ticker,让动画跟着屏幕刷新率走,并且在页面不可见时暂停省电。
-
SingleTicker 和 Ticker 区别?
一个 State 同时只有一个 controller 用 Single;多个用 TickerProviderStateMixin。
-
AnimatedBuilder vs setState 哪个好?
AnimatedBuilder 只重建 builder 内的子树,性能更好;用 addListener+setState 会重建整个 State 的 build。
-
为什么 Hero 文字会有下划线黄条?
详情页没有 Material 包裹文字。在 Hero 外套一层 Material(color: Colors.transparent, child: ...)。
-
动画卡顿怎么排查?
DevTools → Performance,看每帧是否超 16ms。常见原因:动画 builder 里构建大型子树(用 child 缓存)、paint 太复杂(用 RepaintBoundary)。
错法对照
| 错法 | 问题 | 正解 |
|---|
AnimationController 在 build 里创建 | 每次 rebuild 都新建,内存泄漏 | initState 创建,dispose 销毁 |
忘记 _ctrl.dispose() | 内存泄漏,发热 | State.dispose 里 dispose controller |
用 addListener + setState 写所有动画 | 整个 build 每帧重建 | 用 AnimatedBuilder 圈小重建范围 |
| AnimatedBuilder 的 builder 内部 new 大量 Widget | 每帧重建所有子 Widget | 不变化的子树通过 child 参数传入 |
| Hero 的 tag 用 index 当字符串 | 跨页面/跨列表 tag 冲突 | tag 加上业务 id,保证全局唯一 |
| 一个 controller 在多个 State 用 vsync: this | 多个 vsync 来源冲突 | 每个 State 自己的 controller |
卡点急救
- 动画不播放:
_ctrl.forward() 调了吗?vsync 传了吗?
Ticker was disposed but was scheduled:dispose 后 controller 还在被调用。确保 dispose() 顺序:先 _ctrl.dispose() 再 super.dispose()。
- 曲线没效果:
CurvedAnimation 必须包在 Tween.animate(...) 里面。
- Hero 跳变不流畅:两个页面的 Hero child 尺寸差太多;用
flightShuttleBuilder 自定义过渡。
setState() called after dispose():异步回调里调用 setState 之前 if (!mounted) return;。
面试常问
-
隐式动画和显式动画区别?
隐式动画(AnimatedContainer 等)内部封装了 controller,只需改字段;显式动画自己持有 AnimationController,可以精确控制播放、重复、反转、组合多个动画。
-
AnimationController 与 Tween 的关系?
Controller 提供 0-1 的时间进度;Tween 把 0-1 映射到目标值域(位置、颜色、大小)。Tween.animate(controller) 把它们组合成一个 Animation。
-
Hero 动画原理?
路由切换时,框架找到源页面和目标页面 tag 相同的 Hero,把它的 RenderObject 从原树摘下,在两个页面之间做一次 overlay 动画过渡到目标位置和大小。
-
怎么实现一个呼吸灯效果?
AnimationController(duration: 1s)..repeat(reverse: true) 驱动 Tween<double>(0.5, 1.0),绑定到 Opacity。
自测三问
- AnimationController 默认输出范围是多少?怎么映射到 0~200?
- 为什么要
with SingleTickerProviderStateMixin?什么时候用 TickerProviderStateMixin?
- 写一个 Hero 动画:从列表的圆形头像过渡到详情页的大头像,需要哪两个条件?
小结一句话
隐式动画处理简单属性变化,显式动画用 Controller + Tween + AnimatedBuilder 处理一切复杂场景;Hero 解决跨页面共享元素过渡。
延伸