把「手势」理解成 GestureDetector(onTap: …) 的回调注册,会在嵌套滚动、侧滑删除、双击和长按同时存在时立刻失效。Flutter 里一次触摸不是发给「最上面那个 Widget」的函数调用,也不是 Android 那套捕获/冒泡优先级表。它是两段协议:先 HitTest 决定谁有资格看见这根指针,再 GestureArena 决定资格名单里谁能把这根指针用完。谁先注册谁处理——这个模型从来就不是 Flutter 的模型。

读完你应该能独立回答三个问题:列表竖滑和子项横滑为什么不会同时赢、Listener 为什么能绕过竞技场、HitTestBehavior.opaque 挡掉的到底是命中还是手势。

1. 先画边界:指针事件不是手势

手势是 Framework 的概念。Engine 只认识 PointerData:down / move / up / cancel、位置、pressure、device kind、pointer id。Shell 从系统拿到 MotionEvent / UITouch,压成包,投到 UI Task Runner。Raster 完全不参与;Platform Runner 只负责采集,不裁判输赢。

所以「手势卡顿」几乎一定是 UI Runner 的病:HitTest 太深、Recognizer 在 move 里做重活、在竞技场回调里同步 setState 整棵大树。它不是 GPU 的病,也不是 Channel 的病。用 FrameTiming.buildDuration 能看见它,用 rasterDuration 看不见。

dart:ui 的 PointerDataPacket 进 GestureBinding。从这一刻起,对象都是 Dart 堆上的,生命周期跟一次指针会话绑定:down 分配,up/cancel 释放。跨帧存活的是 Recognizer 自己,不是事件对象。把 PointerDownEvent 存进 State 再在下一帧读坐标,读到的是一份快照,不是「还活着的手指」。

不变量先写在这里,后面所有机制都围着它转:

  • 一根手指一个 pointer id,一场竞技场。
  • 命中测试不看手势类型,只回答几何。
  • 原始指针分发和竞技场仲裁是两条线,Listener 只走第一条。
  • 全部发生在 UI Isolate / UI Runner,没有并行 HitTest。

2. HitTest:资格名单怎么来

从 RenderView 深度优先往下。每个 RenderBox 大致做三件事:

  1. 把事件坐标从父坐标系变换到自己的 paint 坐标系(含 transform、scroll offset、Layer 的 offset)。
  2. hitTestSelf:这一点是不是落在自己的命中形状里。默认矩形是 size;异形按钮才去 Path.contains。
  3. hitTestChildren:从 最后一个 child 往前 走——后绘制的在 z 轴上面,先被问。

返回 true 表示「这条路径有效」,自己会被 HitTestResult.add(HitTestEntry(this))。路径的语义是从内到外:最先 add 的是最深处的目标。之后 GestureBinding.dispatchEvent 按这条路径从头走到尾,等于从最上面的控件走到根。

HitTestBehavior 改的是「自己没孩子命中时还要不要入路径」:

behavior孩子没命中时是否挡住后续兄弟典型用途
deferToChild不入路径不空白 Container、只想让孩子热
opaque入路径是大按钮扩大热区、模态遮罩
translucent入路径否叠层上的半透明控件、装饰性水印仍要点穿到下面

不变量:命中测试不看手势类型。 它不知道你是 tap 还是 pan。把「不想响应 tap」写成 IgnorePointer,会把 pan 也一起关了——那是在资格层下手,不是在竞技场认输。

IgnorePointer 让子树 hitTest 直接返回 false,自己也不入路径。AbsorbPointer 自己命中、不把事件传给孩子,等于一块黑洞:路径在它这里形成,孩子的 Recognizer 根本看不到 pointer。两者都比「Recognizer.reject」更早、更粗。排障时先问是不是根本没入路径,再问是不是入了但输了竞技场。

自定义 RenderBox 如果 hitTest 和 paint 用了不同的变换,就会出现「看见点不中、点中看不见」。不变量是:hit 用的矩阵必须和 paint 一致。 HitTestResult.addWithPaintTransform 存在的原因就是把这张矩阵记进 HitTestEntry,后面把全局坐标变回本地时还用它。

@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
  if (!size.contains(position)) return false;
  final hitChildren = hitTestChildren(result, position: position);
  if (hitChildren || hitTestSelf(position)) {
    result.add(BoxHitTestEntry(this, position));
    return true;
  }
  return false;
}

