谁来响应事件
在UIKit中我们使用响应者对象(UIResponder)来接收和处理事件
一个响应者对象一般是UIResponder类的实例,它的子类包括UIView,UIViewController和UIApplication
因此可以知道我们日常使用的控件都是响应者,如UIButton,UILabel等
在UIResponder及其子类中,我们是通过有关触摸(UITouch)的方法来处理和传递事件(UIEvent),包含下列方法
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
注意,参数UITouch传递的是UITouch类型的一个集合(而不是一个UITouch),这对应了两根手指及以上同时触摸一个视图的情况,及一个手指对应一个UITouch
确定第一响应者
确定第一响应者:找到在这次触摸事件中,用户最想要哪个控件发起响应
在触摸发生后,UIApplication会触发- (void)sendEvent:(UIEvent *)event;
将一个封装好的UIEvent传给UIWindow,通常情况下会传给当前展示的UIViewController,传给UIViewController的根视图
UIKit为我们提供了命中测试(hit-testing)来确定触摸事件的响应者
- 无法接收事件的情况
- 不接受交互:
view.userInteractionEnabled = false
- 透明:
view.alpha <= 0.01
- 隐藏:
view.hidden = true
- 检查坐标是否在自身内部使用了
pointInside
方法来判断,该方法可以被重写 - 从后往前遍历子视图,FILO,保证系统会优先测试最后添加的视图
- 按顺序查看平级的兄弟视图时,若发现已经没有未检查的视图了,则返回没有子视图符合要求
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
// recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
// default returns YES if point is in bounds
举例
下图中灰色视图 A 可以看作是当前 UIViewController
的根视图,右侧表示了各个视图的层级结构,用户在屏幕上的触摸点是🌟处,并且这 5 个视图都可以正常的接收事件。⚠️并且注意,D 比 B 更晚添加到 A 上。
具体流程如下:
- 我们先对A进行命中测试,按照流程检查:🌟是否在A内部(显然在),A是否有子视图
- A有两个子视图B和D,按照FILO原则进行遍历子视图,因此对D进行命中测试,再对B进行命中测试
- 对D进行命中测试:🌟不在D内部,说明D及其子视图不是第一响应者
- 然后对B进行命中测试:🌟是否在B内部(在),B是否有子视图
- B有一个子视图C,因此需要再对C进行命中测试
- 对C进行命中测试:🌟不在C内部,说明C及其子视图不是第一个响应者
- 因此可以得到,触摸点在B内部,但不在B的任何子视图内
- 即B是第一响应者,结束命中测试
- 命中测试流程:A✅ --> D❎ --> B✅ --> C❎ >>>> B
class HitTestExampleView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
return nil // 此处指视图无法接受事件
}
if self.point(inside: point, with: event) { // 判断触摸点是否在自身内部
for subview in subviews.reversed() { // 按 FILO 遍历子视图
let convertedPoint = subview.convert(point, from: self)
let resultView = subview.hitTest(convertedPoint, with: event)
// ⬆️这句是判断触摸点是否在子视图内部,在就返回视图,不在就返回nil
if resultView != nil { return resultView }
}
return self // 此处指该视图的所有子视图都不符合要求,而触摸点又在该视图自身内部
}
return nil // 此处指触摸点是否不在该视图内部
}
}
提一个问题
有A、B两个视图,A先添加,B后添加,但是点击屏幕时,我希望是A称为第一响应者,应该怎么办?
- 可以通过修改B视图的hit test方法,例如将
!isUserInteractionEnabled
修改为isUserInteractionEnabled
,这样就会判断就成立,返回nil
if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
return nil // 此处指视图无法接受事件
}
小心越界
对于这个例子我们可以看到D有子视图E,命中测试的过程(省略写)
- 对D进行命中测试:🌟不在D内部,因此D及其子视图不是第一响应者
- 对B进行命中测试:然后对B进行命中测试:🌟是否在B内部(在),B是否有子视图
- B有一个子视图C,因此需要再对C进行命中测试
- 对C进行命中测试:🌟不在C内部,说明C及其子视图不是第一个响应者因此可以得到,触摸点在B内部,但不在B的任何子视图内
最终获得第一响应者仍然是 B,甚至整个命中测试的走向和之前是一样的:A✅ --> D❎ --> B✅ --> C❎ >>>> B
这个例子告诉我们:要注意可点击的子视图是否会超出父视图的范围
如果真的存在这个情况,我们可以通过重写
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
方法来扩大点击有效范围
通过响应链传递事件
确定响应链成员
在找到第一响应者后,整个响应链也确定下来了
响应链:由响应者组成的一个链表,链表的头部是第一响应者
其实响应链就是在命中测试中,走通的路径。用上个章节的例子,整个命中测试的走向是:A✅ --> D❎ --> B✅ --> C❎,我们把没走通的❎的去掉,以第一响应者 B 作为头,依次连接,响应链就是:B -> A。(实际上 A 后面还有控制器等,但在该例子中没有展示控制器等,所以就写到 A)
默认来说,若该节点是UIView类型的话,那么.next就是该节点的父视图,但也有几个例外:
- 如果是UIViewController的根视图,那下一个响应者是UIViewController
- 如果是UIViewController
- 如果UIViewController的根视图是UIWindow的根视图,则下一个响应者是UIWindow对象
- 如果UIViewController是由另一个UIViewController呈现的,则下一个响应者是第二个UIViewController
- UIWindow的下一个响应者是UIApplication
- UIApplication的下一个响应者是AppDelegate
举例
下面举个例子来说明。如下图所示,触摸点是🌟,那根据命中测试,B 就成为了第一响应者。由于 C 是 B 的父视图、A 是 C 的父视图、同时 A 是 Controller 的根视图,那么按照规则,响应链就是这样的:
视图 B` -> `视图 C` -> `根视图 A` -> `UIViewController 对象` -> `UIWindow 对象` -> `UIApplication 对象` -> `App Delegate

