引子
hitTest、响应链 touches、UIGestureRecognizer、UIControl、ScrollView 抢触摸——其实是一条流水线 + 手势旁路观察网。
触摸落下 → hitTest → 投递(响应链 ∥ 祖先 GR)→ UIControl 等语义层
一、响应链 —— 命中之后往上传
响应链不是 hitTest 的链,hitTest走的是视图树。点在任意 view 上,响应链只从命中 view 沿 superview 往上,永远不扫兄弟节点。所以是先走视图树,再有响应链。
链上全是 UIResponder:UIView、UIViewController、UIApplication。UIGestureRecognizer 不是 UIResponder,不在响应链上。
之后都用GR表示GestureRecognizer。
某一环 override 了 touch 且不调 super,链在此截断。
touches 是 UIResponder 实例方法,不是某个protocol定义的。
在 UIResponder 的 touch 方法里,默认实现会把事件继续传给 next responder。通常是父 view / VC。
二、UIControl —— touch 之上的语义
UIControl 其实是系统实现了 override touches + target-action。
独占交互(如:按钮、画板)通常不调 super;
附加逻辑(如:日志)可处理后再 super。
不调 super 只能挡响应链上传,挡不住 ScrollView 的 pan GR。
三、手势 —— 沿 superview 旁路观察
GR不是Responder,不实现touches;系统把同一次 touch 并行喂给 GR 的状态机,识别成功后再影响 view(delay / cancel)。点在 ScrollView 里的 Button 上时,系统从 Button 沿 superview 向上,在响应链里收集沿途各层 view 上的 GR,放入观察者列表,登记为本次 touch 的接收者。这些观察者会并行收到touch投递。
ScrollView 的 pan 能收到子 Button 的 touch,走的是视图祖先路径,不是响应链的 next。
观察者列表不属于某个 view 的公开属性,是 UIKit 为每个 UITouch 维护的内部投递状态。
并行投递不是多线程,而是同一次 touch 在主线程里同时走两条路:touch.view 的 touches(响应链)+ 所有登记 GR 的状态机。GR 识别成功且 cancelsTouchesInView 为 true 时,view 收 touchesCancelled。
四、响应链:touch 何时往上传?
不是自动冒泡,而是每一环 touches 里调了 super 才上传。
UIResponder 默认转发给next;
UIView 默认调用 super;
你 override 后由你决定。
每个阶段独立,Began、Moved、Ended、Cancelled 各决定各的。
默认上传:UIView 没 override;或你调了 super。
默认不上传:UIControl 不调 super。
最后:不调 super 是挡不住祖先手势识别的。
五、A→B→C 与手势收集
A View(tap)
→ B View(pan)
→ C View(touch.view)
手势识别 沿 C→B→A 向上收集:B、A 的 GR 都会收到;兄弟 GR 收不到。同时收到更新,默认不能同时 recognized。六、如何让 C 优先级更高?
只让 C 不 super 不够,只能挡响应链。
- 做法一:C 上挂 tap/pan,
B.pan.require(toFail: cGesture)
// C 上:点击 / 拖动手势let cTap = UITapGestureRecognizer(target: self, action: #selector(onCTap))C.addGestureRecognizer(cTap)// B 的 pan 必须等 C 的 tap 判定失败后才能成功B.panGesture.require(toFail: cTap)// 若 C 上要拖动绘制,用 pan:let cPan = UIPanGestureRecognizer(target: self, action: #selector(onCPan))C.addGestureRecognizer(cPan)B.panGesture.require(toFail: cPan)A.tapGesture.require(toFail: cPan) // 若 A 的 tap 也会抢
- 做法二:父 GR delegate
shouldReceive 方法中对 touch.view === C 返回 false
// B/A中实现这个方法func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { // touch.view 是 C,或点在 C 子树内且应由 C 处理 if touch.view === C || touch.view?.isDescendant(of: C) == true { return false // B/A 的手势根本不参与这次 touch } return true}
- 做法三:
gestureRecognizerShouldBegin
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer, pan.view === B else { return true } let loc = pan.location(in: C) if C.bounds.contains(loc) { return false // 在 C 区域内,B 的 pan 不开始 } return true}
七、cancelsTouchesInView相似相关的配置
在创建 GR/ScrollView 时设置,UIKit 在 sendEvent 投递时内部读取,不是你手动调用。
| | | |
|---|
| | | |
| recognized 后 cancel 子树 touch | | |
| | | |
对手势识别子树含 UIControl例如UIButton 等无豁免权,例如ScrollView 包着 Button,点的是 Button:
- 但 ScrollView 上的 pan GR 照样进候选集,照样可能
delaysTouchesBegan / cancelsTouchesInView - 不会因为子树里是 UIControl 就跳过这套机制
两个 delay 可叠加(began 更晚、用户操作的响应更慢),不会导致 cancel。
cancel 只来自 cancelsTouchesInView,在 gesture recognized 时触发。同一次触摸可先 delay 再 cancel(先等、再滑),是两件独立的事。