引言
先从触摸屏幕说起。要理解Button如何响应触摸,首先要弄清坐标转换的必要性。为什么需要转换?
在UI逻辑中,每个控件的坐标都相对于其父视图定义,而布局本身可能嵌套多层。因此,当手指触摸屏幕时,要判断触摸点落在哪个控件上,必须将所有控件的坐标统一换算到相对于窗口的坐标系。

转换后,坐标变成如下形式——(如图)

虽然这个直观逻辑看起来简单,但实际的命中检测过程是顺着UI的嵌套层级递归进行的——从Window开始,逐级向下倒序遍历子视图。每递归到某一层View,就需要将当前View上的触摸点坐标,转换成子View上的坐标。

简而言之,在检测阶段,每次递归转换坐标,本质上就是在遍历子View时,把Point的参考基点从当前View切换到子View。理解这个参考系切换是核心。
简单梳理流程

- 触摸屏幕
- IOKit.framework 捕捉触摸,封装为 IOHIDEvent 对象
- 通过 IPC(进程间通信)转发给 SpringBoard 进程
- 再通过 IPC 将事件转发给当前活跃进程的 AppDelegate
- App 主线程的 Runloop 通过 Port signal(来自 SpringBoard)检测到 Source1,线程从休眠中被唤醒,Runloop 继续轮询
- Runloop 检测到 Source0(InputSource),封装 UIEvent,并加入当前 Application 的 event 队列
- 事件出队列,通过 sendEvent 发送给 Window
- 具体 Source1 处理事件需严谨说明:
- 检测到 Source1,触发回调 __IOHIDEventSystemClientQueueCallback()
- 触发 Source0 回调 __UIApplicationHandleEventQueuqe(),将 IOHIDEvent 封装为 UIEvent
- 调用 UIApplication 的 sendEvent,将 UIEvent 发送给 Window
- Window 开始查询响应者
- rootViewController.view 按照子视图倒序递归查询
- 通过 pointInside 判断触摸点是否落在当前 View 的 bounds 内
- hitTest:如果触摸点落在 bounds 内,则转换触点坐标为相对于屏幕的坐标点,然后递归倒序遍历子视图的 hitTest 检测
- 子视图数组采用倒序遍历的原因:最后添加的 View 位于层次最上层,先检测它,效率更高
- 检测可能出现 3 种结果:
- 目标响应者被禁用了交互,且不是完全透明、未隐藏,结果为没有响应者(nil)
- View 的某个子视图成为目标响应者
- 当前 View 自身成为目标响应者
- Window 调用 sendTouchesForEvent 将触摸事件发送给上面查询到的响应者;如果响应者为 nil,则后续处理停止
- 执行 touchBegan / touchMoved / touchEnded / touchCancelled 捕获处理
- 回调响应者预先设置的 handleCallback(即 selector),并传递响应者自身作为参数
- 根据 touch 的多种逻辑状态,选择合适的 callback
- 例如按下按钮时背景颜色变化
- 离开按钮时颜色恢复等——各种 touch 事件解释类型,对应执行不同的 callback
- 如果响应者未处理 touch,则事件会沿着响应链反向传递给父视图,直至 Application。即若目标响应者未响应,事件沿传递链回溯到 Application,而 Application 默认不做处理
- 处理结束,App 的 Runloop 进入休眠,等待下次唤醒
apple-touch封装
touchBegan / touchMoved / touchEnded / touchCancelled 是底层方法。Apple 提供了更高级的封装:UIGestureRecognizer 和 UIControl。
UIGestureRecognizer 包含 8 种手势:
- UITapGestureRecognizer – 轻点
- UIPinchGestureRecognizer – 捏合
- UIRotationGestureRecognizer – 旋转
- UISwipeGestureRecognizer – 滑动
- UIPanGestureRecognizer – 拖拽
- UIScreenEdgePanGestureRecognizer – 屏幕边缘拖拽
- UILongPressGestureRecognizer – 长按
- UIHoverGestureRecognizer – 悬停(macOS & iPadOS)
window sendTouchesForEvent 后续流程修正
上面的流程基于底层方式描述。若使用了 Apple 封装的 UIGestureRecognizer,流程需稍作调整。
Window 查询到具体响应者之后:
- Window 调用 sendTouchesForEvent 发送给响应者;同时也会发送给响应者视图绑定的 gestureRecognizers
- 响应者视图的某个 gestureRecognizer 匹配成功,会回调响应者的 touchCancelled 方法,响应者不再接收 touch 事件
- 由于手势互斥,其他 gestureRecognizer 也会收到 touchCancelled 回调,且不再接收 touch 事件
- 识别成功的 gesture 设置的 target-action 被执行
- 否则,继续执行 touchBegan / touchMoved / touchEnded 及后续处理
- 处理结束,App 的 Runloop 进入休眠,等待下次唤醒
还有一些额外配置:
- 识别成功后是否取消其他响应(cancelsTouchesInView),取值 true 或 false
- delaysTouchesBegan:是否在手势识别失败后,才将 touchBegin 事件传递给响应者
- delaysTouchesEnded:是否在手势识别失败后,才将 touchEnded 事件传递给响应者
流程进一步细化
UIControl 是 UIView 的子类。
保持前面修正的流程:
- 如果响应者是
UIButton、UISwitch、UISlider等系统控件(即 UIControl 系统子类),则直接执行 target-action,响应者不再接收 touchBegan 等事件 - target-action 的执行流程:响应者调用 sendAction 转发给 Application,Application 调用 sendAction 分发到指定 target
- 若未指定 target,则将事件分发到响应链上第一个希望处理的对象
UIControl 还提供了自定义行为的接口:
- beginTrackingWithTouch
- continueTrackingWithTouch
- endTrackingWithTouch
- cancelTrackingWithEvent
以上,就是 iOS Button 响应流程的图文详解。理解这些底层细节,对于调试 UI 问题、自定义控件响应逻辑都很有帮助。