沿响应链传递事件
触摸事件首先会由第一响应者进行响应,触发其 touchesBegan
等方法
若第一响应者在这个方法中不处理这个事件,则会传递给响应链的下一个响应者触发该方法,以此类推
若最后还没有人响应,则会被丢弃
举例
如下图,A B C 都是 UIView
,我们将手指按照🌟的位置和箭头的方向在屏幕上移动一段距离,然后松开手。我们应该能在控制台看到下右图的输出。我们可以看到,A B C 三个视图都积极的响应了每一次事件,每次触摸的发生后,都会先触发 B 的响应方法,然后传递给 C,在传递给 A。但是这种「积极」的响应其实意味着在我们这个例子中,A B C 都不是这个触摸事件的合适接受者。他们之所以「积极」的将事件传递下去,是因为他们查看了这个事件的信息之后,认为自己并不是这个事件的合适处理者。(当然了,我们这边放的是三个 UIView
,他们本身确实也不应该能处理事件)

如果我们把C换成UIControl类,打印如下。我们会发现响应链传递到C处就停止了,也就是A的touches方法没有被触发
这意味着响应链中,UIControl及其子类默认来说是不会把事件传递下去的,这就做到了当某个控件接受了事件之后,事件的传递就会终止(UISCrollView也会这样的工作机制)

总结
总的来说,触摸屏幕幕后事件的传递可分为以下四个步骤:
- 通过 命中测试 来找到 第一响应者
- 由 第一响应者 来确定 响应链
- 将事件沿 响应链 传递
- 事件被某个响应者接收,或没有响应者接收从而丢弃
响应链与手势识别
当手势识别参与响应链
在上面讨论的情况,是不使用UIGestureRecognizer的情况(蓝色部分)下面来讨论在UIGestureRecognizer参与的情况下,事件的处理和接收是如何运作的

