05 · Flutter 交互与动画
【通用进阶】本章有一个前端转 Flutter 最大的思维差异点:手势竞技场。 DOM 有事件冒泡,Flutter 没有——如果你带着前端思维写手势,一定会踩坑。
开篇:前端概念对照
| 前端概念 | Flutter 对应 | 差异大小 |
|---|---|---|
addEventListener('click') | GestureDetector(onTap:) | 小 |
| 事件冒泡 | 没有冒泡 | ⚠️ 根本差异 |
stopPropagation() | 不存在 | ⚠️ 根本差异 |
pointer-events: none | HitTestBehavior.translucent 的子节点 | 中 |
CSS transition | 隐式动画组件(AnimatedContainer 等) | 小 |
CSS @keyframes | AnimationController + Tween | 中 |
requestAnimationFrame | Ticker | 中 |
| Web Animations API | AnimationController | 中 |
transform: scale() | Transform.scale / AnimatedScale | 小 |
| 共享元素转场 | Hero | 小(能力更强) |
一、手势竞技场(Gesture Arena):最关键的思维转换
DOM 的事件冒泡(你熟悉的)
<div onclick="outer()">
<button onclick="inner()">点我</button>
</div>
<script>
// 点击 button:先触发 inner,再冒泡触发 outer
// 想阻止:e.stopPropagation()
</script>规则:事件从最内层元素开始,向外层冒泡。多个监听器都会收到事件(除非被阻止)。
Flutter 的手势竞技场(你要适应的)
Flutter 没有冒泡。取而代之的是:
手指按下
↓
命中测试(hit test):从最内层向外,收集所有「对这次手势感兴趣」的 GestureDetector
↓
全部进入「竞技场」(Gesture Arena)
↓
竞争:只能有一个胜者
↓
胜者接收后续事件,败者被「淘汰」(收到 onCancel)核心规则:
- 只有一个手势胜出(不是所有监听器都能收到)
- 最内层的通常胜出(这是最常见的「获胜」规则)
- 也可以「协商」:比如水平和垂直滚动竞争时,谁先移动超过阈值谁赢
前端类比(近似)
可以想象成:DOM 里所有监听器同时 preventDefault(),然后浏览器只挑一个执行。
实际影响:为什么嵌套点击「不冒泡」
GestureDetector(
onTap: () => print('外层'),
child: GestureDetector(
onTap: () => print('内层'),
child: Container(width: 200, height: 200, color: Colors.blue),
),
)点击结果:只打印 内层。不会打印「外层」。
如果你想两个都触发,必须显式都调:
GestureDetector(
onTap: () => print('外层'),
child: GestureDetector(
onTap: () {
print('内层');
// 手动调用外层的逻辑(没有冒泡帮你)
},
child: ...,
),
)竞技场的「协商」机制
有意思的是,某些手势会延迟决出胜负:
场景:ListView(垂直滚动)里嵌套一个横向滑动的 Widget
手指按下 → 两者都进竞技场
手指移动 → 观察移动方向
├─ 主要是水平移动 → 横向 Widget 胜出,ListView 被取消
└─ 主要是垂直移动 → ListView 胜出这就是为什么在 Flutter 里嵌套滚动通常能正常工作——竞技场自动仲裁。
⚠️ 但有时仲裁失败:比如垂直方向的 PageView 嵌在 ListView 里,两者方向相同,会「打架」。此时需要手动指定:
PageView(
physics: const ClampingScrollPhysics(),
...
)或用 GestureDetector 的 onVerticalDragUpdate 手动判断。
Listener:绕过竞技场的原始指针事件
如果你确实需要类似 DOM 的「所有监听器都收到事件」,用 Listener:
Listener(
onPointerDown: (event) => print('指针按下 ${event.position}'),
onPointerMove: (event) => print('指针移动'),
onPointerUp: (event) => print('指针抬起'),
child: ...,
)GestureDetector | Listener | |
|---|---|---|
| 层级 | 高层(识别点击、拖拽、缩放等语义手势) | 底层(原始指针事件) |
| 竞技场 | 参与(会竞争) | 不参与(所有 Listener 都收到) |
| 用途 | 常规交互 | 需要原始坐标 / 不想被竞技场干扰 |
前端类比:Listener ≈ pointerdown/pointermove 原生事件;GestureDetector ≈ click/swipe 这类语义事件。
练习 5.1:验证「没有冒泡」
// 粘到 lib/main_playground.dart
class ArenaDemo extends StatelessWidget {
const ArenaDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('手势竞技场')),
body: Center(
child: GestureDetector(
onTap: () => debugPrint('🔵 外层 onTap'),
child: Container(
width: 300,
height: 300,
color: Colors.blue.shade200,
alignment: Alignment.center,
child: GestureDetector(
onTap: () => debugPrint('🔴 内层 onTap'),
child: Container(
width: 150,
height: 150,
color: Colors.red.shade200,
alignment: Alignment.center,
child: const Text('点红色区域'),
),
),
),
),
),
);
}
}观察:
- 点红色:只打印
🔴 内层 onTap(外层不会触发) - 点蓝色:打印
🔵 外层 onTap
再试一次:把外层换成 Listener,观察两者都收到。
自检
- [ ] 知道 Flutter 没有事件冒泡,嵌套点击只有最内层触发
- [ ] 理解「竞技场」:多个手势竞争,只有一个胜出
- [ ] 知道需要原始事件时用
Listener - [ ] 知道嵌套滚动靠竞技场自动仲裁,同向滚动会打架
二、HitTestBehavior:命中测试行为
三种行为
GestureDetector 的 behavior 参数控制它如何参与命中测试:
| 值 | 行为 | 前端类比 |
|---|---|---|
deferToChild(默认) | 只有点到子节点才算命中;点到自己的空白区域不算 | 默认 |
opaque | 整个区域都算命中,且阻止下层接收 | 不透明背景 |
translucent | 整个区域都算命中,但下层也能收到 | pointer-events 穿透 |
典型场景:点空白区域没反应
// ❌ 问题:Container 有大小,但 GestureDetector 只有点到 Text 才响应
GestureDetector(
onTap: () => print('点了'),
child: Container(
width: 200,
height: 100,
color: Colors.grey, // 有背景色,但仍然是 deferToChild
child: const Text('文字'),
),
)等等——这个例子其实能工作,因为 Container 有 color 时会创建 ColoredBox,它是有实体的子节点。
真正出问题的场景是透明/无子节点:
// ❌ 点到空白区域没反应
GestureDetector(
onTap: () => print('点了'),
child: Container(width: 200, height: 100), // 没有 color,没有子节点
)
// ✅ 加 behavior
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => print('点了'),
child: Container(width: 200, height: 100),
)translucent 的典型用途
Stack 里有一层透明遮罩,但希望点击能穿透到下层:
Stack(
children: [
ContentWidget(), // 下层内容
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => print('遮罩收到了,下层也能收到'),
child: const SizedBox.expand(),
),
],
)前端类比:translucent ≈ 遮罩层设置后,下层元素仍能响应(类似某些 UI 库的「点击穿透」)。
自检
- [ ] 知道
deferToChild是默认值,空白区域不响应 - [ ] 空白区域要响应时加
behavior: HitTestBehavior.opaque - [ ] 需要穿透时用
translucent
三、动画:两大类
Flutter 动画分两类,选型看复杂度:
| 类型 | 适用场景 | 复杂度 | 前端类比 |
|---|---|---|---|
隐式动画(AnimatedXxx) | 简单的属性过渡(大小、颜色、位置、透明度) | 低 | CSS transition |
显式动画(AnimationController) | 复杂/可控制/循环/组合动画 | 高 | Web Animations API |
四、隐式动画(简单场景首选)
核心原理
给属性设一个目标值和时长,Flutter 自动做过渡。改变目标值时,动画自动播放。
class _MyState extends State<MyWidget> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _expanded ? 300 : 150, // 目标值变了 → 自动过渡
height: _expanded ? 300 : 150,
color: _expanded ? Colors.blue : Colors.red,
child: const Center(child: Text('点我')),
),
);
}
}就这么多。不需要 controller,不需要 dispose——隐式动画没有内存泄漏风险。
前端对照
.box {
transition: all 0.3s ease-in-out;
}常用隐式动画组件
| 组件 | 动画属性 | 前端对应 |
|---|---|---|
AnimatedContainer | 尺寸、颜色、边距、装饰 | transition: all |
AnimatedOpacity | 透明度 | transition: opacity |
AnimatedPadding | 内边距 | transition: padding |
AnimatedPositioned | Stack 内位置 | transition: top/left |
AnimatedAlign | 对齐方式 | |
AnimatedScale | 缩放 | transition: transform |
AnimatedRotation | 旋转 | transition: transform |
AnimatedSwitcher | 子节点切换(淡入淡出) | Vue <transition> |
AnimatedDefaultTextStyle | 文字样式 | |
AnimatedPhysicalModel | 阴影/elevation | |
TweenAnimationBuilder | 任意属性(最通用) |
AnimatedSwitcher:子节点切换过渡
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: Text(
_count.toString(),
key: ValueKey(_count), // ⚠️ 必须加 key,否则识别不出「换了」
),
)⚠️ 必须给 child 加 key——否则 Flutter 认为「还是同一个 Widget」,不播放动画。这呼应了 03 章的 Key 知识。
TweenAnimationBuilder:万能隐式动画
当内置组件不够用时:
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _targetValue),
duration: const Duration(milliseconds: 500),
builder: (context, value, child) => Transform.rotate(
angle: value * 3.14,
child: child,
),
child: const Icon(Icons.refresh), // child 不参与重建(性能优化)
)练习 5.2:隐式动画
class ImplicitAnimDemo extends StatefulWidget {
const ImplicitAnimDemo({super.key});
@override
State<ImplicitAnimDemo> createState() => _ImplicitAnimDemoState();
}
class _ImplicitAnimDemoState extends State<ImplicitAnimDemo> {
bool _big = false;
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('隐式动画')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOutCubic,
width: _big ? 250 : 100,
height: _big ? 250 : 100,
decoration: BoxDecoration(
color: _big ? Colors.purple : Colors.orange,
borderRadius: BorderRadius.circular(_big ? 40 : 8),
),
),
const SizedBox(height: 20),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text('$_count',
key: ValueKey(_count), style: const TextStyle(fontSize: 40)),
),
ElevatedButton(
onPressed: () => setState(() {
_big = !_big;
_count++;
}),
child: const Text('切换'),
),
],
),
),
);
}
}观察:容器尺寸/颜色/圆角平滑过渡;数字切换时淡入淡出。
然后试试:去掉 ValueKey(_count),观察 AnimatedSwitcher 失效。
自检
- [ ] 简单过渡优先用隐式动画(不用 controller、无泄漏风险)
- [ ] 知道
AnimatedSwitcher必须给 child 加 key - [ ] 能列出常用的
AnimatedXxx组件
五、显式动画:完整控制
四个核心角色
| 角色 | 职责 | 前端类比 |
|---|---|---|
AnimationController | 驱动:产生 0→1 的进度值,控制播放/暂停/反向/重复 | 动画播放器 |
Tween | 映射:把 0→1 映射到实际值(如 0→300 像素) | 关键帧定义 |
Curve | 缓动:把线性进度变成加减速 | ease-in-out |
AnimatedBuilder | 重建:动画值变化时重建 Widget | 渲染回调 |
基本结构
class _MyState extends State<MyWidget> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this, // ⚠️ 必须,需要 TickerProvider
);
_animation = Tween<double>(begin: 0, end: 300).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose(); // ⚠️ 必须释放,否则内存泄漏
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) => Container(
width: _animation.value, // 用动画值
height: 100,
color: Colors.blue,
child: child, // 不随动画重建的部分
),
child: const Center(child: Text('内容')),
);
}
void _play() {
_controller.forward(); // 播放
// _controller.reverse(); // 倒放
// _controller.repeat(); // 循环
}
}⚠️ 两个必须注意的点
① vsync: this 需要 mixin
class _MyState extends State<MyWidget> with SingleTickerProviderStateMixin {作用:让动画只在页面可见时才跑(防止后台耗电)。
多个 controller 时用 TickerProviderStateMixin(不带 Single)。
② 必须 dispose
@override
void dispose() {
_controller.dispose();
super.dispose();
}忘记 dispose 是最常见的内存泄漏。报错信息通常是:
A AnimationController was still active when the widget was disposed前端类比:相当于 requestAnimationFrame 循环没取消,组件卸载后还在跑。
AnimationController 的常用方法
_controller.forward(); // 正向播放到 1
_controller.reverse(); // 反向播放到 0
_controller.repeat(); // 循环播放
_controller.repeat(reverse: true); // 来回循环(呼吸效果)
_controller.stop(); // 停止
_controller.reset(); // 重置到 0
_controller.animateTo(0.5); // 动画到指定值
_controller.value = 0.5; // 直接跳到(无过渡)Tween 的多种类型
Tween<double>(begin: 0, end: 300) // 数值
ColorTween(begin: Colors.red, end: Colors.blue) // 颜色
SizeTween(begin: Size.zero, end: const Size(100, 100))// 尺寸
RectTween(begin: ..., end: ...) // 矩形
AlignmentTween(begin: ..., end: ...) // 对齐常用 Curve
Curves.linear // 线性
Curves.easeIn // 慢开始
Curves.easeOut // 慢结束
Curves.easeInOut // 慢开始慢结束
Curves.fastOutSlowIn // Material 标准
Curves.bounceOut // 弹跳
Curves.elasticOut // 弹性
Curves.decelerate // 减速简化写法:链式调用
_animation = Tween<double>(begin: 0, end: 300)
.chain(CurveTween(curve: Curves.easeInOut))
.animate(_controller);💡 性能优化:AnimatedBuilder 的 child 参数
AnimatedBuilder(
animation: _animation,
builder: (context, child) => Transform.rotate(
angle: _animation.value,
child: child, // ← 这部分不会重建
),
child: const ExpensiveWidget(), // 预先构建,每帧复用
)原理:child 只构建一次,每帧复用。变化的部分在 builder 里。
前端类比:类似 React 里把不变的子树抽成 useMemo。
练习 5.3:显式动画 + 循环
class ExplicitAnimDemo extends StatefulWidget {
const ExplicitAnimDemo({super.key});
@override
State<ExplicitAnimDemo> createState() => _ExplicitAnimDemoState();
}
class _ExplicitAnimDemoState extends State<ExplicitAnimDemo>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _rotation;
late final Animation<Color?> _color;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
_rotation = Tween<double>(begin: 0, end: 2 * 3.14159).animate(_controller);
_color = ColorTween(begin: Colors.blue, end: Colors.red).animate(_controller);
_controller.repeat(); // 循环播放
}
@override
void dispose() {
_controller.dispose(); // 试试注释掉这行,看控制台报什么
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('显式动画')),
body: Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => Transform.rotate(
angle: _rotation.value,
child: Container(
width: 150,
height: 150,
color: _color.value,
child: child,
),
),
child: const Center(child: Text('转')),
),
),
);
}
}观察:方块持续旋转并变色。
然后试:
- 注释掉
dispose里的_controller.dispose(),退出页面看控制台告警 - 把
repeat()改成repeat(reverse: true),观察呼吸效果 - 把
Curves加上,观察缓动差异
自检
- [ ] 知道显式动画四件套:Controller / Tween / Curve / AnimatedBuilder
- [ ] 知道
vsync: this需要SingleTickerProviderStateMixin - [ ] 永远记得
disposecontroller - [ ] 会用
AnimatedBuilder的child参数做性能优化
六、Hero:共享元素转场
作用
两个页面之间,同一个元素「飞」过去的转场效果。
前端类比:类似一些路由库的 shared element transition。Flutter 内置且非常简单。
用法
在两个页面给对应 Widget 包 Hero,用同一个 tag:
// 列表页
Hero(
tag: 'device-${device.id}',
child: Image.network(device.imageUrl, width: 80, height: 80),
)
// 详情页
Hero(
tag: 'device-${device.id}', // ⚠️ 必须同一个 tag
child: Image.network(device.imageUrl, width: 300, height: 300),
)路由跳转时 Flutter 自动做补间动画。就这么简单。
⚠️ 三个坑
① tag 必须唯一且匹配
同一页面内有重复 tag 会报错。通常用业务 id 拼接。
② 两个 Hero 的 child 最好是同类 Widget
图片→图片、容器→容器。跨类型可能效果怪异。
③ 网络图片要注意加载时机
详情页图片未加载完时 Hero 动画会「空飞」。可用 placeholderBuilder:
Hero(
tag: 'device-1',
placeholderBuilder: (context, heroSize, child) =>
Container(color: Colors.grey, width: heroSize.width, height: heroSize.height),
child: Image.network(url),
)自检
- [ ] 知道 Hero 用同 tag 实现跨页面共享元素转场
- [ ] 知道 tag 必须唯一
七、动画 dispose 泄漏专题
这是新手最容易埋下的线上问题,单独强调。
会泄漏的资源清单
| 资源 | 释放方式 | 忘记的后果 |
|---|---|---|
AnimationController | dispose() | 后台持续 tick,耗电;控制台告警 |
TextEditingController | dispose() | 监听器泄漏 |
ScrollController | dispose() | 同上 |
FocusNode | dispose() | 同上 |
StreamSubscription | cancel() | 回调继续触发,可能 setState after dispose |
Timer | cancel() | 持续执行 |
PageController / TabController | dispose() | 同上 |
标准 dispose 模板
@override
void dispose() {
_animationController.dispose();
_textController.dispose();
_scrollController.dispose();
_focusNode.dispose();
_subscription?.cancel();
_timer?.cancel();
super.dispose(); // ⚠️ 放最后
}项目实例:Riverpod 的自动清理
本项目用 Riverpod 管理状态时,ref.onDispose() 承担了同样的职责:
lib/ui/core/notifiers/paged_notifier_mixin.dart 中使用 CancelToken 并在搜索变更时取消:
Future<void> setKeyword(String kw) async {
_keyword = kw;
_cancelToken?.cancel('keyword changed'); // ✅ 取消上一个请求
_cancelToken = CancelToken();
...
}Riverpod 的 ref.onDispose() 详见 08-状态管理Riverpod。这是声明式的清理,比手动 dispose 更不容易遗漏。
自检
- [ ] 能列出需要手动释放的资源清单
- [ ] 每次创建 controller 都同步写
dispose - [ ] 知道 Riverpod 里用
ref.onDispose()做同样的事
八、本章速查
手势
| 需求 | 写法 |
|---|---|
| 点击 | GestureDetector(onTap:) |
| 长按 | onLongPress |
| 拖拽 | onPanUpdate |
| 横向滑动 | onHorizontalDragUpdate |
| 缩放 | onScaleUpdate |
| 空白区域也响应 | behavior: HitTestBehavior.opaque |
| 点击穿透到底层 | behavior: HitTestBehavior.translucent |
| 原始指针事件(不参与竞争) | Listener |
| 嵌套点击都要触发 | 手动调用(没有冒泡) |
动画
| 需求 | 方案 |
|---|---|
| 简单属性过渡 | AnimatedContainer / AnimatedOpacity 等 |
| 子节点切换过渡 | AnimatedSwitcher(要加 key) |
| 任意属性过渡 | TweenAnimationBuilder |
| 完全控制(播放/暂停/循环) | AnimationController + AnimatedBuilder |
| 需要 mixin | SingleTickerProviderStateMixin |
| 循环 | _controller.repeat() |
| 来回循环 | repeat(reverse: true) |
| 性能优化 | AnimatedBuilder 的 child 参数 |
| 跨页面共享元素 | Hero(同 tag) |
下一步
06 属于「02-Dart基础」类别,但阅读顺序排在 03~05 之后—— 理解了 Flutter 的单线程渲染模型后,再学事件循环与 Isolate 才有落点。