本节摘要:上一章说过输入事件沿转化链反向流回 Dart,本节把这段路走完:指针事件如何做命中测试圈定候选者,手势竞技场如何用"迟到者出局"的规则裁定嵌套手势,以及 onTap 与 onLongPress 打架、按钮放进可滑动列表点不灵这些经典问题的答案。读完你将能预判任何嵌套手势的行为。
事件旅程分两段。第一段是原始指针事件(PointerEvent):指尖按下、移动、抬起,每个动作都是一条独立消息,带坐标、时间戳与设备号。这一层的事件又"生"又"碎"——按住不动再抬起,会收到按下、若干次移动、抬起共好几条消息,此刻框架还不知道你想"点一下"还是"长按拖动"。
第二段是手势语义(Gesture):框架把原始事件流组装成"单击""双击""长按""拖动"这类有业务含义的动作,再分发给你的回调。你在 GestureDetector 与各种按钮上写的 onTap、onLongPress,消费的都是第二段。两段之间的翻译规则,就是下面的命中测试与竞技场。
命中测试解决"事件给谁"的问题:框架从根节点出发,对每次按下事件做一次从深到浅的检测,凡"按下坐标落在自己范围内"的渲染对象都进入候选名单。注意关键词"从深到浅"——屏幕上叠着三层卡片,一次按下会同时命中三层,候选名单里它们都在。谁最终响应,交给竞技场裁定。

同一根手指的一次接触,候选者们会注册各自的意图到同一个"竞技场"(GestureArena)。裁定规则朴素有效:
拖动类优先即时裁定。位移一旦超过阈值(几毫米级别),拖动候选者立即宣布获胜,其余全部出局收到取消事件。所以列表滚动起来干脆利落,不用等任何超时。
单击类必须等超时。onTap 想赢,得等"这根手指最终没有演变成拖动或长按"——超时时间一过(约百毫秒量级),单击才算获胜。这解释了一个体感细节:单击回调天然带一点延迟,而列表快速滚动时按钮"点了没反应",其实是滚动候选者先赢了,单击被判出局。
长按与双击互斥。注册了 onDoubleTap 后,单击要等更久(得确认不会有第二次按下),所以给按钮加双击识别会让单击变迟钝——能不加就不加,或者只在确实需要双击语义的画布类界面上加。
嵌套场景的推论:内层的 GestureDetector 和外层的都在同一个竞技场里,通常内层单击会赢(它注册的位置更"近");但若内层只注册了拖动而外层也注册了拖动,赢的是内层——想让外层(比如整页滚动)接管,就要用行为控制或直接不注册内层的同种手势,而不是层层包 GestureDetector 碰运气。
轻记账的账单列表,长按弹操作菜单、点按进详情,滑动整页滚动。把三种手势放在同一屏,正好验证裁定规则:
import 'package:flutter/material.dart'; class BillTile extends StatelessWidget { final String billId; final String title; final double amount; const BillTile({ super.key, required this.billId, required this.title, required this.amount, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: () { debugPrint('竞技场赢家:单击 → 进详情 $billId'); Navigator.of(context).pushNamed('/detail', arguments: billId); }, onLongPress: () { debugPrint('竞技场赢家:长按 → 弹出操作菜单 $billId'); showModalBottomSheet( context: context, builder: (_) => const ListTile(title: Text('删除这笔账单')), ); }, child: ListTile( title: Text(title), // InkWell 提供水波纹观感;它内部同样是手势,与外层竞技场共用裁定 trailing: Text('¥$amount'), ), ); } }
把它放进 ListView 跑起来,做三组实验并观察日志:快速点按(单击赢,立即触发);按住不动(长按赢,菜单弹出);按住往下滑(拖动赢,列表滚动,两个回调都不来)。三组现象与竞技场规则逐一对应——以后遇到"点不动""误触发",先复现是哪一种裁定结果,再查嵌套结构,比盲改快得多。
一个容易忽略的工程细节:需要水波纹反馈的场景优先用 InkWell 或现成按钮组件,而不是裸 GestureDetector。前者还接入了无障碍的语义树与悬停态,后者只有裸手势——这也是第 6 章可访问性检查会抓的点。
旅程的主干讲完了,两条细则补齐细节,它们都来自真实的排错现场。
细则一:命中测试会忽略超出父级裁剪的孩子。 用 Stack 把一张卡片的位置摆到父级边界之外(负偏移),压在边界外的那部分收不到点击——命中测试沿裁剪后的区域走,视觉溢出不等于可点。解决办法是把溢出部分挪进父级的合法范围,或用 ClipBehavior 调整裁剪行为。这条细则的推论常反着用:给点击热区太小的图标套一层透明 Padding,扩的就是命中测试的实际范围。
细则二:Pointer 事件与手势是并行的两层。 Listener 组件收原始指针事件,永远先于手势裁定触发——需要"无论胜负都要知道按下"的场景(自定义按压缩放、拖拽影子)用 Listener,需要语义手势的用 GestureDetector。两者叠在同区域并不冲突,只是别在 Listener 的回调里又做一遍手势该做的事,两套状态机打架的排错成本极高。
点按的旅程已经走完,但如果处理事件的瞬间还要算大数、读大文件,界面照样会卡。下一节看 Dart 的异步机制如何给界面线程让路,以及什么时候必须请出 Isolate。