在通过命中测试找到第一响应者后,会将UITouch分发给UIResponder的touches方法,同时也会分发给手势识别系统,让这两个处理系统同时工作
注意,蓝色部分的流程并不会只执行一次,举例来说:当我们用一根手指在视图上缓慢滑动时,会产生一个UITouch对象,这个UITouch会随着你手指的滑动,不断的更新自身,同时也不断出发touches系列方法,例如类似这样的触发顺序
touchesBegan // 手指触摸屏幕
touchesMoved // 手指在屏幕上移动
touchesMoved // ...
...
touchesMoved // ...
touchesMoved // 手指在屏幕上移动
touchesEnded // 手指离开屏幕
UITouch的gestureRecognizer属性中存储了在寻找第一响应者的过程中收集到的手势,而在不断触发touches系列方法的过程中,手势识别系统也会不停的判断当前这个UITouch是否符合收集到的某个手势
当手势识别成功:第一响应者会收到touchesCancelled的消息,并且该视图不会再收到来自该UITouch的touches事件,同时也让该UITouch关联的其他手势也能收到touchesCancelled,并且不会再收到来自该UITouch的touches事件,这样做就实现了该识别到的手势能够独占UITouch
touchesBegan // 手指触摸屏幕
touchesMoved // 手指在屏幕上移动
touchesMoved // ...
...
touchesMoved // ...
touchesMoved // 手指在屏幕上移动
touchesCancelled // 手势识别成功,touches 系列方法被阻断
// 现在手指💅并没有离开屏幕
// 但如果继续滑动🛹的话
// 并不会触发 touches 系列方法
当手势识别未成功:暂未识别成功不代表以后不会识别成功,因此不会阻断响应链。手势的内部状态大部分时候是.possible,即UITouch暂时不与其匹配,但之后有可能会识别成功,而.fail是真的识别失败
举例
现在用一根手指在一个视图上触摸并滑动一段距离
-
在不带手势的情况下,手指按下去的时候,响应者的touchbegan会被触发,手指移动时touchmoved被触发,手指结束抬起时touchended触发,在这个过程中,我们接收到的一直是一个不断更新的UITouch
-
在视图中添加手势的情况下,我们多了一条响应链同时工作,手势识别系统前半段也处于识别状态,手指滑动一段距离(手指还未抬起前),手势识别系统确定了该UITouch所做的动作是符合UIPanGestureRecognizer的特点的,于是给该视图发送touchesCancelled消息,从而阻止这个UITouch继续触发这个视图的touches系列方法。在这之后,被调用的只有与手势关联的target-action方法(墨绿色节点)
进一步理解
- 手势识别器不是响应者,但也有touches系列方法,比它所添加的视图的touches方法更早那么一点触发
- 手势识别器的状态在图中未标出:
- 手势在图中
recognizing
的橙色节点处和recognized
棕色节点处都处于.possible
状态 - 手势在图中绿色节点处的状态变化是
.began
->[.changed]
->ended
- 手势在图中
更多选择
cancelsTouchesInView
:默认是true;如果设置成false,当手势识别成功时,不会发送touchesCancelled给视图,即不会打断视图本身方法的触发,最后的结果时手势和本身方法同时触发delaysTouchesBegan
:该属性默认是false
;如果的设置为true,那么视图的touches方法会被延迟到手势识别成功或者失败之后再开始(其实如果手势识别成功了,视图的touches方法不会被触发...)delaysTouchesEnded
:该属性默认是true
。该属性为true
时,视图的touchesEnded
将会延迟大约 0.15s 触发,该属性常用于连击。如果设置为false,就不会延迟视图的touchesEnded方法,将会立即触发,那么我们的双击就会被识别为两次单击
UIControl与手势识别
对于自定义的 UIControl
来说,手势识别的优先级比 UIControl
自身处理事件的优先级高。
举个例子来说:当我们给一个 UIControl
添加了一个 .touchupInside
的方法,又添加了一个 UITapGestureRecognizer
之后。点击这个 UIControl
,会看到与手势关联的方法触发了,并且给 UIControl
发送了 touchCancelled
,导致其自身的处理时间机制被中断,从而也没能触发那个 .touchupInside
的方法。
那么会导致一个问题:当我们给一个已经拥有点击手势的视图,添加一个UIControl作为子视图,那么无论我们怎么给UIControl添加点击类型的target-action方法,最后结果都是触发其父视图的手势,并且中断UIControl的处理,导致target-action方法永远无法触发
APPLE解决方案:UIKit对部分控件做了特殊处理,当这些控件的父视图上有与该控件冲突功能的手势,会优先触发控件自身的方法,不会触发其父视图上的那个手势
那如果你就想触发手势呢?那就应当将手势添加到目标控件上而不是视图上(就像上面那个 UIControl
添加了一个 .touchupInside
的方法例子),这样的话生效的就是手势了