size.contains 是矩形热区。圆形按钮必须重写 hitTestSelf,否则四角也能点——那不是竞技场的问题。

3. 分发:路径上每个人都先看见 raw pointer

GestureBinding.dispatchEvent 沿 HitTestResult.path 走。每个 entry 的 target.handleEvent(event, entry)。RenderPointerListener(Listener)在这里直接回调 onPointerDown/Move/Up。它 不加入竞技场。

这是一个经常被忽略的分界:

  • Listener:原始指针,同步、可与任何人并行。适合画笔、游戏、自己做手势状态机。
  • GestureDetector / RawGestureDetector:把 pointer 交给一组 GestureRecognizer,由竞技场仲裁。
  • InkWell / Button:内部仍是 Recognizer,只是多了水波纹和语义。水波纹在 onTapDown 就启动,所以「滑走了还闪一下」常常是 Ink 的锅。

handleEvent 发生在 UI 线程、当前事件的调用栈上。onPointerMove 里做 10ms 的计算,这一帧的 build/layout 预算就被吃掉。Recognizer 的 handleEvent 同样如此。手势代码看起来「只是回调」,实际和 build() 抢同一条 16.6ms。

坐标:事件带的是全局位置。HitTestEntry 上的 transform 把全局变本地。Scrollable 在滚动后,同一根手指的局部坐标会变,全局坐标跟手。自己算 delta 时要用 PointerMoveEvent.delta 或全局差,不要缓存 down 时的局部坐标去减——那会把滚动本身算进拖拽。

PointerRouter 是另一条分发线:Recognizer 在 addPointer 时按 pointer id 注册,之后 即使 HitTest 路径变了(手指滑出控件),move/up 仍能送到这个 Recognizer。这就是「在按钮上按下、滑到外面再抬起」Tap 会认输、Drag 仍能跟手的原因。不要把 HitTest 路径理解成整个手势期间的订阅列表;它只决定 down 那一帧谁有资格入场。

4. 竞技场的不变量

同一 pointer(同一根手指的 id)对应一场 GestureArena。入场成员是 GestureArenaMember。框架源码里的硬规则比文档少,但每一条都是 bug 的根:

  • 一场最多一个赢家。 赢的人收到 acceptGesture(pointer),其余 rejectGesture(pointer)。没有「两个都赢 50%」。
  • 没人认领可以全灭或默认胜。 pointer up 时若竞技场还开着,sweep 会把胜利判给仍在场的第一个成员。Tap 经常靠这条活下来:它从不主动 accept,等别人认输或 sweep。
  • 可以 hold。 GestureArenaManager.hold(pointer) 阻止 up 时立刻 sweep。双击识别器靠它把第一下 tap 的胜负推迟到 kDoubleTapTimeout(通常 300ms)之后。这就是「有双击时单击变慢」的物理来源,不是设备卡。
  • 可以提前 close。 一旦有人 resolve(GestureDisposition.accepted),竞技场关闭,再来的 move/up 只发给赢家(以及仍走 Listener / PointerRouter 的那些人)。
  • 认输不可撤回。 rejected 之后再看到符合自己的轨迹也没用。所以过早 accept 和过早 reject 一样致命。

accept 不是「我感兴趣」,是「我确定这是我的手势,别人必须停」。HorizontalDragGestureRecognizer 在位移超过 touch slop 且方向锁定后才 accept。在那之前它只是 possible,和 Tap 共存。slop 的值来自 kTouchSlop(逻辑像素,约 18),并按设备像素比和 pointer kind 调整。把 slop 改成 1,横滑会赢掉几乎所有 tap。

父子都能入场。子项的横滑和父级的竖滑是 同一场 竞技场里的两个成员,不是两场。这就是嵌套手势能被仲裁的原因:它们看见的是同一 pointer id。Widget 树的父子关系只决定谁能 addPointer(因为 HitTest 路径包含两者),真正的互斥是 arena 做的。

class _ClaimOnSlop extends HorizontalDragGestureRecognizer {
  @override
  void handleEvent(PointerEvent event) {
    super.handleEvent(event);
    // 不要在 down 里 accept。那会让父级 Scrollable 永远拿不到这根手指。
  }
}

5. Recognizer 的状态机

