UIResponder(响应对象)
An abstract interface for responding to and handling events.
一个UIResponder
类为那些需要响应并处理事件的对象定义了一组接口。在iOS
中不是任何对象都能处理事件, 只有继承了UIResponder
的对象才能接收并处理事件,称为响应者对象。UIApplication
,UIViewController
,UIView
都继承自UIResponder
,因此他们都是响应者对象,,都能够接收并处理事件。这意味着所有的视图(all views)和大多数的关键视图控制器对象都是响应者。但是要注意核心动画中的层(layer)不是响应者。
继承自UIResponder
的类能处理事件是由于UIResponder
内部提供了以下方法:
// 触摸事件
- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet *)touches NS_AVAILABLE_IOS(9_1);
// 按压事件
- (void)pressesBegan:(NSSet *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
// 传感器事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
// 远程控制事件
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);
分发机制(Hit-Testing)
iOS
使用hit-testing
来找到触摸点所在的视图。hit-testing
将会检测是否触摸事件在相关视图的显示区域之内。如果在,将递归检测当前视图的所有子视图。视图层级中最底层的view
如果包含触摸点将成为hit-test view
。在iOS
确认了hit-test view
之后,将传递触摸事件给对应的视图进行处理。
为了解释上面,下面看一下官方例子:用户触摸视图view E
,iOS
将有序查找子视图,找到hit-test view
1:由下图可知,触摸位于视图A的区域之内,所以会对B,C进行检测
2:如果触摸事件不在视图B区域中,但位于视图区域C,将对视图C的子视图D,E进行检测
3:如果触摸事件不在视图D区域中,但位于视图区域E中,又因为视图E是视图层级结构中最底层的视图,所以视图E将成为hit-test view
Hit-testing returns the subview that was touched
hitTest:withEvent:
方法会根据给定触摸点(CGPoint)
和事件对象(UIEvent)
两个参数,返回点击的视图(hit test view)
。
1、该方法首先会调用pointInside:withEvent:
方法,如果hitTest:withEvent:
方法中所传递的参数point
点是位于视图之内,pointInside:withEvent:
方法将返回true,然后,在返回true
的所有子视图上将递归调用hitTest:withEvent:
方法。
2、如果hitTest:withEvent:
方法中传递的point
点不在视图显示区域之内,第一次调用pointInside:withEvent:
方法将返回false
,那么该点将被忽略,hitTest:withEvent:
方法将返回nil
。如果一个子视图返回false
,那么整个视图层级都将被忽略,因为触摸并不在子视图当中,所以子视图的子视图同样也不会发生触摸事件。
总结hitTest处理流程
调用当前view
的pointInside:withEvent:
方法来判定触摸点是否在当前view
内部,如果返回false
,则hitTest:withEvent:
返回nil
;如果返回true
,则向当前view
内的subViews
发送hitTest:withEvent:
消息,所有subView
的遍历顺序是从数组的末尾向前遍历,直到有subView
返回非空对象或遍历完成。如果有subView
返回非空对象,hitTest
方法会返回这个对象,如果每个subView
返回都是nil
,则返回自己。
注意:
hitTest:withEvent:方法忽略隐藏(hidden=YES)的视图,禁止用户操作(userInteractionEnabled=YES)的视图,以及alpha级别小于0.01(alpha<0.01)的视图。
hit-test view
将首先处理触摸事件,如果hit-test view
并不能够处理事件,那么该事件将由视图的响应者链进行查找,一直到系统找到能够处理事件的对象。
事件响应者链
许多类型的事件都依赖于响应者链进行事件的传递。响应者链关联着一系列的响应者对象,由第一个响应者对象开始一直到application
对象结束,如果第一个响应者不能够处理事件,事件将会被传递到响应者链中的下一个响应者对象。
第一响应者首先接收事件。代表性的就是:视图是第一响应者对象。一个对象要成为第一响应者需要做两件事:
- 1、重写
canBecomeFirstResponder
方法,返回true
,接收成为第一响应者信息 - 2、以及
becomeFirstResponder
方法,如果有必要,对象能够自己给自己发送信息
注意:
在对象被赋值成为第一响应者之前,确保APP已经建立的对象图形(object graph)。例如:我们可以在viewDidAppear:方法中调用becomeFirstResponder方法,但是,如果我们尝试viewWillAppear:中赋值第一响应者,我们的对象图形可能还没有建立,所以becomeFirstResponder方法将返回false。
响应者链遵守事件传递的具体路径
如果最初的对象hit-test
视图或者第一响应者(first responder)不能够处理事件,UIKit将传递事件到响应者链中的下一个响应者。每一个响应者都会决定是否处理事件还是调用nextResponder
方法将事件传递给下级响应者。该过程一直到有一个响应者对象能够处理事件或者没有下级响应者为止。
下图显示了两个APP配置下2种不同事件类型的路径传递,事件传递路径取决于具体的结构,所有的事件传递都遵守相同的起始。
左边App事件所传递的路径
1、初始视图(initial view)
将尝试着处理事件或消息。如果它不能处理事件,将传递事件到自己的父视图(superview)
,因为初始视图并不是它所在视图控制器中视图层级的最顶部视图
2、父视图(superview)
将尝试处理所传递的事件,如果父视图不能够处理事件,该事件将传递到它自己的父视图,因为它仍然不是视图层级的最顶部视图
3、视图控制器视图层级中最顶部视图(topmost view)将尝试处理所传递的事件,如果最顶部视图不能够处理事件,它将传递事件给它的视图控制器
4、视图控制器(view controller)
将尝试处理所传递事件,如果它不能够处理事件,该事件将被传递到window
5、如果window
对象不能够处理事件,它将传递事件到APP
全局单例对象(singleton app object).
6、如果app
对象不能够处理事件,该事件将被放弃
右边App事件的传递流程
1、视图传递事件到它所在的视图控制器的视图层级中,一直到最顶部视图。
2、最顶部视图将传递事件到它的视图控制器
3、视图控制器将传递事件到它的最顶部视图的父视图。1~3步重复,直到找到根控制器
4、根视图控制器将传递事件到window
对象
5、window
对象将传递事件到app
对象
唯一不同就在于,如果当前的ViewController
是有层级关系的,那么当子ViewController
不能处理事件时,它会将事件继续往上传递,直到传递到其Root ViewController
,其他流程是一样的。
事件的传递和响应链
传递链
由系统向离用户最近的view
传递 UIKit –> active app’s event queue –> window –> root view –>……–>lowest view
响应链
由离用户最近的view
向系统传递。initial view –> super view –> …..–> view controller –> window –> Application
hitTest:withEvent:的使用
- 1、增加视图的触摸区域
比如:按钮本身大小为20和20,由于太小不方便操作,所以可以通过自定义UIButton
,重写hitTest
方法,增加点击区域。下面实现每个方向增加40的可点击区域,具体实现代码:
class MyButton: UIButton {
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
if !self.userInteractionEnabled || self.hidden || self.alpha == 0 {
return nil
}
// 扩大点击区域
if CGRectContainsPoint(CGRectInset(self.bounds, -40, -40), point) {
for subview in self.subviews.reverse() {
let convertPoint = subview.convertPoint(point, fromView:self)
if let sview = subview.hitTest(convertPoint, withEvent: event) {
return sview
}
}
return self
}
return nil
}
}
hitTest:withEvent:
方法首先检查视图是否允许接收触摸事件。视图允许接收触摸事件的条件是:
- 视图不是隐藏的:
self.hidden == NO
- 视图是允许交互的:
self.userInteractionEnabled ==true
- 视图透明度大于:
0.01:self.alpha >0.01
- 视图包含这个点:
pointInside:withEvent: ==true
如果视图允许接收触摸事件,这个方法通过从后往前发送hitTest:withEvent:
消息给每一个子视图来穿过接收者的子树,直到子视图中的一个返回nil。这些子视图中的第一个返回的非nil就是在触摸点下面的最前面的视图,被接收者返回。如果所有的子视图都返回nil或者接收者没有子视图,那么返回接收者自己。否则,如果视图不允许接收触摸事件,这个方法返回nil而根本不会传递到接收者的子树。因此,hit-test可能不会访问所有的视图体系结构中的视图。
测试功能:
func testExpandButtonClickArea(){
//为了便于观察,添加一个背景视图,大小正好为100*100
let backgroundView = UIView(frame: CGRect(x: 60, y: 160, width: 100, height: 100))
backgroundView.backgroundColor = UIColor.purpleColor()
view.addSubview(backgroundView)
let btn = MyButton(type: .Custom)
btn.frame = CGRect(x: 100, y: 200, width: 20, height: 20)
btn.backgroundColor = UIColor.redColor()
btn.setTitle("btn", forState: .Normal)
btn.addTarget(self, action: #selector(UIButtonViewController.tapButton), forControlEvents: .TouchUpInside)
view.addSubview(btn)
}
func tapButton(){
print("button has been pressed!");
}
点击紫色区域内容,同样可以响应点击事件,可以在console看到打印输出:button has been pressed!
- 2、实现传递事件到点击视图之下的视图
有的时候对于一个视图忽略触摸事件并传递给下面的视图是很重要的。例如,假设一个透明的视图覆盖在应用内所有视图的最上面。覆盖层有子视图应该相应触摸事件的一些控件和按钮。但是触摸覆盖层的其他区域应该传递给覆盖层下面的视图。为了完成这个行为,覆盖层需要覆盖hitTest:withEvent:
方法来返回包含触摸点的子视图中的一个,然后其他情况返回nil,包括覆盖层包含触摸点的情况:
class SHView: UIView {
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
var hitTestView = super.hitTest(point, withEvent:event)
if hitTestView == self{
hitTestView = nil
}
return hitTestView;
}
}
测试部分代码:
func testCoverView(){
let btn1 = UIButton(type: .Custom)
btn1.frame = CGRect(x: 80, y: 200, width: 20, height: 20)
btn1.backgroundColor = UIColor.redColor()
btn1.setTitle("btn1", forState: .Normal)
btn1.addTarget(self, action: #selector(OverSuperViewController.tapButton(_:)), forControlEvents: .TouchUpInside)
view.addSubview(btn1)
let btn2 = UIButton(type: .Custom)
btn2.frame = CGRect(x: 120, y: 200, width: 20, height: 20)
btn2.backgroundColor = UIColor.yellowColor()
btn2.setTitle("btn2", forState: .Normal)
btn2.addTarget(self, action: #selector(OverSuperViewController.tapButton(_:)), forControlEvents: .TouchUpInside)
view.addSubview(btn2)
//添加一个覆盖层
let backgroundView = SHView(frame: CGRect(x: 60, y: 160, width: 100, height: 100))
backgroundView.backgroundColor = UIColor.purpleColor()
backgroundView.alpha = 0.75;
view.addSubview(backgroundView)
}
func tapButton(button:UIButton){
print("button = %@,title = %@",button,button.currentTitle);
}
当点击覆盖层的时候,如果点击的位置属于对应的按钮的区域,将响应对应的触发事件,点击btn1将打印按钮1的相关信息,点击按钮2将打印按钮2的相关信息。页面效果如下:
- 3、超出父视图区域部分响应事件
首先看一下页面效果:当前页面上有3个控件,紫色视图是红色视图的子视图,红色视图是灰色视图的子视图。最上面是一个按钮,方便我们进行测试:现在我们要实现点击红色视图之外的紫色区域能够响应事件。
实现代码:自定义TestView
实现hitTest
方法,并调用我们对UIView
的扩展方法
class TestView: UIView {
override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
super.hitTest(point, withEvent: event)
return overlapHitTest(point, withEvent: event)
}
}
extension UIView{
func overlapHitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
// We should not send touch events for hidden or transparent views, or views with userInteractionEnabled set to NO;
if !self.userInteractionEnabled || self.hidden || self.alpha == 0 {
return nil
}
// If touch is inside self, self will be considered as potential result.
var hitView: UIView? = self
if !self.pointInside(point, withEvent: event) {
if self.clipsToBounds {
return nil
} else {
hitView = nil
}
}
// Check recursively all subviews for hit. If any, return it.
for subview in self.subviews.reverse() {
let insideSubview = self.convertPoint(point, toView: subview)
if let sview = subview.overlapHitTest(insideSubview, withEvent: event) {
return sview
}
}
// Else return self or nil depending on result from step 2.
return hitView
}
}
测试部分代码:
func testOverSuperview(){
let view1 = TestView(frame:CGRect(x: 100, y: 100, width: 200, height: 200))
view1.backgroundColor = UIColor.lightGrayColor()
view.addSubview(view1)
let view2 = UIView(frame: CGRect(x: 40, y: 40, width: 100, height: 100))
view2.backgroundColor = UIColor.redColor()
view1.addSubview(view2)
let view3 = UIButton(type: .Custom)
view3.frame = (frame: CGRect(x: 10, y: 10, width: 200, height: 80))
view3.backgroundColor = UIColor.purpleColor()
view3.addTarget(self, action: #selector(ThirdViewController.tapButton), forControlEvents: .TouchUpInside)
view2.addSubview(view3)
}
func tapButton(){
print("button has been pressed!");
}
参考
UIResponder