Skip to content

05 · Flutter 交互与动画

【通用进阶】本章有一个前端转 Flutter 最大的思维差异点:手势竞技场。 DOM 有事件冒泡,Flutter 没有——如果你带着前端思维写手势,一定会踩坑。


开篇:前端概念对照

前端概念Flutter 对应差异大小
addEventListener('click')GestureDetector(onTap:)
事件冒泡没有冒泡⚠️ 根本差异
stopPropagation()不存在⚠️ 根本差异
pointer-events: noneHitTestBehavior.translucent 的子节点
CSS transition隐式动画组件(AnimatedContainer 等)
CSS @keyframesAnimationController + Tween
requestAnimationFrameTicker
Web Animations APIAnimationController
transform: scale()Transform.scale / AnimatedScale
共享元素转场Hero小(能力更强)

一、手势竞技场(Gesture Arena):最关键的思维转换

DOM 的事件冒泡(你熟悉的)

html
<div onclick="outer()">
  <button onclick="inner()">点我</button>
</div>

<script>
// 点击 button:先触发 inner,再冒泡触发 outer
// 想阻止:e.stopPropagation()
</script>

规则:事件从最内层元素开始,向外层冒泡。多个监听器都会收到事件(除非被阻止)。

Flutter 的手势竞技场(你要适应的)

Flutter 没有冒泡。取而代之的是:

手指按下

命中测试(hit test):从最内层向外,收集所有「对这次手势感兴趣」的 GestureDetector

全部进入「竞技场」(Gesture Arena)

竞争:只能有一个胜者

胜者接收后续事件,败者被「淘汰」(收到 onCancel)

核心规则

  1. 只有一个手势胜出(不是所有监听器都能收到)
  2. 最内层的通常胜出(这是最常见的「获胜」规则)
  3. 也可以「协商」:比如水平和垂直滚动竞争时,谁先移动超过阈值谁赢

前端类比(近似)

可以想象成:DOM 里所有监听器同时 preventDefault(),然后浏览器只挑一个执行。

实际影响:为什么嵌套点击「不冒泡」

dart
GestureDetector(
  onTap: () => print('外层'),
  child: GestureDetector(
    onTap: () => print('内层'),
    child: Container(width: 200, height: 200, color: Colors.blue),
  ),
)

点击结果:只打印 内层不会打印「外层」。

如果你想两个都触发,必须显式都调

dart
GestureDetector(
  onTap: () => print('外层'),
  child: GestureDetector(
    onTap: () {
      print('内层');
      // 手动调用外层的逻辑(没有冒泡帮你)
    },
    child: ...,
  ),
)

竞技场的「协商」机制

有意思的是,某些手势会延迟决出胜负

场景:ListView(垂直滚动)里嵌套一个横向滑动的 Widget

手指按下 → 两者都进竞技场
手指移动 → 观察移动方向
   ├─ 主要是水平移动 → 横向 Widget 胜出,ListView 被取消
   └─ 主要是垂直移动 → ListView 胜出

这就是为什么在 Flutter 里嵌套滚动通常能正常工作——竞技场自动仲裁。

⚠️ 但有时仲裁失败:比如垂直方向的 PageView 嵌在 ListView 里,两者方向相同,会「打架」。此时需要手动指定:

dart
PageView(
  physics: const ClampingScrollPhysics(),
  ...
)

或用 GestureDetectoronVerticalDragUpdate 手动判断。

Listener:绕过竞技场的原始指针事件

如果你确实需要类似 DOM 的「所有监听器都收到事件」,用 Listener

dart
Listener(
  onPointerDown: (event) => print('指针按下 ${event.position}'),
  onPointerMove: (event) => print('指针移动'),
  onPointerUp: (event) => print('指针抬起'),
  child: ...,
)
GestureDetectorListener
层级高层(识别点击、拖拽、缩放等语义手势)底层(原始指针事件)
竞技场参与(会竞争)不参与(所有 Listener 都收到)
用途常规交互需要原始坐标 / 不想被竞技场干扰

前端类比Listenerpointerdown/pointermove 原生事件;GestureDetectorclick/swipe 这类语义事件。

练习 5.1:验证「没有冒泡」

dart
// 粘到 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:命中测试行为

三种行为

GestureDetectorbehavior 参数控制它如何参与命中测试

行为前端类比
deferToChild(默认)只有点到子节点才算命中;点到自己的空白区域不算默认
opaque整个区域都算命中,且阻止下层接收不透明背景
translucent整个区域都算命中,但下层也能收到pointer-events 穿透

典型场景:点空白区域没反应

dart
// ❌ 问题:Container 有大小,但 GestureDetector 只有点到 Text 才响应
GestureDetector(
  onTap: () => print('点了'),
  child: Container(
    width: 200,
    height: 100,
    color: Colors.grey,        // 有背景色,但仍然是 deferToChild
    child: const Text('文字'),
  ),
)