以 DragGestureRecognizer 为骨架,绝大多数冲突都能映射过来。

  • down:addPointer,入场,进入 possible,PointerRouter 注册。此时 没有赢家。
  • move:累计 delta。未过 slop 则继续 hold。过 slop 后按轴策略决定:水平识别器发现垂直分量明显更大就 reject;否则 resolve(accepted),发出 onStart,之后 onUpdate。
  • 多轴竞争:Scrollable 默认在主轴上更贪,用的是 VerticalDragGestureRecognizer / HorizontalDragGestureRecognizer,不是 PanGestureRecognizer。Pan 两轴都吃,更容易从父级抢走滚动。列表项里用 Pan 做「随便拖」是常见误用。
  • up:若仍 possible,sweep。Tap 会在此时赢,Drag 会认输(没过 slop 的轻微抖动被当成 tap,这是对的)。
  • cancel:所有人 reject,状态机回 idle。系统抢占(来电、权限弹窗、窗口失焦)走这条。视觉态必须当 up 处理,否则按钮会停在「按住」。

TapGestureRecognizer 的坑更细:

  • 移动超过 slop → 认输,把机会让给 Drag。
  • onTapDown 在竞技场尚未关闭时就可能触发(「可能是 tap」)。真正的 onTap 在赢了之后。
  • 若树上还有 DoubleTapGestureRecognizer,单击要等双击窗口。把导航放在 onTapDown,用户其实在滑列表或准备双击时都会误跳。

EagerGestureRecognizer 在 down 时立刻 accept。画布、地图内部手势常用它挡住父 Scrollable。它是一把锤子:一旦用了,父级彻底拿不到这根手指。正确用法是「根据 down 位置决定这根手指归谁」:点在可编辑图形上才 eager claim,点在空白处让父级滚。

LongPressGestureRecognizer 用定时器。定时器触发前它 hold;触发则 accept。和 Tap 共存时,长按成功 Tap 必输;长按失败(中途移动)两者可能都输,滚动接手。

6. 多指、滚轮、鼠标:不是同一套裁判

多指:每个 pointer 一场竞技场。ScaleGestureRecognizer 同时追踪多个 pointer id,自己在成员内部做焦点、缩放、旋转。它赢的是「这些 pointer 组成的缩放手势」,但实现上仍按 pointer 入场。两指缩放时,单指 Tap 必须在第二指 down 时被拒绝——否则你会看到「捏合结束弹出一次点击」。

鼠标:hover 不走竞技场。MouseTracker 维护 annotations 集合,进入/退出是集合 diff。onEnter/onExit 和 tap 无关。一帧里鼠标移动跨越多个 annotation,会按顺序发 exit/enter。MouseRegion 的 opaque 同样影响命中,但不产生 GestureArena。

滚轮是 PointerScrollEvent,走 PointerSignalResolver:路径上第一个 resolve 的人吃掉这次滚动,避免父子各滚一次。这不是竞技场,是「信号只能有一个消费者」。自己在 Listener.onPointerSignal 里处理滚轮却不 resolve,父级 Scrollable 仍会滚。

手写笔 / trackpad 的 PointerDeviceKind 不同,slop 和按钮掩码不同。不要用「有 down 就是手指」写死逻辑。trackpad 的平移可能以 scroll signal 的形式进来,根本没有 down/up 对。

7. 生命周期、取消与内存

Recognizer 挂在 RenderPointerListener 或 RawGestureDetector 的 State 上。Element unmount 时必须 dispose:从 arena 退场、从 PointerRouter 注销、取消长按定时器。漏了会变成「已经不在树里的横滑还在跟手」——典型场景是列表项滑出视口被回收,Recognizer 还在 router 里。

PointerCancelEvent 必须当 up 处理:清 offset、关动画、不要留下「按住」的视觉态。Android 上窗口丢失焦点、iOS 上系统边缘手势抢占,都会 cancel。Scrollable 在认输后要把 drag 停掉;在赢了之后才进入 ballistic。自己实现嵌套滚动,优先用 NestedScrollView / NotificationListener<ScrollNotification>,不要在 Listener.onPointerMove 里 ScrollController.jumpTo 同时让 Scrollable 也入场——两套状态机会打架,而且 Listener 不受 arena 约束。

内存:事件对象短命,Recognizer 长命。每帧 new 一个 GestureRecognizer 是错误用法——RawGestureDetector 的 gestures factory 应返回稳定实例,或让 State 持有并在 didUpdateWidget 里更新回调闭包。闭包捕获了整页 BuildContext 再塞进长命 Recognizer,等于把整页钉在堆上。

