当我们在设计自己的APP时,可能会想动态的响应事件.例如:屏幕上许多对象都能够发生触摸,我们必须决定哪一个对象来响应给定的事件并且知道对象是如何接受事件的。当用户事件产生的时候,UIKit会创建一个事件对象(event
object),该对象包含了事件整个过程中所拥有的信息。并且该事件对象将处于APP活跃事件列队中。对于触摸事件,对象将包含一系列UIEvent对象。对于运动事件,取决于你使用的框架和你感兴趣的运动事件的类型。
事件沿着具体的路径进行传递,一直到发现对象能够处理该事件,首先,UIApplication单例对象会将该事件将被传递给window对象,window对象会使用hitTest:withEvent:方法来递归的寻找操作初始点所在的view,该视图就称之为hit-test
view,寻找hit-test view过程称为hit-testing.
分发机制(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)。该方法首先会调用pointInside:withEvent:方法,如果hitTest:withEvent:方法中所传递的参数point点是位于视图之内,pointInside:withEvent:方法将返回true,然后,在返回true的所有子视图上将递归调用hitTest:withEvent:方法。
如果hitTest:withEvent:方法中传递的point点不在视图显示区域之内,第一次调用pointInside:withEvent:方法将返回false,那么该点将被忽略,hitTest:withEvent:方法将返回nil。如果一个子视图返回false,那么整个视图层级都将被忽略,因为触摸并不在子视图当中,所以子视图的子视图同样也不会发生触摸事件。
简单一点理解: hitTest:withEvent:方法的处理流程如下:调用当前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)的视图。如果一个子视图的区域超过父视图的bound区域(父视图的clipsToBounds属性为NO,这样超过父视图bound区域的子视图内容也会显示),那么正常情况下对子视图在父视图之外区域的触摸操作不会被识别,因为父视图的pointInside:withEvent:方法会返回NO,这样就不会继续向下遍历子视图了。当然,也可以重写pointInside:withEvent:方法来处理这种情况。
hit-test view将首先处理触摸事件,如果hit-test view并不能够处理事件,那么该事件将由视图的响应者链进行查找,一直到系统找到能够处理事件的对象。
响应者链由响应者组成
许多类型的事件都依赖于响应者链进行事件的传递。响应者链关联着一系列的响应者对象,由第一个响应者对象开始一直到application对象结束,如果第一个响应者不能够处理事件,事件将会被传递到响应者链中的下一个响应者对象。
一个响应者对象是能够处理和响应事件的对象,UIResponder类是所有响应者对象的基类,它不仅仅定义了事件处理的接口而且还有共有的响应者行为。UIApplication,
UIViewController, UIView等类的实例都是响应者对象,这意味着所有的视图(all views)和大多数的关键视图控制器对象都是响应者。但是要注意核心动画中的层(layer)不是响应者。
第一响应者首先接收事件。代表性的就是:视图是第一响应者对象。一个对象要成为第一响应者需要做两件事:
重写canBecomeFirstResponder方法,返回true。
接收成为第一响应者信息,及becomeFirstResponder方法,如果有必要,对象能够自己给自己发送信息。
注意:在对象被赋值成为第一响应者之前,确保APP已经建立的对象图形(object
graph)。例如:我们可以在viewDidAppear:方法中调用becomeFirstResponder方法,但是,如果我们尝试viewWillAppear:中赋值第一响应者,我们的对象图形可能还没有建立,所以becomeFirstResponder方法将返回false。
响应者链遵守事件传递的具体路径
如果最初的对象,hit-test视图或者第一响应者(first
responder)不能够处理事件,UIKit将传递事件到响应者链中的下一个响应者。每一个响应者都会决定是否处理事件还是调用nextResponder方法将事件传递给下级响应者。该过程一直到有一个响应者对象能够处理事件或者没有下级响应者为止。
下图显示了两个APP配置下2种不同事件类型的路径传递,事件传递路径取决于具体的结构,所有的事件传递都遵守相同的起始。
Figure 2-2 The
responder chain on iOS
左边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,其他流程是一样的。
开发中覆盖hitTest:withEvent:的一些用途:
1:增加视图的触摸区域
比如:按钮本身大小为20*20,但是太小不便操作,我们可以通过自定义UIButton,重写hitTest方法,增加点击区域:下面是添加了一个20*20的按钮,然后通过操作hitTest方法,实现100*100区域内可点击,即在每个方向增加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!"); }
参考文章:
http://smnh.me/hit-testing-in-ios/
http://blog.csdn.net/jiajiayouba/article/details/23447145