178 lines
6.4 KiB
Markdown
178 lines
6.4 KiB
Markdown
# 【Demo】动画体系(隐式 / 显式 / Hero / CustomPaint)
|
||
|
||
> 源文件:`lib/pages/demo/animation_demo.dart` | 分类:基础布局
|
||
> 一句话:对 CSS 学 Flutter 动画 —— 隐式=transition,显式=keyframes,加上 Hero 转场和 Canvas 自绘。
|
||
|
||
---
|
||
|
||
## 一、这个 Demo 解决什么问题
|
||
|
||
新手面对 Flutter 动画容易懵:组件太多、概念太多。本页把动画体系按"对 CSS"的方式梳理成 4 块:
|
||
|
||
| 动画类型 | 对标 CSS | 一句话 |
|
||
|---|---|---|
|
||
| ① 隐式动画 | transition | 属性变了自己补过渡帧,零 controller |
|
||
| ② 显式动画 | keyframes | controller 手动驱动,精确控制时序/曲线 |
|
||
| ③ Hero 转场 | 无(原生独有) | 页面跳转时元素"飞过去" |
|
||
| ④ CustomPaint | canvas | 用 Canvas 画任意图形 |
|
||
|
||
---
|
||
|
||
## 二、怎么运行 / 演示内容
|
||
|
||
```dart
|
||
runApp(const MaterialApp(home: AnimationDemoPage()));
|
||
```
|
||
|
||
1. 点方块:宽/圆角/颜色三属性同时变(AnimatedContainer);
|
||
2. 答题互动:选对"弹跳放大",选错"水平抖动"(TweenSequence 分段动画);
|
||
3. 点封面:共享元素飞到详情页(Hero);
|
||
4. 拖滑块:进度环实时重绘(CustomPainter drawArc)。
|
||
|
||
---
|
||
|
||
## 三、核心知识点(新手向)
|
||
|
||
### 1. 隐式 vs 显式(判断口诀)
|
||
- 只需要"A 变成 B 的过渡" → **隐式**(写一行 `duration` 完事);
|
||
- 需要"按我的节奏动起来"(回弹、抖动、连续) → **显式 controller**。
|
||
|
||
```dart
|
||
// 隐式:属性变了自动补过渡(duration 必写!漏了就瞬变)
|
||
AnimatedContainer(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOut,
|
||
width: _boxExpanded ? 200.w : 120.w,
|
||
decoration: BoxDecoration(
|
||
color: _boxExpanded ? Color(0xFF3B82F6) : Color(0xFFFF9800),
|
||
borderRadius: BorderRadius.circular(_boxExpanded ? 45.r : 8.r),
|
||
),
|
||
)
|
||
```
|
||
|
||
### 2. 显式动画五件套
|
||
```dart
|
||
// ① 页面必须 with SingleTickerProviderStateMixin
|
||
class _State extends State<Page> with SingleTickerProviderStateMixin {
|
||
// ② controller:驱动一切(vsync 必传,duration 定义时长)
|
||
late final AnimationController _answerCtrl = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 500),
|
||
);
|
||
|
||
// ③ TweenSequence:分段动画(先放大再回弹 = 两个 Tween 按权重拼)
|
||
late final Animation<double> _correctScale = TweenSequence<double>([
|
||
TweenSequenceItem(
|
||
tween: Tween(begin: 1.0, end: 1.25).chain(CurveTween(curve: Curves.easeOut)),
|
||
weight: 40,
|
||
),
|
||
TweenSequenceItem(
|
||
tween: Tween(begin: 1.25, end: 1.0).chain(CurveTween(curve: Curves.bounceOut)),
|
||
weight: 60,
|
||
),
|
||
]).animate(_answerCtrl);
|
||
|
||
// ④ 触发:每次从 0 重新播放
|
||
_answerCtrl.forward(from: 0);
|
||
|
||
// ⑤ 红线:必须 dispose
|
||
@override
|
||
void dispose() {
|
||
_answerCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3. AnimatedBuilder 的 child 缓存
|
||
```dart
|
||
AnimatedBuilder(
|
||
animation: _correctScale,
|
||
child: body, // 不变的子树用 child 传进来缓存,避免 builder 反复重建
|
||
builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child),
|
||
)
|
||
```
|
||
|
||
### 4. Hero 共享元素
|
||
```dart
|
||
// 页面 A
|
||
Hero(tag: 'hero-demo-cover', child: Container(width: 120.w, height: 72.h, ...));
|
||
// 页面 B(同一个 tag!)
|
||
Hero(tag: 'hero-demo-cover', child: Container(width: 280.w, height: 168.h, ...));
|
||
```
|
||
两个页面里 tag 相同的 Hero,转场时自动共享动画(封面从小图"飞"成大图)。
|
||
|
||
### 5. CustomPainter 自绘进度环
|
||
```dart
|
||
class _ProgressRingPainter extends CustomPainter {
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final rect = (Offset.zero & size).deflate(stroke / 2); // 内缩一个线宽
|
||
// 背景环(画满 360°)
|
||
canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159, false, bgPaint);
|
||
// 前景环:起点 -90° = 12 点方向,顺时针画 progress 比例
|
||
canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159 * progress, false, fgPaint);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_ProgressRingPainter oldDelegate) {
|
||
// 参数变了才重绘(滑块拖动时)
|
||
return oldDelegate.progress != progress || oldDelegate.color != color;
|
||
}
|
||
}
|
||
```
|
||
要点:`drawArc` 的角度单位是弧度,`-π/2` 才是 12 点方向(画布 0° 在 3 点方向)。
|
||
|
||
---
|
||
|
||
## 四、关键代码剖析
|
||
|
||
### 抖动动画(4 段 Tween 拼出"抖三下")
|
||
```dart
|
||
late final Animation<double> _wrongShake = TweenSequence<double>([
|
||
TweenSequenceItem(tween: Tween(begin: 0.0, end: -8.0).chain(CurveTween(curve: Curves.easeOut)), weight: 20),
|
||
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 30),
|
||
TweenSequenceItem(tween: Tween(begin: 8.0, end: -6.0), weight: 30),
|
||
TweenSequenceItem(tween: Tween(begin: -6.0, end: 0.0), weight: 20),
|
||
]).animate(_answerCtrl);
|
||
```
|
||
|
||
### 对错反馈的应用(按选择状态包 Transform)
|
||
```dart
|
||
if (isRight) {
|
||
body = AnimatedBuilder(
|
||
animation: _correctScale,
|
||
child: body,
|
||
builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child),
|
||
);
|
||
}
|
||
if (isWrong) {
|
||
body = AnimatedBuilder(
|
||
animation: _wrongShake,
|
||
child: body,
|
||
builder: (context, child) =>
|
||
Transform.translate(offset: Offset(_wrongShake.value, 0), child: child),
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、踩坑记录
|
||
|
||
1. **AnimationController 必须 dispose**,且页面必须 `with SingleTickerProviderStateMixin`(否则报 Ticker 错误);
|
||
2. **隐式动画一定要写 `duration`**,漏了就瞬变没有过渡;
|
||
3. **AnimatedBuilder 的 builder 里别放不变子树**,用 `child` 参数缓存(否则每帧重建,性能浪费);
|
||
4. **shouldRepaint 要正确比较参数**:不重绘或一直重绘都是错;
|
||
5. 多个 controller 用 `TickerProviderStateMixin`(多 Ticker),单个用 Single 版;
|
||
6. 同屏多个 FloatingActionButton 要 `heroTag: null`(见 list_demo),Hero tag 全局必须唯一,重了会断言崩溃。
|
||
|
||
---
|
||
|
||
## 六、扩展思考
|
||
|
||
- 本项目 exam 答题页的"选对弹跳/选错抖动"就是本页思路的真实落地;
|
||
- 显式动画进阶:`AnimationController.repeat()` 无限循环(loading 旋转)、`addStatusListener` 监听完成回调;
|
||
- 隐式动画家族:AnimatedOpacity / AnimatedSwitcher(切换动画)/ AnimatedPositioned / AnimatedAlign —— 都是"属性变了自动补帧";
|
||
- 性能纪律:动画组件尽量用 Transform(不触发 layout),别动 width/height 频繁重建。
|