跨组件:Hero、Overlay、Navigator 会改变 HitTest 的树形状。弹层出现后,底下的按钮仍可能 translucent 命中,除非 barrier 是 opaque。这是命中问题,不是竞技场 bug。ModalBarrier 的存在就是为了在资格层把下面的人踢出去。

8. 设计取舍

  • 竞技场而不是静态优先级表: 输赢在运行时由识别器自己声明「我确定了」。代价是延迟:Tap 要等别人认输或 sweep,单击会和双击互相拖慢。换来的是同一套代码在列表、画布、对话框里行为一致。
  • HitTest 与手势分离: 可以点穿、可以半透明叠层。代价是两套 bug:点不中 vs 手势抢。排查必须先分类。
  • Listener 逃生口: 需要零延迟的指针(绘图、游戏)。代价是你自己成为正确性的唯一保证,框架不再帮你互斥。
  • 单 UI Isolate: 手势状态无数据竞争,推理简单。代价是识别逻辑必须轻;想在 worker 里做手势识别,得自己把 pointer 流送出去,再把结论送回来,延迟至少一帧。
  • down 时刻锁定入场名单: 手指滑出控件仍能跟手。代价是「热区外松手」的语义要每个 Recognizer 自己定义(Tap 输、Drag 赢)。

这些取舍决定了「什么优化有意义」。给每个列表项加 Listener 拦截 move,看起来能「更跟手」,实际是拆掉了仲裁,父级滚动会抖。正确做法是让对的 Recognizer 在对的时刻 accept。

9. 排障时按层提问

  1. 点了没反应: 先问 HitTest。是不是 IgnorePointer、零尺寸、transform 把热区移走、父级 AbsorbPointer、behavior 默认 deferToChild 而孩子是空的 Container。debugDumpRenderTree() 看 size 和 hasSize。
  2. 反应了但抢了滚动: 画入场名单。子项是不是 Pan 而不是轴对齐的 Drag;是不是过早 accept;NeverScrollableScrollPhysics 是否误用。在 Recognizer 回调里临时 print pointer id 和 runtimeType 比猜 Widget 树快。
  3. 先闪一下按下态再滑走: 副作用放在了 onTapDown / Ink highlight。移到 onTap,或接受「highlight 可以闪、导航不能闪」。
  4. 双击总是先触发单击: 这是超时换正确性。需要双击的表面不要同时提供单击导航,或改用 SerialTapGestureRecognizer 自己数次数。
  5. 画布和列表抢: 画布按命中对象决定是否 EagerGestureRecognizer claim。不要全局 eager。
  6. 只有某机型: 查 PointerDeviceKind 和系统手势(iOS 返回边缘、Android 三键/手势导航)。系统会在 Flutter 赢之前 cancel。
  7. Platform View: 竞技场到原生边界失效,走平台手势透传。那是混合视图那篇文章的路径,不要在 Dart 里加 Recognizer 硬抢。

能画出「谁 hit、谁入场、谁 accept、谁没 dispose」,手势就不再玄学。

10. 源码级走一遍:从 MotionEvent 到 onTap

把一次单击钉到具体类型上,后面排障才不会在 Widget 层空转。

Android 上 FlutterView.onTouchEvent 把 MotionEvent 收成 PointerData:pointer 来自 pointer id,kind 来自 tool type,坐标先按视图矩阵变成物理像素,再在 Engine 里除以 device pixel ratio 变成逻辑像素。iOS 上 FlutterViewController 的 touchesBegan/Moved/Ended/Cancelled 做同样的事。包进 PointerDataPacket 后,投递目标是 UI Task Runner,不是当前这个主线程调用栈。所以你在原生 onTouchEvent 里打的时间戳,和 Dart PointerDownEvent.timeStamp 之间隔着一次队列。主线程忙时,手势会「肉眼可见地晚一拍」,这不是 Recognizer 的 slop,是 Embedder 排队。

UI Runner 上 GestureBinding._handlePointerDataPacket 把 packet 转成 PointerEvent 序列。同一帧里可能有 down+move,顺序必须保留。然后:

  1. hitTestInView 从 RendererBinding 拿到 RenderView,对每个 event 做 HitTest(move 也会测,但 arena 入场只在 down)。
  2. dispatchEvent:先 pointerRouter.route(已经订阅该 pointer 的 Recognizer / Listener),再沿本次 HitTest 路径 handleEvent。
  3. down 时路径上的 RenderPointerListener、RenderSemanticsGestures、RawGestureDetector 对应的 RenderObject 调用各自 Recognizer 的 addPointer。
  4. GestureArenaManager.close(pointer):所有想入场的人已经入场,竞技场关闭「报名」,开始等待 accept / reject / sweep。
  5. 若这是一个几乎立刻抬起的 tap,move 很少,up 时 sweep。仍在场的成员按加入顺序,第一个 获得胜利。TapGestureRecognizer 通常靠这条赢,而不是自己 accept。