等等——这个例子其实能工作,因为 Containercolor 时会创建 ColoredBox,它是有实体的子节点。

真正出问题的场景是透明/无子节点

dart
// ❌ 点到空白区域没反应
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 里有一层透明遮罩,但希望点击能穿透到下层:

dart
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 自动做过渡。改变目标值时,动画自动播放。

dart
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——隐式动画没有内存泄漏风险

前端对照

css
.box {
  transition: all 0.3s ease-in-out;
}

常用隐式动画组件

组件动画属性前端对应
AnimatedContainer尺寸、颜色、边距、装饰transition: all
AnimatedOpacity透明度transition: opacity
AnimatedPadding内边距transition: padding
AnimatedPositionedStack 内位置transition: top/left
AnimatedAlign对齐方式
AnimatedScale缩放transition: transform
AnimatedRotation旋转transition: transform
AnimatedSwitcher子节点切换(淡入淡出)Vue <transition>
AnimatedDefaultTextStyle文字样式
AnimatedPhysicalModel阴影/elevation
TweenAnimationBuilder任意属性(最通用)

AnimatedSwitcher:子节点切换过渡

dart
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:万能隐式动画

当内置组件不够用时:

dart
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:隐式动画

dart
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渲染回调

基本结构

dart
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

dart
class _MyState extends State<MyWidget> with SingleTickerProviderStateMixin {

作用:让动画只在页面可见时才跑(防止后台耗电)。

多个 controller 时用 TickerProviderStateMixin(不带 Single)。

② 必须 dispose

dart
@override
void dispose() {
  _controller.dispose();
  super.dispose();
}

忘记 dispose 是最常见的内存泄漏。报错信息通常是:

A AnimationController was still active when the widget was disposed

前端类比:相当于 requestAnimationFrame 循环没取消,组件卸载后还在跑。

AnimationController 的常用方法

dart
_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 的多种类型

dart
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

dart
Curves.linear          // 线性
Curves.easeIn          // 慢开始
Curves.easeOut         // 慢结束
Curves.easeInOut       // 慢开始慢结束
Curves.fastOutSlowIn   // Material 标准
Curves.bounceOut       // 弹跳
Curves.elasticOut      // 弹性
Curves.decelerate      // 减速

简化写法:链式调用

dart
_animation = Tween<double>(begin: 0, end: 300)
    .chain(CurveTween(curve: Curves.easeInOut))
    .animate(_controller);

💡 性能优化:AnimatedBuilderchild 参数

dart
AnimatedBuilder(
  animation: _animation,
  builder: (context, child) => Transform.rotate(
    angle: _animation.value,
    child: child,        // ← 这部分不会重建
  ),
  child: const ExpensiveWidget(),    // 预先构建,每帧复用
)

原理child 只构建一次,每帧复用。变化的部分在 builder 里。

前端类比:类似 React 里把不变的子树抽成 useMemo

练习 5.3:显式动画 + 循环

dart
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('转')),
        ),
      ),
    );
  }
}

观察:方块持续旋转并变色。

然后试

  1. 注释掉 dispose 里的 _controller.dispose(),退出页面看控制台告警
  2. repeat() 改成 repeat(reverse: true),观察呼吸效果
  3. Curves 加上,观察缓动差异

自检

  • [ ] 知道显式动画四件套:Controller / Tween / Curve / AnimatedBuilder
  • [ ] 知道 vsync: this 需要 SingleTickerProviderStateMixin
  • [ ] 永远记得 dispose controller
  • [ ] 会用 AnimatedBuilderchild 参数做性能优化

六、Hero:共享元素转场

作用

两个页面之间,同一个元素「飞」过去的转场效果。

前端类比:类似一些路由库的 shared element transition。Flutter 内置且非常简单。

用法

在两个页面给对应 Widget 包 Hero,用同一个 tag

dart
// 列表页
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

dart
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 泄漏专题

这是新手最容易埋下的线上问题,单独强调。

会泄漏的资源清单

资源释放方式忘记的后果
AnimationControllerdispose()后台持续 tick,耗电;控制台告警
TextEditingControllerdispose()监听器泄漏
ScrollControllerdispose()同上
FocusNodedispose()同上
StreamSubscriptioncancel()回调继续触发,可能 setState after dispose
Timercancel()持续执行
PageController / TabControllerdispose()同上

标准 dispose 模板

dart
@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 并在搜索变更时取消:

dart
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
需要 mixinSingleTickerProviderStateMixin
循环_controller.repeat()
来回循环repeat(reverse: true)
性能优化AnimatedBuilderchild 参数
跨页面共享元素Hero(同 tag)

下一步

06 · Dart 异步并发与进阶语法

06 属于「02-Dart基础」类别,但阅读顺序排在 03~05 之后—— 理解了 Flutter 的单线程渲染模型后,再学事件循环与 Isolate 才有落点。