iOS中事件的响应链和传递链
iOS中事件的响应链和传递链
决明在 iOS 中,只有继承了 UIResponder
(响应者)类的对象才能接收并处理事件。其公共子类包括 UIView
、UIViewController
和 UIApplication
。UIResponder
类中提供了以下 4 个对象方法来处理触摸事件:
/// 触摸开始 |
注意:
- 如果手指同时触摸屏幕,
touches(_:with:)
方法只会调用一次,Set<UITouch>
包含两个对象;- 如果手指前后触摸屏幕,
touches(_:with:)
会依次调用,且每次调用时Set<UITouch>
只有一个对象。
iOS 中的事件传递
事件传递和响应的整个流程
- 触发事件后,系统会将该事件加入到一个由
UIApplication
管理的事件队列中; UIApplication
会从事件队列中取出最前面的事件,将之分发出去以便处理,通常,先发送事件给应用程序的主窗口(keyWindow
);- 主窗口会在视图层次结构中找到一个最适合的视图来处理触摸事件;
- 找到适合的视图控件后,就会调用该视图控件的
touches(_:with:)
方法; touches(_:with:)
的默认实现是将事件顺着响应者链(后面会说)一直传递下去,直到连UIApplication
对象也不能响应事件,则将其丢弃。
如何寻找最适合的控件来处理事件
当事件触发后,系统会调用控件的 hitTest(_:with:)
方法来遍历视图的层次结构,以确定哪个子视图应该接收触摸事件,过程如下:
- 调用自己的
hitTest(_:with:)
方法; - 判断自己能否触发事件、是否隐藏、alpha <= 0.01;
- 调用
point(inside:with:)
来判断触摸点是否在自己身上; - 倒序遍历
subviews
,并重复前面三个步骤。直到找到包含触摸点的最上层视图,并返回这个视图,那么该视图就是那个最适合的处理事件的view;
- 如果没有符合条件的子控件,就认为自己最适合处理事件,也就是自己是最适合的
view;
通俗一点来解释就是,其实系统也无法决定应该让哪个视图处理事件,那么就用遍历的方式,依次找到包含触摸点所在的最上层视图,则认为该视图最适合处理事件。
注意:
触摸事件传递的过程是从父控件传递到子控件的,如果父控件也不能接收事件,那么子控件就不可能接收事件。
寻找最适合的的 view 的底层剖析
hitTest(_:with:)
的调用时机- 事件开始产生时会调用;
- 只要事件传递给一个控件,就会调用这个控件的
hitTest(_:with:)
方法(不管这个控件能否处理事件或触摸点是否自己身上)。
hitTest(_:with:)
的作用- 返回一个最适合的 view 来处理触摸事件。
注意:
如果
hitTest(_:with:)
方法中返回nil
,那么该控件本身和其subview
都不是最适合的 view,而是该控件的父控件。
在默认的实现中,如果确定最终父控件是最适合的 view,那么仍然会调用其子控件的hitTest(_:with:)
方法(不然怎么知道有没有更适合的 view?参考 如何寻找最适合的控件来处理事件。)
hitTest(_:with:)
的默认实现
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { |
iOS 中的事件响应
找到最适合的 view 接收事件后,如果不重写实现该 view 的 touches(_:with:)
方法,那么这些方法的默认实现是将事件顺着响应者链向下传递, 将事件交给下一个响应者去处理。
可以说,响应者链是由多个响应者对象链接起来的链条。UIResponder
的一个对象属性 next
能够很好的解释这一规则。
UIResponder().next
返回响应者链中的下一个响应者,如果没有下一个响应者,则返回 nil
。
例如,UIView
调用此属性会返回管理它的 UIViewController
对象(如果有),没有则返回它的 superview
;UIViewController
调用此属性会返回其视图的 superview
;UIWindow
返回应用程序对象;共享的 UIApplication
对象则通常返回 nil
。
例如,我们可以通过 UIView
的 next
属性找到它所在的控制器:
extension UIView { |
实践篇
示意图说明:白色 view 是蓝色 view 的父视图;蓝色 view 是橙色 view 的父视图。
需求一:点击重叠区,只有蓝色 view(既父视图)响应事件。
一个最简单的办法是将子视图的
isUserInteractionEnabled
设置为 false ;也可以在子视图的hitTest(_:with:)
方法里面返回nil
或superview
,可以达到同样的效果。
需求二:点击屏幕上的任意地方;只有蓝色 view 响应事件。
一个最简单的办法是在蓝色
view
的hitTest(_:with:)
方法里返回self
。当事件传递到蓝色view
时,返回自己做为最适合触发事件的控件。
需求三:点击橙色 view 的任意地方,蓝色 view(既父视图)响应事件。
难点在于点击非重叠区时,蓝色 view 不能接收到事件。为什么会出现这种情况呢?回顾一下 “原理篇 - 如何寻找最适合的控件来处理事件” 就会发现,一个控件想要接收事件需要满足两个条件:
- 判断自己能否触发事件;
- 判断触摸点是否在自己身上(
point(inside:with:)
)。
根据第二点,我们在点击非重叠区时,触摸点不在自己(蓝色 view)身上,因此不能够接收事件。
再回顾一下这一节的要点:触摸事件传递的过程是从父控件传递到子控件的,如果父控件也不能接收事件,那么子控件就不可能接收事件。
那应该怎么做呢?关键还是在第二点上(判断触摸点是否在自己身上),这个方法返回的是一个Bool
类型的值,换句话说,无论点是否在自己身上,只要让这个方法返回true
,就可以让蓝色 view 接收事件。/// BlueView.swift
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
// 首先正常返回,
// 如果点不在自己身上,则判断点是否在橙色 view 身上。
// 注:此时的 subviews.first 代表橙色 view。
return super.point(inside: point, with: event) || subviews.first!.frame.contains(point)
}这样做是可以的,也最简单。但有一个问题,那就是如果橙色 view 也实现了
touches(_:with:)
,这时候是橙色 view 触发事件而不是蓝色 view。为什么呢?
因为只要判断符合了条件,事件就会传递到橙色 view,而触摸点正好在橙色 view 身上,因此是橙色 view 触发了事件。
不过一般来说,有这种需求的子控件(橙色 view)都不会自己实现事件而是交给父控件(蓝色 view)去处理。所以如果不想考虑这么多的话,可以直接用上面的方法。但是如果想屏蔽掉子控件事件的触发的话,还是有办法解决的。
解决的办法就是拦截橙色 view 接收事件,只要在BlueView.swift
中重写hitTest(_:with)
方法,返回指定的 view 来做为最适合处理事件的控件就可以了。/// BlueView.swift
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hitView = super.hitTest(point, with: event)
// 如果点在橙色 view 的身上,返回自己(蓝色 view),不在则正常返回。
// 注:此时的 subviews.first 代表橙色 view。
return subviews.first!.frame.contains(point) ? self : hitView
}这样一来,事件就不会传递到橙色 view 了,只要点在橙色 view 身上,我就返回它的父视图(蓝色 view);如果不在,就正常返回(点击了蓝色 view 还是蓝色 view 触发事件;点击了白色 view 则触摸点不在蓝色 view 身上,此时白色 view 接收事件。)
需求四:点击重叠区时,橙色 view 和蓝色 view 都响应事件。
一个最简单的办法是在我们重新实现橙色 view 的touches(_:with:)
方法后,调用super.touches(_:with:)
让它继续将事件传递给下一个响应者(蓝色 view)接收并处理事件。/// OrangeView.swift
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("Orange: \(#function)")
// 继续将事件传递给下一个响应者 (此时是蓝色 view)
super.touchesBegan(touches, with: event)
}
/// BlueView.swift
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
print("Blue", #function)
}
正常响应,点击橙色 view 是橙色 view 响应事件;而点击蓝色 view 是蓝色 view 响应事件。
可以说是经常出现的需求了,有时候我们需要处理超出父视图区域的子视图事件,但是点击超出区域的部分却不能响应事件。那要怎么做呢?
其实这个问题在需求三的第一个示例中已经解决了,这里不再赘述。