debugPrintGestureArenaDiagnostics = true 会把入场、hold、sweep、win/lose 打到控制台。比在每个 onTap 里 print 更接近真相。生产关掉。

Scrollable 的 VerticalDragGestureRecognizer 在 scrollDirection 为垂直时入场。它的 minFlingDistance、dragStartBehavior 决定 onStart 的位置是「过 slop 的那一点」还是「down 的那一点」。DragStartBehavior.down 跟手更狠,但也更容易和 tap 抢——因为 accept 更早。列表项要 tap、列表要滚,用默认 start 通常更正确。

11. 嵌套滚动、系统手势、Overlay:竞技场的边界在哪

竞技场的不变量是「同一 pointer id」。越出这个 id,框架帮不了你。

嵌套滚动。 外层 CustomScrollView、内层横向 PageView,两根轴,两个 Drag 识别器,仍是同一 pointer。垂直 slop 先满足则外层赢,水平先满足则内层赢。PageView 里再放纵向列表,变成「先锁轴」。NeverScrollableScrollPhysics 不是仲裁,是直接让内层识别器不工作。NotificationListener<OverscrollNotification> 是竞技场结束之后的事:已经有人赢了,你在用输家的溢出做协调。不要用 notification 去「模拟」accept/reject。

iOS 边缘返回、Android 预测性返回。 系统手势在 Flutter 竞技场之外。边缘 20pt 的 back swipe 可能直接 PointerCancelEvent。自己在边缘做抽屉,要和系统手势抢,输的常常是 Flutter。要么留白给系统,要么用原生侧关掉该边缘手势(有平台限制)。这不是 EagerGestureRecognizer 能赢的局。

Overlay / Hero / Tooltip。 Overlay 插在独立子树,HitTest 从 overlay 的 RenderStack 开始。一个全屏 ModalBarrier 必须 opaque,否则会点到 route 下面。Hero 飞行中旧位置和新位置都可能短暂可命中,要用 IgnorePointer 包飞行中的那一份。CompositedTransformFollower 的绘制在另一处,hitTest 默认仍在 follower 的 layout 位置——看见和点中分离,是这套协议最典型的坑。要么自己做 hit 变换,要么把可点控件放在真正 layout 到目标处的那棵子树上。

多指拖拽。 ImmediateMultiDragGestureRecognizer / DelayedMultiDragGestureRecognizer 为每个 pointer 创建独立的 drag 对象,允许「两根手指拖两个 item」。它们和 ScaleGestureRecognizer 冲突:捏合需要两指属于同一识别器。画布类编辑器要在 down 时按命中对象分流:点空白走 scale/pan,点 item 走 multi-drag,不要把所有识别器无脑挂在同一 RawGestureDetector 上指望 arena 懂业务。

RawGestureDetector(
  gestures: {
    TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
      () => TapGestureRecognizer(),
      (TapGestureRecognizer d) {
        d.onTap = widget.onTap;
      },
    ),
    HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
      () => HorizontalDragGestureRecognizer(),
      (HorizontalDragGestureRecognizer d) {
        d.onUpdate = widget.onHorizontalDragUpdate;
      },
    ),
  },
  child: widget.child,
)

factory 的第一个闭包应返回 长命 实例(或至少不要每帧新语义)。GestureDetector 内部就是这张 map。自己写才能少挂不需要的识别器——GestureDetector 默认会创建 tap/longPress/pan/scale 等一长串,即使回调是 null,有的版本仍可能入场。明确的 RawGestureDetector 是嵌套场景里更干净的刀。

竞技场诊断时要把 pointer id 画出来

同一屏幕上可以有多根手指、一支笔、一个鼠标。日志里只打 onTap 而不打 pointer,你会把两场竞技场的输赢拼成一个故事。正确的最小日志是:pointer、runtimeType、accepted/rejected、时间戳。GestureArenaManager 内部是 Map<int, _GestureArena>,按 id 索引。自己用 Listener 做状态机时,也要按 id 分桶,不要用「当前只有一根手指」的全局布尔。

