目标
- 理解隐式动画与显式动画的区别与适用场景。
- 掌握
AnimatedContainer、AnimatedOpacity、AnimatedPadding、AnimatedAlign、AnimatedPositioned、AnimatedCrossFade、AnimatedSwitcher。 - 掌握
TweenAnimationBuilder的简化动画写法。 - 掌握
AnimationController、Tween、CurvedAnimation、AnimatedBuilder、ScaleTransition的显式动画流程。 - 掌握
Hero页面共享元素过渡动画、PageRouteBuilder自定义页面转场。 - 能实现点赞心跳、展开卡片、淡入淡出、页面共享元素、进度动画、组合动画。
- 能识别并修复
AnimationController未释放、Ticker 相关错误、setState() called after dispose()。 - 通过"题目要求 → 答案 → 答案解读"的多元化习题完成练中学。
一、知识地图
动画与过渡
│
├── 隐式动画(改属性 → 自动补间)
│ ├── AnimatedContainer 宽高 / 颜色 / 圆角 / 边距
│ ├── AnimatedOpacity 透明度
│ ├── AnimatedPadding 内边距
│ ├── AnimatedAlign 对齐位置
│ ├── AnimatedPositioned Stack 中位置
│ ├── AnimatedDefaultTextStyle 文字样式
│ ├── AnimatedCrossFade 两个子组件之间淡入淡出
│ ├── AnimatedSwitcher 子组件切换
│ └── AnimatedSize 尺寸变化
│
├── 简化显式动画
│ └── TweenAnimationBuilder 自动管理控制器
│
├── 显式动画(手动控制)
│ ├── AnimationController 控制器:play / pause / reverse / repeat
│ ├── Tween 起止值
│ ├── CurvedAnimation 缓动曲线
│ ├── AnimatedBuilder 监听构建
│ ├── ScaleTransition 缩放
│ ├── FadeTransition 淡入淡出
│ ├── SlideTransition 滑动
│ └── RotationTransition 旋转
│
├── 过渡动画
│ ├── Hero 页面共享元素
│ └── PageRouteBuilder 自定义页面转场
│
└── 生命周期
├── SingleTickerProviderStateMixin 单个控制器
├── TickerProviderStateMixin 多个控制器
└── dispose() 必须释放
二、快速参考卡
隐式 vs 显式
| 维度 | 隐式动画 | 显式动画 |
|---|---|---|
| 写法 | 改属性,自动补间 | 创建 Controller,手动驱动 |
| 代表 | AnimatedContainer |
AnimationController |
| 控制 | 只能设 duration / curve | 可暂停、反转、循环、监听状态 |
| 适用 | 状态切换的简单动画 | 复杂动画、连续动画、交互动画 |
| 代码量 | 少 | 多 |
| 性能 | 自动优化 | 需手动优化重建范围 |
常用隐式动画
| Widget | 动画属性 | 典型场景 |
|---|---|---|
AnimatedContainer |
width / height / color / padding / margin / decoration | 卡片展开、按钮变色 |
AnimatedOpacity |
opacity | 淡入淡出 |
AnimatedPadding |
padding | 内边距过渡 |
AnimatedAlign |
alignment | 位置切换 |
AnimatedPositioned |
left / top / right / bottom | Stack 中移动 |
AnimatedDefaultTextStyle |
style | 文字样式变化 |
AnimatedCrossFade |
crossFadeState | 两个子组件切换 |
AnimatedSwitcher |
child | 内容切换 |
AnimatedSize |
child 尺寸 | 高度自适应 |
AnimationController 常用方法
| 方法 | 作用 |
|---|---|
forward() |
从当前值播放到 1.0 |
reverse() |
从当前值反向到 0.0 |
repeat() |
循环播放 |
repeat(reverse: true) |
往返循环 |
stop() |
停止 |
reset() |
重置到 0.0 |
dispose() |
释放(必须) |
addStatusListener |
监听状态变化 |
addListener |
监听值变化 |
常用 curve
| curve | 效果 | 适用 |
|---|---|---|
Curves.linear |
匀速 | 进度条 |
Curves.easeIn |
慢启动 | 入场 |
Curves.easeOut |
慢结束 | 出场 |
Curves.easeInOut |
两端慢 | 通用 |
Curves.bounceOut |
结尾回弹 | 点赞 |
Curves.elasticOut |
弹性拉伸 | 强调 |
Curves.fastOutSlowIn |
Material 标准 | Material 设计 |
一句话口诀
状态变了想动画,用隐式;想控制播放节奏,用显式;页面跳转共享元素,用 Hero。
隐式与显式对比图
隐式:setState 改属性 → AnimatedXxx 自动补间 → 界面平滑变化
显式:AnimationController.forward()
↓
Tween 插值
↓
AnimatedBuilder 重建
↓
界面平滑变化
三、热身:最小可运行示例
dart
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(home: const AnimatedBoxPage());
}
}
class AnimatedBoxPage extends StatefulWidget {
const AnimatedBoxPage({super.key});
@override
State<AnimatedBoxPage> createState() => _AnimatedBoxPageState();
}
class _AnimatedBoxPageState extends State<AnimatedBoxPage> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('隐式动画')),
body: Center(
child: GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
width: _expanded ? 240 : 120,
height: _expanded ? 240 : 120,
decoration: BoxDecoration(
color: _expanded ? Colors.deepPurple : Colors.blue,
borderRadius: BorderRadius.circular(_expanded ? 32 : 8),
),
alignment: Alignment.center,
child: const Text(
'点我',
style: TextStyle(color: Colors.white, fontSize: 20),
),
),
),
),
);
}
}
按 r 热重载,点击方块,观察宽高、颜色、圆角平滑变化。
热身任务 :点击方块 3 次,观察每次变化。然后按 R 热重启,_expanded 回到 false,方块回到初始状态。这说明状态驱动动画:状态变,动画自动补间;状态重置,动画也重置。
四、多元化习题
难度:⭐ 入门 ⭐⭐ 进阶 ⭐⭐⭐ 综合
每题标注【练什么】,帮你明确训练目标。
练习0:热重载体验动画 ⭐
【练什么】duration、curve 对动画的影响
题目要求
运行热身示例,完成以下操作并记录变化:
- 把
duration改成2秒,观察动画速度。 - 把
curve改成Curves.bounceOut,观察回弹效果。 - 把
AnimatedContainer换成普通Container,观察差异。
答案
dart
// 1. 变慢
duration: const Duration(seconds: 2),
// 2. 回弹曲线
curve: Curves.bounceOut,
// 3. 普通 Container 直接跳变
Container(
width: _expanded ? 240 : 120,
height: _expanded ? 240 : 120,
decoration: BoxDecoration(
color: _expanded ? Colors.deepPurple : Colors.blue,
borderRadius: BorderRadius.circular(_expanded ? 32 : 8),
),
alignment: Alignment.center,
child: const Text('点我', style: TextStyle(color: Colors.white, fontSize: 20)),
)
答案解读
duration控制动画时长。时间越长,补间越慢;时间越短,变化越快。curve控制缓动曲线。easeInOut是两端慢中间快,bounceOut是结尾回弹,linear是匀速。AnimatedContainer在属性变化时自动补间;普通Container会瞬间跳变。
隐式动画的核心:属性变化 + 自动补间。你只需要改状态,Flutter 负责插值和渲染。
为什么 curve 能让动画更自然?
真实世界的运动不是匀速的。物体启动时加速,停止时减速。Curves 模拟这种物理规律,让动画更符合直觉。bounceOut 模拟弹跳落地,elasticOut 模拟弹簧。
练习1:选择题 ⭐
【练什么】隐式动画的识别
题目要求
以下哪个 Widget 属于隐式动画?
A. AnimationController
B. AnimatedOpacity
C. Tween
D. CurvedAnimation
答案
B。
答案解读
AnimatedOpacity是隐式动画,改变opacity值后自动补间。AnimationController、Tween、CurvedAnimation都是显式动画的组成部分,需要手动驱动。
识别技巧 :名字以 Animated 开头的 Widget 几乎都是隐式动画。AnimatedContainer、AnimatedOpacity、AnimatedPadding、AnimatedAlign、AnimatedSwitcher、AnimatedCrossFade、AnimatedSize、AnimatedPositioned、AnimatedDefaultTextStyle。
练习2:判断题 ⭐
【练什么】动画概念边界
题目要求
判断对错,并说明理由:
AnimatedContainer会在width、height、color变化时自动动画。AnimationController不需要释放。Hero用于页面间共享元素过渡。TweenAnimationBuilder需要手动创建AnimationController。AnimationController的默认值范围是 0 到 1。
答案
- 对。
- 错。
- 对。
- 错。
- 对。
答案解读
AnimatedContainer会自动对可动画属性做补间。AnimationController必须在dispose()中释放,否则会泄漏。Hero用于两个页面之间共享同一个 Widget 的过渡动画。TweenAnimationBuilder内部自动管理控制器,无需手动创建。AnimationController默认值范围是 0 到 1,可以直接用作opacity、scale等。
为什么 AnimationController 必须释放?
它持有 Ticker,Ticker 会向系统注册每帧回调。不释放会导致页面销毁后仍在消耗资源,触发内存泄漏警告。记住:创建了 AnimationController,就必须在 dispose() 中释放。
练习3:连线题 ⭐
【练什么】隐式动画组件与作用对应
题目要求
将左侧组件与右侧作用连线:
| 左侧 | 右侧 |
|---|---|
| AnimatedOpacity | 内边距动画 |
| AnimatedPadding | 透明度动画 |
| AnimatedAlign | 页面共享元素 |
| AnimatedSwitcher | 对齐位置动画 |
| Hero | 子组件切换动画 |
答案
- AnimatedOpacity --- 透明度动画
- AnimatedPadding --- 内边距动画
- AnimatedAlign --- 对齐位置动画
- AnimatedSwitcher --- 子组件切换动画
- Hero --- 页面共享元素
答案解读
隐式动画组件命名规律是 Animated + 属性名,作用就是对该属性做补间。Hero 属于过渡动画,跨页面生效。
更多隐式动画:
AnimatedPositioned:Stack 中位置变化。AnimatedDefaultTextStyle:文字样式变化。AnimatedCrossFade:两个子组件之间淡入淡出。AnimatedSize:尺寸变化。
练习4:排序题 ⭐⭐
【练什么】显式动画的创建流程
题目要求
实现一个显式动画,按正确顺序排列:
在 initState 中创建并启动 controller
在 dispose 中释放 controller
创建 AnimationController 并指定 vsync
用 AnimatedBuilder 监听并构建界面
答案
创建 AnimationController 并指定 vsync → 在 initState 中创建并启动 controller → 用 AnimatedBuilder 监听并构建界面 → 在 dispose 中释放 controller
答案解读
显式动画四步:创建、启动、监听、释放。顺序不能乱。
为什么需要 vsync?
vsync 是 TickerProvider 的缩写,它让 AnimationController 与屏幕刷新率同步。SingleTickerProviderStateMixin 提供了一个 Ticker,让动画每帧更新。
为什么用 SingleTickerProviderStateMixin?
- 一个页面只有一个
AnimationController时用SingleTickerProviderStateMixin。 - 有多个
AnimationController时用TickerProviderStateMixin。 - 不混入的话,
vsync参数无法提供,编译报错。
练习5:填空题 ⭐
【练什么】淡入淡出
题目要求
补全代码,实现淡入淡出:
dart
AnimatedOpacity(
opacity: _visible ? ______ : ______,
duration: const Duration(milliseconds: 300),
child: const Text('我会淡入淡出'),
)
答案
dart
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: const Text('我会淡入淡出'),
)
答案解读
opacity取值 0 到 1。0 表示完全透明,1 表示完全不透明。- 切换
_visible后,AnimatedOpacity会在 300 毫秒内平滑过渡透明度。 - 这是最常用的隐式动画之一,适合做显示/隐藏、淡入/淡出效果。
注意 :opacity 为 0 时,子组件仍然占据空间,只是不可见。如果要同时移除空间,用 AnimatedSwitcher 或 Visibility。
练习6:代码预测题 ⭐⭐
【练什么】显式动画的连续播放
题目要求
阅读代码,预测动画效果:
dart
class _DemoState extends State<Demo> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _controller,
child: const FlutterLogo(size: 100),
);
}
}
答案
Flutter Logo 会持续放大缩小,1 秒一个来回,无限循环。
答案解读
repeat(reverse: true)让动画反复播放并反向。ScaleTransition监听_controller的值并缩放子组件。AnimationController默认值范围 0 到 1,直接用作 scale 就是 0 到 1 倍缩放。
注意 :scale: _controller 时,scale 值从 0 到 1。如果想让 scale 从 1 到 1.4,需要配合 Tween:
dart
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
final _scale = Tween(begin: 1.0, end: 1.4).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
// 使用
ScaleTransition(scale: _scale, child: ...)
练习7:改错题 ⭐⭐
【练什么】显式动画的常见错误
题目要求
下面代码有错误,找出来并修正:
dart
class _DemoState extends State<Demo> {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
);
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _controller,
child: const Text('淡入'),
);
}
}
答案
修正后:
dart
class _DemoState extends State<Demo> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _controller,
child: const Text('淡入'),
);
}
}
答案解读
三个错误:
- 缺少
vsync: this:需要混入SingleTickerProviderStateMixin,否则vsync参数无法提供。 - 没有释放控制器 :必须在
dispose()中调用_controller.dispose()。 - 没有启动动画 :
AnimationController创建后默认停在 0.0,需要调用forward()才会播放。
修正步骤 :混入 Mixin → 加 vsync: this → 加 ..forward() → 加 dispose()。
练习8:补全代码题 ⭐⭐
【练什么】TweenAnimationBuilder 数字动画
题目要求
用 TweenAnimationBuilder 实现数字从 0 平滑增长到 100:
dart
TweenAnimationBuilder<double>(
tween: Tween(begin: ______, end: ______),
duration: const Duration(seconds: 2),
builder: (context, value, child) {
return Text('${value.round()}', style: const TextStyle(fontSize: 48));
},
)
答案
dart
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 100),
duration: const Duration(seconds: 2),
builder: (context, value, child) {
return Text('${value.round()}', style: const TextStyle(fontSize: 48));
},
)
答案解读
TweenAnimationBuilder内部自动创建控制器。tween定义起止值,builder中拿到当前插值value构建界面。- 改
end值即可重新动画。
为什么比显式动画简单?
不需要手动创建 AnimationController、不需要 vsync、不需要 dispose。适合一次性动画、数值过渡、进度条等场景。
如果想让数字在点击时重新动画:
dart
double _target = 0;
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _target),
duration: const Duration(seconds: 2),
builder: (context, value, child) => Text('${value.round()}'),
)
// 点击按钮
onPressed: () => setState(() => _target = 100),
练习9:动手编码题:点赞心跳 ⭐⭐
【练什么】AnimationController + Tween + ScaleTransition
题目要求
做一个点赞按钮,点击时爱心图标放大再回弹,同时显示点赞数。
答案
dart
class LikeButton extends StatefulWidget {
const LikeButton({super.key});
@override
State<LikeButton> createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scale;
bool _liked = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scale = Tween(begin: 1.0, end: 1.4).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_controller.reverse();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTap() {
setState(() => _liked = !_liked);
_controller.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ScaleTransition(
scale: _scale,
child: Icon(
_liked ? Icons.favorite : Icons.favorite_border,
color: _liked ? Colors.red : Colors.grey,
size: 48,
),
),
const SizedBox(height: 8),
Text(_liked ? '已点赞' : '点赞'),
],
),
);
}
}
答案解读
AnimationController驱动Tween(1.0 → 1.4),ScaleTransition把缩放应用到图标上。- 状态监听在动画完成后反向播放,形成回弹效果。
- 点击时切换
_liked并从头播放动画。
关键点:
_controller.forward(from: 0)让动画从 0 开始,而不是从当前位置继续。addStatusListener监听AnimationStatus.completed,动画完成时触发反向。reverse()会让动画从 1.4 回到 1.0,形成回弹。
练习10:动手编码题:Hero 页面过渡 ⭐⭐
【练什么】Hero 跨页面共享元素
题目要求
做一个列表页和详情页。列表页点击某张卡片时,用 Hero 让卡片平滑过渡到详情页顶部。
答案
dart
// 列表页
class HeroListPage extends StatelessWidget {
const HeroListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('列表')),
body: ListView.builder(
itemCount: 5,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => HeroDetailPage(index: index)),
),
child: Hero(
tag: 'item_$index',
child: Container(
height: 80,
margin: const EdgeInsets.all(8),
color: Colors.primaries[index % Colors.primaries.length],
alignment: Alignment.center,
child: Text('第 $index 项',
style: const TextStyle(color: Colors.white)),
),
),
);
},
),
);
}
}
// 详情页
class HeroDetailPage extends StatelessWidget {
final int index;
const HeroDetailPage({super.key, required this.index});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('详情 $index')),
body: Hero(
tag: 'item_$index',
child: Container(
height: 240,
color: Colors.primaries[index % Colors.primaries.length],
alignment: Alignment.center,
child: Text('第 $index 项',
style: const TextStyle(color: Colors.white, fontSize: 24)),
),
),
);
}
}
答案解读
Hero的tag在两个页面中必须一致,Flutter 会自动匹配并做过渡。- 列表项和详情页的 Hero 子组件可以不同,但 tag 必须相同。
- 这是页面过渡最常用的效果。
Hero 工作原理:
- 用户点击列表项,触发
Navigator.push。 - Flutter 在当前页面找到
tag匹配的 Hero,记录其位置和大小。 - 在新页面找到相同 tag 的 Hero,记录其目标位置和大小。
- Flutter 在过渡期间创建一个"飞行"的 Hero,从起始位置动画到目标位置。
常见错误:
- tag 不匹配:两个页面 tag 不一致,Hero 不生效。
- tag 重复 :同一页面有多个相同 tag 的 Hero,报
Multiple heroes share the same tag。 - Hero 包裹了非 Material 组件 :文字可能没有样式,用
Material包裹。
练习11:综合挑战:可展开卡片 ⭐⭐⭐
【练什么】AnimatedCrossFade + 点击展开
题目要求
做一个可展开的卡片:点击标题时,卡片平滑展开显示更多内容;再次点击收起。
答案
dart
class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key});
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(16),
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('点击展开',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: _expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: const SizedBox.shrink(),
secondChild: const Padding(
padding: EdgeInsets.only(top: 12),
child: Text(
'这里是展开后的详细内容。'
'AnimatedCrossFade 会在两个子组件之间做淡入淡出过渡。',
),
),
),
],
),
),
),
);
}
}
答案解读
AnimatedCrossFade在两个子组件之间做淡入淡出。crossFadeState控制显示哪个。SizedBox.shrink()作为收起时的空内容。- 配合
InkWell点击切换状态,就能实现平滑展开收起。
AnimatedSize 对比:
dart
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: _expanded
? const Text('展开后的内容')
: const SizedBox.shrink(),
)
AnimatedCrossFade:两个子组件之间切换,适合"有内容 / 无内容"。AnimatedSize:子组件尺寸变化时自动补间,适合"高度变化"。
练习12:开放式挑战:加载进度动画 ⭐⭐⭐
【练什么】AnimationController + Tween + AnimatedBuilder
题目要求
用 AnimationController 和 Tween 实现一个圆形进度动画:从 0 到 1 播放 3 秒,中间显示百分比,结束后显示"完成"。
答案示例
dart
class ProgressDemo extends StatefulWidget {
const ProgressDemo({super.key});
@override
State<ProgressDemo> createState() => _ProgressDemoState();
}
class _ProgressDemoState extends State<ProgressDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _progress;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 3),
vsync: this,
);
_progress = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('进度动画')),
body: Center(
child: AnimatedBuilder(
animation: _progress,
builder: (context, child) {
final value = _progress.value;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 160,
height: 160,
child: CircularProgressIndicator(
value: value,
strokeWidth: 10,
),
),
const SizedBox(height: 16),
Text(
value >= 1.0 ? '完成' : '${(value * 100).round()}%',
style: const TextStyle(fontSize: 24),
),
],
);
},
),
),
);
}
}
答案解读
AnimationController驱动Tween(0 → 1),AnimatedBuilder监听变化重建。CircularProgressIndicator的value接收进度值。value >= 1.0时显示"完成"。- 这是自定义进度动画的标准写法。
为什么用 AnimatedBuilder 而不是 setState?
AnimatedBuilder只重建builder内的子树,性能更好。setState会重建整个 Widget,如果页面复杂,性能会下降。AnimatedBuilder是显式动画的标准写法。
练习13:错误观察挑战 ⭐⭐
【练什么】AnimationController 未释放
题目要求
故意制造一次 AnimationController 未释放的错误:创建控制器但不写 dispose(),然后频繁切换页面。观察控制台提示,记录关键错误词。
答案
示例错误代码:
dart
class _DemoState extends State<Demo> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat();
}
// 缺少 dispose
@override
Widget build(BuildContext context) {
return FadeTransition(opacity: _controller, child: const Text('动画'));
}
}
控制台常见错误或警告:
AnimationController.dispose() called more than once.
或:
A Ticker was active when its widget was disposed.
或内存泄漏警告。
答案解读
AnimationController持有Ticker,如果不释放,页面销毁后 Ticker 仍在运行,造成资源泄漏。- 正确做法是在
dispose()中调用_controller.dispose()。 - 如果动画还在运行,先
stop()再dispose()更安全。
标准写法:
dart
@override
void dispose() {
_controller.stop(); // 可选:先停止
_controller.dispose(); // 必须:释放
super.dispose();
}
练习14:进阶挑战:组合动画 ⭐⭐⭐
【练什么】多个 Tween 共享一个 Controller
题目要求
做一个卡片,点击时同时执行:缩放(1.0 → 1.1)、透明度(0.5 → 1.0)、旋转(0 → 0.1 弧度)。三个动画同时播放 500 毫秒。
答案
dart
class ComboAnimationPage extends StatefulWidget {
const ComboAnimationPage({super.key});
@override
State<ComboAnimationPage> createState() => _ComboAnimationPageState();
}
class _ComboAnimationPageState extends State<ComboAnimationPage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scale = Tween(begin: 1.0, end: 1.1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
final opacity = Tween(begin: 0.5, end: 1.0).animate(_controller);
final rotation = Tween(begin: 0.0, end: 0.1).animate(_controller);
return Scaffold(
appBar: AppBar(title: const Text('组合动画')),
body: Center(
child: GestureDetector(
onTap: () => _controller.forward(from: 0),
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: opacity.value,
child: Transform.rotate(
angle: rotation.value,
child: Transform.scale(
scale: scale.value,
child: Container(
width: 200,
height: 200,
color: Colors.deepPurple,
alignment: Alignment.center,
child: const Text(
'点我',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
),
),
);
},
),
),
),
);
}
}
答案解读
- 一个
AnimationController可以驱动多个Tween。 - 每个
Tween用animate(_controller)绑定控制器。 AnimatedBuilder监听控制器,同时更新透明度、旋转、缩放。
性能优化 :把不依赖动画的子树作为 child 传入,避免重复构建:
dart
AnimatedBuilder(
animation: _controller,
child: Container(width: 200, height: 200, color: Colors.deepPurple),
builder: (context, child) {
return Transform.scale(scale: scale.value, child: child);
},
)
练习15:进阶挑战:Hero + 自定义过渡 ⭐⭐⭐
【练什么】Hero 配合 PageRouteBuilder
题目要求
在 Hero 过渡的基础上,让详情页从右侧滑入,而不是默认的底部弹入。
答案
dart
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 400),
pageBuilder: (context, animation, secondaryAnimation) {
return HeroDetailPage(index: index);
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final offsetTween = Tween(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(
position: animation.drive(offsetTween),
child: child,
);
},
),
);
答案解读
PageRouteBuilder让你完全控制转场动画。pageBuilder返回目标页面,transitionsBuilder定义过渡效果。Offset(1, 0)表示从右侧开始,Offset.zero表示最终位置。CurveTween给动画加缓动曲线。
常见过渡效果:
| 效果 | 起始 Offset | 方向 |
|---|---|---|
| 底部滑入 | Offset(0, 1) |
从下到上 |
| 右侧滑入 | Offset(1, 0) |
从右到左 |
| 左侧滑入 | Offset(-1, 0) |
从左到右 |
| 顶部滑入 | Offset(0, -1) |
从上到下 |
淡入 + 滑动组合:
dart
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: animation.drive(
Tween(begin: const Offset(0, 0.1), end: Offset.zero)
.chain(CurveTween(curve: Curves.easeOut)),
),
child: child,
),
);
},
五、综合小项目:动画卡片列表
目标:用本课知识做一个动画卡片列表,包含:
- 列表项进入时淡入 + 从下往上滑入;
- 点击卡片,卡片缩放并跳转到详情页;
- 详情页使用 Hero 过渡;
- 详情页有一个点赞按钮,点击时心跳动画;
- 返回列表时,列表项淡出。
参考答案框架:
dart
class AnimatedListPage extends StatefulWidget {
const AnimatedListPage({super.key});
@override
State<AnimatedListPage> createState() => _AnimatedListPageState();
}
class _AnimatedListPageState extends State<AnimatedListPage>
with SingleTickerProviderStateMixin {
late AnimationController _entryController;
@override
void initState() {
super.initState();
_entryController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
)..forward();
}
@override
void dispose() {
_entryController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('动画卡片列表')),
body: ListView.builder(
itemCount: 10,
itemBuilder: (context, index) {
final delay = index * 0.05;
final animation = CurvedAnimation(
parent: _entryController,
curve: Interval(
delay.clamp(0.0, 0.5),
(delay + 0.5).clamp(0.0, 1.0),
curve: Curves.easeOut,
),
);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: animation.drive(
Tween(begin: const Offset(0, 0.2), end: Offset.zero),
),
child: GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => DetailPage(index: index),
),
),
child: Hero(
tag: 'card_$index',
child: Card(
margin: const EdgeInsets.all(8),
child: ListTile(
title: Text('卡片 $index'),
subtitle: const Text('点击查看详情'),
),
),
),
),
),
);
},
),
);
}
}
答案解读
这个项目综合了本课所有知识点:隐式动画、显式动画、Hero、Tween、CurvedAnimation、Interval。建议先自己动手写,再对照参考框架。
关键技巧:
Interval让不同列表项错开动画时间,形成瀑布式入场效果。delay.clamp(0.0, 0.5)确保 Interval 的起始值不超过 0.5。- Hero 和 GestureDetector 配合,点击卡片时既有缩放又有页面过渡。
六、本课小结
你练了什么
- 隐式动画:
AnimatedContainer、AnimatedOpacity、AnimatedCrossFade、AnimatedSwitcher、AnimatedSize; - 显式动画:
AnimationController、Tween、CurvedAnimation、AnimatedBuilder、ScaleTransition、FadeTransition、SlideTransition; - 简化动画:
TweenAnimationBuilder; - 过渡动画:
Hero页面共享元素、PageRouteBuilder自定义转场; - 点赞心跳、展开卡片、Hero 跳转、进度动画、组合动画、瀑布式入场;
- 通过故意不释放
AnimationController,学会看 Ticker 相关错误和内存泄漏警告。
核心心法
状态变了想动画,用隐式;想控制播放节奏,用显式;页面跳转共享元素,用 Hero。
隐式 vs 显式选择指南
| 场景 | 推荐 |
|---|---|
| 点击切换颜色、大小、透明度 | 隐式 |
| 加载进度、循环动画 | 显式 |
| 数字从 A 到 B | TweenAnimationBuilder |
| 页面间共享元素 | Hero |
| 自定义页面转场 | PageRouteBuilder |
| 多个属性同时动画 | 显式 + 多个 Tween |
常见错误速查
| 错误信息 | 原因 | 解决 |
|---|---|---|
AnimationController.dispose() called more than once |
重复释放 | 只在 State 的 dispose 中调用一次 |
A Ticker was active when its widget was disposed |
未释放控制器 | 加 _controller.dispose() |
vsync was null |
缺少 TickerProvider | 混入 SingleTickerProviderStateMixin |
Multiple heroes share the same tag |
Hero tag 冲突 | 每个 Hero tag 唯一 |
setState() called after dispose() |
异步回调中页面已销毁 | 加 if (mounted) |
| 动画不播放 | 没调用 forward / repeat | 检查是否启动动画 |
| 动画跳变 | 用了普通 Container | 换成 AnimatedContainer |
性能优化建议
| 优化点 | 做法 |
|---|---|
| 减少重建范围 | 用 AnimatedBuilder 只重建动画部分 |
| 复用子树 | AnimatedBuilder 的 child 参数传入不变部分 |
| 避免动画中做重活 | 动画回调中不做网络、存储操作 |
| 及时释放 | dispose() 中释放控制器 |
| 合理使用隐式 | 简单动画用隐式,不必上显式 |
| 控制动画时长 | 一般 200--500ms,过短看不清,过长卡顿感 |
使用 RepaintBoundary |
复杂动画包裹,减少重绘范围 |
面试常见问题
-
隐式动画和显式动画有什么区别?
隐式动画改属性自动补间,代码少,适合简单切换;显式动画需要手动创建
AnimationController,可以控制播放、暂停、反转、循环,适合复杂动画。 -
AnimationController为什么需要vsync?vsync让动画与屏幕刷新率同步,避免不必要的帧渲染。通过SingleTickerProviderStateMixin提供。 -
Hero的 tag 有什么要求?两个页面的 Hero tag 必须一致,且同一页面不能有重复 tag,否则报
Multiple heroes share the same tag。 -
AnimatedBuilder和setState哪个更适合动画?AnimatedBuilder只重建builder内的子树,性能更好。setState会重建整个 Widget,动画场景下不推荐。 -
如何在动画结束后执行操作?
用
_controller.addStatusListener监听AnimationStatus.completed,或用await _controller.forward()等动画完成。 -
Tween和CurvedAnimation有什么区别?Tween定义起止值(从 A 到 B),CurvedAnimation定义缓动曲线(怎么从 A 到 B)。两者结合使用:Tween(begin, end).animate(CurvedAnimation(parent, curve))。
下一课预告
自定义组件与主题------ThemeData、InheritedWidget、CustomPainter、自定义 Widget、组合优于继承,继续用"练中学"的方式做可复用组件与统一主题。