假如我们点击了手机屏幕📱,那么当前页面的app需要识别出点击的是哪一个控件,并且对事件的响应进行处理。
而iOS系统的UIKit已经设计好一套方案:利用事件传递以及响应链去确定响应者。
响应事件
在UIKit中我们使用响应者对象(Responder)处理事件。一个响应者对象一般是UIResponder
类的实例,它常见的子类包括UIView
,UIViewController
,UIApplication
,这意味着我们日常使用到的控件几乎都是响应者,如 UIButton
,UILabel
等等
在UIResponder
及其子类中,我们是通过有关触摸(UITouch)方法来处理和传递事件(UIEvent),具体的方法有
open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
open func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)
在 UITouch
内,存储了大量触摸相关的数据,当手指在屏幕上移动时,所对应的 UITouch
数据也会更新,例如:这个触摸是在哪个 window
或者哪个 view
内发生的?当前触摸点的坐标是?前一个触摸点的坐标是?当前触摸事件的状态是?这些都存储在 UITouch
里面。另外需要注意的是,在这四个方法的参数中,传递的是UITouch
类型的一个集合(而不是一个 UITouch
),这对应了两根及以上手指触摸同一个视图的情况。
确定第一响应者
第一响应者就是 我们点击了屏幕上的UIButton控件,那么这个按钮控件就是这个事件的第一响应者
在触摸发生后,UIApplication
会触发 func sendEvent(_ event: UIEvent)
将一个封装好的 UIEvent
传给 UIWindow
,也就是当前展示的 UIWindow
,通常情况接下来会传给当前展示的 UIViewController
,接下来传给 UIViewController
的根视图。这个过程是一条龙服务,没有分叉。但是在传递给当前 UIViewController
的根视图之后,就是开发人员的主战场,视图的层级结构就可以变得错综复杂起来了。
回到开头的问题,我现在变成了一台手机📱,并且我知道有人触摸了屏幕。我所拥有的信息是触摸点的坐标,我知道应该就是视图层级中其中的某一个,但我无法直接知道用户是想点哪个视图。我需要一个策略来找到这个第一响应者,
UIKit
为我们提供了命中测试(hit-testing
)来确定触摸事件的响应者,这个策略具体是这样运作的:
命中测试
关于图中还有一些细节需要先说明:
- 在
检查自身可否接收事件
中,如果视图符合以下三个条件中的任一个,都会无法接收事件:
1.view.isUserInteractionEnabled = false
2.view.alpha <= 0.01
3.view.isHidden = true
-
检查坐标是否在自身内部
这个过程使用了func point(inside point: CGPoint, with event: UIEvent?) -> Bool
方法来判断坐标是否在自身内部,该方法是可以被重写的。 -
从后往前遍历子视图重复执行
指的是按照 FILO 的原则,将其所有子视图按照「后添加的先遍历」的规则进行命中测试。该规则保证了系统会优先测试视图层级树中最后添加的视图,如果视图之间有重叠,该视图也是同级视图中展示最完整的视图,即用户最可能想要点的那个视图。 - 在
按顺序看看平级的兄弟视图
时,若发现已经没有未检查过的视图了,则应走向 诶?没有子视图符合要求?。
下面我们举个例子来解释这个流程,在例子中我们从当前 UIViewController
的根视图开始执行这个流程。下图中灰色视图 A 可以看作是当前 UIViewController
的根视图,右侧表示了各个视图的层级结构,用户在屏幕上的触摸点是🌟处,并且这 5 个视图都可以正常的接收事件。⚠️并且注意,D 比 B 更晚添加到 A 上。
具体的流程如下:
- 首先对 A 进行命中测试,显然🌟是在 A 内部的,按照流程接下来检查 A 是否有子视图。
- 我们发现 A 有两个子视图,那我们就需要按
FILO
原则遍历子视图,先对 D 进行命中测试,后对 B 进行命中测试。 - 我们对 D 进行命中测试,我们发现🌟不在 D 的内部,那就说明 D 及其子视图一定不是第一响应者。
- 按顺序接下来对 B 进行命中测试,我们发现🌟在 B 的内部,按照流程接下来检查 B 是否有子视图。
- 我们发现 B 有一个子视图 C,所以需要对 C 进行命中测试。
- 显然🌟不在 C 的内部,这时我们得到的信息是:触摸点在 B 的内部,但不在 B 的任一子视图内。
- 得到结论:B 是第一响应者,并且结束命中测试。
- 整个命中测试的走向是这样的:A✅ --> D❎ --> B✅ --> C❎ >>>> B
整个流程应该算是清晰明了🐶,实际上这个流程就是UIView
的一个方法:func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
,方法最后返回的UIView?
即第一响应者,这个方法代码还原应该是这样的
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 // 此处指触摸点是否不在该视图内部
}
}
小心越界!
针对这个流程举个额外的例子,如果按下图的视图层级和触摸点来判断的话,最终获得第一响应者仍然是 B,甚至整个命中测试的走向和之前是一样的:A✅ --> D❎ --> B✅ --> C❎ >>>> B,究其原因是在 D 检查触摸点是否在自身内部时,答案是否,所以不会去对 E 进行命中测试,即使看起来我们点了 E。这个例子告诉我们,要注意可点击的子视图是否会超出父视图的范围。另若有这种情况可以重写 func point(inside point: CGPoint, with event: UIEvent?) -> Bool
方法来扩大点击有效范围。对于这种处理方式,个人觉得是可以,但没必要,寻求合理的视图布局和清晰易读的代码比这个关键💪
那么如果要响应的视图超过了父视图的范围,会有什么影响呢
- 不能确定出正确的响应者,像上面的例子 本来响应者应该是E,但是检测出来的是B
- 由于确定不了响应者 那么控件对应的响应方法就不会响应 出现点击一个UIButton但是没反应的情况
那么解决办法就是扩大 点击范围
- 由于命中检测中是先判断
point
是否在父视图里,若不在则不会去检测子视图,那么可以重写父视图的hitTest
方法,先去检测子视图,如果都不在子视图里面 再去执行父视图的point
方法
class testBtn: UIButton {
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() {
// let convertedPoint = subview.convert(point, from: self)
// let resultView = subview.hitTest(convertedPoint, with: event)
// // ⬆️这句是判断触摸点是否在子视图内部,在就返回视图,不在就返回nil
// if resultView != nil {
// print(resultView!)
// return resultView
// }
// }
// return self
// }
for subview in subviews.reversed() {
let convertedPoint = subview.convert(point, from: self)
let resultView = subview.hitTest(convertedPoint, with: event)
// ⬆️这句是判断触摸点是否在子视图内部,在就返回视图,不在就返回nil
if resultView != nil {
print(resultView!)
return resultView
}
}
if self.point(inside: point, with: event) {
return self
}
return nil
}
}
- 或者去重写父视图的point方法
通过响应链传递事件
在找到了第一响应者之后,整个响应链也随着确定下来了。所谓响应链是由响应者组成的一个链表,链表的头是第一响应者,链表的每个结点的下一个结点都是该结点的 next
属性。
其实响应链就是在命中测试中,走通的路径。用上个章节的例子,整个命中测试的走向是:A✅ --> D❎ --> B✅ --> C❎,我们把没走通的❎的去掉,以第一响应者 B 作为头,依次连接,响应链就是:B -> A。(实际上 A 后面还有控制器等,但在该例子中没有展示控制器等,所以就写到 A)
默认来说,若该结点是UIView
类型的话,这个 next
属性是该结点的父视图。但也有几个例外:
如果是
UIViewController
的根视图,则下一个响应者是UIViewController
。-
如果是
UIViewController
- 如果
UIViewController
的视图是UIWindow
的根视图,则下一个响应者是UIWindow
对象。 - 如果
UIViewController
是由另一个UIViewController
呈现的,则下一个响应者是第二个UIViewController
。
- 如果
-
UIWindow
的下一个响应者是UIApplication
。 -
UIApplication
的下一个响应者是app delegate
。但仅当该app delegate
是UIResponder
的实例且不是UIView
、UIViewController
或app
对象本身时,才是下一个响应者。
下面举个例子来说明。如下图所示,触摸点是🌟,那根据命中测试,B 就成为了第一响应者。由于 C 是 B 的父视图、A 是 C 的父视图、同时 A 是 Controller 的根视图,那么按照规则,响应链就是这样的:
视图 B
->视图 C
-> 根视图 A
-> UIViewController 对象
-> UIWindow 对象
->UIApplication 对象
-> App Delegate
沿响应链传递事件
触摸事件首先将会由第一响应者响应,触发其 open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
等方法,根据触摸的方式不同(如拖动,双指),具体的方法和过程也不一样。若第一响应者在这个方法中不处理这个事件,则会传递给响应链中的下一个响应者触发该方法处理,若下一个也不处理,则以此类推传递下去。若到最后还没有人响应,则会被丢弃(比如一个误触)。 我们可以创建一个 UIView
的子类,并加入一些打印函数,来观察响应链具体的工作流程。
class TouchesExampleView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("Touches Began on " + colorBlock)
super.touchesBegan(touches, with: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
print("Touches Moved on " + colorBlock)
super.touchesMoved(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
print("Touches Ended on " + colorBlock)
super.touchesEnded(touches, with: event)
}
}
下面我们举一个例子。如下图,A B C 都是 UIView
,我们将手指按照🌟的位置和箭头的方向在屏幕上移动一段距离,然后松开手。我们应该能在控制台看到下右图的输出。我们可以看到,A B C 三个视图都积极的响应了每一次事件,每次触摸的发生后,都会先触发 B 的响应方法,然后传递给 C,在传递给 A。但是这种「积极」的响应其实意味着在我们这个例子中,A B C 都不是这个触摸事件的合适接受者。他们之所以「积极」的将事件传递下去,是因为他们查看了这个事件的信息之后,认为自己并不是这个事件的合适处理者。(当然了,我们这边放的是三个 UIView
,他们本身确实也不应该能处理事件)
那么如果我们把上图中的 C 换成平时使用的 UIControl
类,控制台又会怎么打印呢?如右下图所示,会发现响应链的事件传递到 C 处就停止了,也就是 A 的 touches
方法没有被触发。这意味着在响应链中,UIControl
及其子类默认来说,是不会将事件传递下去的。在代码中,可以理解为 UIView
默认会在其touches
方法中去调用其 next
的 touches
方法,而 UIControl
默认不会去调用。这样就做到了,当某个控件接受了事件之后,事件的传递就会终止。另外,UIScrollView
也是这样的工作机制。
UIControl 接收信息的机制是 target-action 机制
总结——建立在不使用UIGestureRecognizer
的基础上的
总的来说,触摸屏幕后事件的传递可以分为以下几个步骤:
- 通过「命中测试」来找到「第一响应者」
- 由「第一响应者」来确定「响应链」
- 将事件沿「响应链」传递
- 事件被某个响应者接收,或没有响应者接收从而被丢弃
如果使用了UIGestureRecognizer
在上文介绍了当屏幕上发生一次触摸之后,系统会如何寻找「第一响应者」
,在寻找到「第一响应者」
之后,如何确定「响应链」
以及如何沿「响应链」
传递事件。在上一篇文章的环境中,是不使用 UIGestureRecognizer
的。但是在我们平时的开发中想要给一个 UIView
加上处理事件的能力的话,使用UIGestureRecognizer
及其子类比继承一个UIView
的类、重写touches
方法要方便的很多。这两种方法对事件的处理机制相互影响又有所不同。这也是下文的讨论内容:通过响应链及手势识别处理事件。
首先我们先回顾一下事件传递及响应链的大致流程:
1.通过「命中测试」来找到「第一响应者」
2.由「第一响应者」来确定「响应链」
3.将事件沿「响应链」传递
4.事件被某个响应者接收,或没有响应者接收从而被丢弃
在步骤 3 中,事件沿「响应链」
传递这个过程,就是响应者通过调用其 next
的touches
系列方法来实现的。在上篇文章中我们也提到,假如我们使用 UIControl
等类作为响应者,这些类本身就不会调用其 next
的touches
系列方法,从而实现阻断响应链的效果,也可以认为是实现接受某个事件的效果。那在下文中,我们将浅析在有 UIGestureRecognizer
参与的情况下,事件的处理和接收是如何运作的。
当手势识别参与响应链
上文中,我们只讨论了下图中蓝色部分事件沿响应链传递的流程,但实际上,同时一起发生的还有图中下半部分手势识别的部分。
从图中我们可以看到,在通过命中测试找到第一响应者之后,会将
UITouch
分发给UIResponder
的 touches
系列方法(具体方法见上篇文章),同时也会分发给手势识别系统,让这两个处理系统同时工作。
首先要注意的是,上图中蓝色部分的流程并不会只执行一次,举例来说:当我们用一根手指在一个视图上缓慢滑动时,会产生一个 UITouch
对象,这个 UITouch
对象会随着你手指的滑动,不断的更新自身,同时也不断地触发 touches
系列方法。一般来说,我们会得到如下类似的触发顺序:
touchesBegan // 手指触摸屏幕
touchesMoved // 手指在屏幕上移动
touchesMoved // ...
...
touchesMoved // ...
touchesMoved // 手指在屏幕上移动
touchesEnded // 手指离开屏幕
UITouch
的 gestureRecognizers
属性中的存储了在寻找第一响应者的过程中收集到的手势,而在不断触发 touches
系列方法的过程中,手势识别系统也在在不停的判断当前这个 UITouch
是否符合收集到的某个手势。
当手势识别成功: 被触摸的那个视图,也就是第一响应者会收到 touchesCancelled
的消息,并且该视图不会再收到来自该 UITouch
的 touches
事件。同时也让该 UITouch
关联的其他手势也收到 touchesCancelled
,并且之后不再收到此 UITouch
的 touches
事件。这样做就实现了该识别到的手势能够独占该 UITouch
。具体表现参考如下
touchesBegan // 手指触摸屏幕
touchesMoved // 手指在屏幕上移动
touchesMoved // ...
...
touchesMoved // ...
touchesMoved // 手指在屏幕上移动
touchesCancelled // 手势识别成功,touches 系列方法被阻断
// 现在手指💅并没有离开屏幕
// 但如果继续滑动🛹的话
// 并不会触发 touches 系列方法
当手势识别未成功: 指暂时未识别出来,不代表以后不会识别成功,不会阻断响应链。注意这里指的是未成功,并不一定是失败。在手势的内部状态中,手势大部分情况下状态是 .possible
,指的是UITouch
暂时与其不匹配,但之后可能有机会识别成功。而 .fail
是真的识别失败,指的是以目前的触摸情况来看已经不可能是这个手势了,并且在下个runloop
会从 gestureRecognizers
中移除该手势。
举个🌰
下面举个简单的例子模拟一下响应链和手势的相互影响。现在用一根手指,在一个视图上触摸并滑动一段距离。下图给出了视图不带手势的情况,和带一个 UIPanGestureRecognizer
手势的情况。
从图中我们可以看到,当不带手势的情况下,手指按下去的时候,响应者的 touchBegan
方法会触发,随着手指的移动,touchMoved
会不断触发,当手指结束移动并抬起来的时候,touchEnded
会触发。在这个过程中,我们接收到一直是一个不断更新的 UITouch
。
在该视图有添加一个UIPanGestureRecognizer
手势的情况下,我们多了下方这一条来表示与响应链同时工作的手势识别系统,可以看到手势识别系统也是在手指按下去那一刻就开始工作的,前半段处于一直正在识别的状态。在我们拖动了很小一段距离之后(注意这时候我们的手指还没抬起), 手势识别系统确定了该 UITouch
所做的动作是符合UIPanGestureRecognizer
的特点的,于是给该视图的响应链发送了touchCancelled
的信息,从而阻止这个 UITouch
继续触发这个视图的 touches 系列方法(同时也取消了别的相关手势的touches
系列方法,图中未体现)。在这之后,被调用的只有与手势关联的 target-action
方法(也就是图中的墨绿色节点 call PanFunction
)。
再进一步理解
为了图片的美观和易读,在图片中我隐去了不少细节,在此列出:
1.手势识别器的状态在图中未标出:
手势在图中
recognizing
的橙色节点处和recognized
棕色节点处都处于.possible
状态
手势在图中绿色节点处的状态变化是.began -> [.changed] -> ended
- 手势识别器不是响应者,但也有
touches
系列方法,比它所添加的视图的touches
方法更早那么一点触发
从图中也可以看出,手势那条线上的每个节点都稍靠左一些
手势那条线上的橙、棕、墨绿色节点处也可以看做手势识别器的touches
方法触发
- 更详细的触发顺序应当如下图所示(在一个
UIView
上添加了UIPanGestureRecognizer
,并单指在上面滑动一段距离的情况)
更多选择🦾
1.我们可以通过配置手势的属性来改变它的表现,下面介绍三个常用的属性:
cancelsTouchesInView:
该属性默认是 true
。顾名思义,如果设置成 false
,当手势识别成功时,将不会发送touchesCancelled
给目标视图,从而也不会打断视图本身方法的触发,最后的结果是手势和本身方法同时触发。有的时候我们不希望手势覆盖掉视图本身的方法,就可以更改这个属性来达到效果。
2.delaysTouchesBegan:
该属性默认是false
。在上个例子中我们得知,在手指触摸屏幕之后,手势处于.possible
状态时,视图的touches
方法已经开始触发了,当手势识别成功之后,才会取消视图的 touches
方法。当该属性时true
时,视图的 touches
方法会被延迟到手势识别成功或者失败之后才开始。也就是说,假如设置该属性为 true
,在整个过程中识别手势又是成功的话,视图的touches
系列方法将不会被触发。
3.delaysTouchesEnded:
默认为YES。这种情况下发生一个touch
时,在手势识别成功后,发送给touchesCancelled
消息给hit-testview
,手势识别失败时,会延迟大概0.15ms
,期间没有接收到别的touch
才会发送touchesEnded
。如果设置为NO
,则不会延迟,即会立即发送touchesEnded
以结束当前触摸
UIControl 与手势识别
UIController 带有控制类控件的父类
由于 UIControl
接收target-action
方法的方式是在其touches
方法中识别、接收、处理,而手势的touches
方法一定比其所在视图的 touches
方法早触发。再根据上文的描述的触发规则,可以得到的结论是:对于自定义的UIControl
来说,手势识别的优先级比UIControl
自身处理事件的优先级高。
`
举个例子来说:当我们给一个 UIControl
添加了一个 .touchupInside
的方法,又添加了一个UITapGestureRecognizer
之后。点击这个 UIControl
,会看到与手势关联的方法触发了,并且给 UIControl
发送了touchCancelled
,导致其自身的处理时间机制被中断,从而也没能触发那个 .touchupInside
的方法。
同时这样的机制可能会导致一个问题:当我们给一个已经拥有点击手势的视图,添加一个 UIControl
作为子视图,那么我们无论怎么给该 UIControl
添加点击类型的 target-action
方法,最后的结果都是触发其父视图的手势(因为在命中测试的过程中收集到了这个手势),并且中断 UIControl
的事件处理,导致添加的 target-action
方法永远无法触发。
UITouch
在寻找第一响应者的时候,会把整条响应链上的手势收集在自身的 gestureRecognizers 数组中,当找到第一响应者之后,在每次第一响应者触发 touches 方法之前,会先触发 UITouch 手势数组里手势的 touches 方法
那其实🍎已经给我们做了一个解决方案,UIKit
对部分控件(同时也是 UIControl
的子类)做了特殊处理,当这些控件的父视图上有与该控件冲突功能的手势时,会优先触发控件自身的方法,不会触发其父视图上的那个手势。
也举个例子来说:当我们给一个已经拥有点击手势的视图,添加一个 UIButton
作为子视图,并且给按钮添加点击类型的 target-action
方法,那么当点击按钮时,按钮的 target-action
方法会触发,手势的方法会被忽略。
并且文档中也提到了,如果不想要这种情况发生,那就应当把手势添加到目标控件上(因为手势比控件更早识别到事件,也就是上文提到的给 UIControl
添加了.touchupInside
方法的例子),这样的话生效的就是手势了。
总结
总的来说,手势识别器在大多数情况下,识别屏幕触摸事件的优先级,比控件本身的方法的优先级高。
所以在开发的过程中,注意不要让手势覆盖控件本身的方法实现。同时也要理解默认情况下,手势识别在一开始实际上并不会阻止控件自身的touches
系列方法,而是在之后的某个时机去取消。另外在 UIKit
中,也对部分情况做了特殊处理,让UIKit
控件有机会跳过父视图的手势识别,去获得事件的控制权。
参考博客
https://juejin.cn/post/6894518925514997767
https://juejin.cn/post/6905914367171100680
https://www.jianshu.com/p/a8c04c70212f