PointerDownEvent.buttons 区分鼠标左/右键、触控笔笔杆键。右键不要进 tap 导航,应进 onSecondaryTap。把右键当 tap,桌面和 Web 上会无法弹出上下文菜单。kPrimaryButton 掩码是这条边界。

device 字段标识物理设备。手指和笔同时存在时,笔的 hover 不应取消手指的 drag。MouseTracker 和 GestureBinding 共享事件流但裁判不同:hover 更新 annotation,down 才开竞技场。用 Listener.onPointerHover 改滚动位置,等于在竞技场外偷拨,父级 Scrollable 不知道,松手后 ballistic 会跳。

HitTest 与滚动偏移:localPosition 的陷阱

BoxHitTestResult.addWithPaintOffset 把 child 的 offset(含 scroll offset)记进矩阵。Scrollable 的 sliver 只命中可见孩子——这是性能不变量:未 layout 的 sliver child 不在树上,谈不上命中。自己用 Viewport 以外的方式做「只画可见项」时,必须保证不可见项要么不在子级列表,要么 hitTest 为 false。留在树上且 size 非零的离屏按钮仍能被点到,如果它们的几何因缺 clip 而叠在视口上。

clipBehavior: Clip.none 的 Stack 是命中事故高发区:视觉溢出、命中也溢出,挡住了后面本该可点的控件。clipBehavior 默认 clip 的组件,hitTest 仍可能在 clip 外返回 true,除非 RenderObject 实现了 clip 感知的 hitTest(RenderClipRect 会裁)。不要假设「看不见 = 点不中」。看见走 paint,点中走 hitTest,两套 clip。

Transform.scale 缩小时,命中区域跟着矩阵变。视觉热区变小,手指变难点。扩大热区应在未缩放的祖先加 padding,或重写 hitTestSelf 用更大的 rect,不要靠用户「点准中心」。

手势与语义、平台视图的交接

读屏激活走 SemanticsAction.tap,不经过竞技场。GestureDetector 若只设 onTap,框架会同步提供语义 tap;RawGestureDetector 和纯 Listener 不会。自绘按钮用 Listener 收手势,必须另挂 Semantics(onTap: ...),否则 TalkBack 用户无法激活。这是手势篇和无障碍篇的接缝:两条输入路径,一份业务函数。

Platform View 矩形上,HitTest 会命中 RenderAndroidView / RenderUiKitView。Recognizer 可能入场,但后续事件被 Embedder 转走后,Flutter 侧会收到 cancel。表现是:外层 Scrollable 抖一下又停,WebView 开始滚。eager 策略就是在 down 时决定这场 cancel 发不发。把它当 bug 修来修去,不如把策略写成参数。

自己实现 GestureArenaMember 时的最小契约

多数人停在 GestureDetector。一旦要做「双指在画布平移、单指在已选图形上变形」,就要自己实现 GestureRecognizer。最小契约只有四件事:addPointer、handleEvent、acceptGesture、rejectGesture。漏掉 reject 里的清理,输了之后仍会在 PointerRouter 里吃 move,表现为「认输了还在跟手」。

class CanvasPanRecognizer extends OneSequenceGestureRecognizer {
  Offset? _start;
  void Function(Offset delta)? onPan;

  @override
  void addPointer(PointerDownEvent event) {
    startTrackingPointer(event.pointer, event.transform);
    _start = event.position;
    resolve(GestureDisposition.accepted); // 仅当确定父级不该滚时
  }

  @override
  void handleEvent(PointerEvent event) {
    if (event is PointerMoveEvent && _start != null) {
      onPan?.call(event.position - _start!);
    }
    if (event is PointerUpEvent || event is PointerCancelEvent) {
      stopTrackingPointer(event.pointer);
    }
  }

  @override
  void acceptGesture(int pointer) {}

  @override
  void rejectGesture(int pointer) {
    stopTrackingPointer(pointer);
    _start = null;
  }

  @override
  String get debugDescription => 'canvasPan';
}

OneSequenceGestureRecognizer 表示「这根手指的整段序列归我」。MultiTapGestureRecognizer 一类才同时看多根。startTrackingPointer 把你挂进 PointerRouter;stopTrackingPointer 必须在 up/cancel/reject 成对调用。不成对就是泄漏:Recognizer 活在 State 里,router 里的订阅却指向已经不该收事件的对象。

resolve(accepted) 在 addPointer 里立刻调用,等于 Eager。只有在 HitTest 已经告诉你「点在画布空白」时才能这样。点在按钮上仍 eager,按钮的 Tap 永远赢不了。分流发生在 工厂创建哪个识别器 或 addPointer 里是否 resolve,不是发生在 onTap 里 return。

GestureRecognizer.team(GestureArenaTeam)允许一组识别器共享胜负:水平拖和垂直拖组成队,队赢了再在队内分。Scrollable 内部用 team 处理某些嵌套。自己用 team 前先确认不是「两个都该输给第三个人」。team 赢了会挡住队外的 Tap。

鼠标、触控板、kTouchSlop 的量纲

kTouchSlop 是逻辑像素。dpr=3 的设备上物理像素约 54。把 slop 写成 2,所有 tap 都会变成 drag。桌面鼠标的 slop 更小(kPrecisePointerHitSlop 一类常量),因为指针精度高。同一套 PanGestureRecognizer 在手机和桌面表现不同,是常量按 PointerDeviceKind 分支,不是 bug。

触控板的平移在部分平台是 PointerPanZoomStart/Update/End,不是 down/move/up。Listener 要额外挂这些回调。只处理 down/move 的自绘画布,在 Mac 触控板上会完全不动。这是指针设备种类的边界,不是竞技场没赢。

PointerSignalResolver 的 register 在一次信号分发中只生效一次。父子都 onPointerSignal 里 resolve,先被 HitTest 路径排到的人赢。想让父级滚轮、子级只在按住 Alt 时缩放,必须在子级 有条件地 resolve,不要无条件吃掉。

与滚动物理、通知的分工

竞技场决定谁拥有手指。ScrollPhysics 决定赢了之后怎么滑。把「滑不动」当成手势 bug,有一半其实是 NeverScrollableScrollPhysics、父级 AbsorbPointer、或 IgnorePointer 包了列表。先问谁赢了 arena:若 VerticalDrag 已经 accept 仍不滑,去查 physics 和 ScrollController 是否在别处 jumpTo。若 arena 里 Tap 赢了,再查为什么 Drag 过早 reject——多半是 slop 或轴锁。

NotificationListener 发生在滚动已经发生之后。用它做「吸顶、折叠头」可以;用它做「禁止父级滚动」晚了。禁止发生在 physics 和 arena。通知是只读观察。

这三条——资格、所有权、物理——画在一张纸上,比在三个 Widget 上各打一次补丁干净。

把「手势系统」收成一句工程口令:先几何,后所有权,再物理。 几何是 HitTest 矩阵与 clip;所有权是 pointer id 上的竞技场;物理是赢了之后 ScrollPhysics / 画布矩阵怎么积分。三层任何一层的 bug 都会被说成「手势坏了」,但修的文件完全不同。几何在 RenderBox,所有权在 GestureRecognizer,物理在 Scrollable 或你自己的 Ticker。跨层打补丁(在 Listener 里 jumpTo 又让 Scrollable 入场)会制造不可复现的抖动。新需求先问落在哪一层,再决定加 Recognizer、改 hitTest,还是改 physics。这句口令可以贴在自定义交互的 PR 模板上,比再贴一份 GestureDetector 参数表有用。

调试时把 debugPrintHitTestResults 和 debugPrintGestureArenaDiagnostics 成对打开,只开一个会误判。路径上有人、竞技场里没人,是 Listener 或 IgnorePointer;竞技场里有人却全输,是 slop 和轴锁。两者日志时间戳对齐同一 pointer id,故事才会闭合。关日志再提交,否则用户设备会卡在打印上——这本身也是 UI Runner 的病。

至此可以把手势调试收成固定三问:路径上有谁、竞技场里谁赢、赢了之后物理是否在听。三问都有日志开关。再往下才是 Platform View 和系统手势那些例外。例外另案处理,不在 Recognizer 里加 if 平台。

12. 小结

指针从 Shell 进 UI Runner,经 HitTest 变成资格路径,经 Arena 变成唯一赢家,Recognizer 把赢家的 pointer 流翻译成 tap/drag/scale。不变量是一场指针一次所有权转移。Listener 故意不参加这场转移。嵌套手势的正确性来自「同一 pointer id 进同一场竞技场」,不是来自 Widget 树的父子关系本身。把问题画回这两段协议,代码改动才会落在正确的层。