iOS中的响应者链是指UIKit 生成的UIResponder对象组成的链表,它是iOS里一切事件相关(触摸事件、运动事件、远程控制事件、按下事件)的基础
- 触摸事件是最常见的事件,一般表示手指在屏幕上的各种操作,它会被传递给最初发生触摸的视图
- 运动事件是由UIKit触发的,例如通过加速器、陀螺仪等内置硬件触发的事件,比如摇一摇(运动事件相关的加速度计、陀螺仪、磁强计都不属于响应者链。而是由CoreMotion传递事件给你指定的对象)
- 远程控制事件允许响应对象接收来自外部附件或耳机的命令,以便它能够管理音频和视频——例如,播放视频或跳转到下一个音频轨道
- 按下事件表示与游戏控制器、AppleTV遥控器或其他具有物理按钮的设备的交互
首先在响应者链中有几个类需要了解:UIResponder、UIEvent、UITouch
- UITouch 是用于处理用户触摸交互的底层对象,每个UITouch代表当前触摸屏幕的一根手指,如果手指移动,系统会不断更新UITouch的属性,在手势发生的过程中(触摸序列中),UITouch对象会被创建,被销毁,还会改变状态。
- UIEvent代表一个事件(事件分为很多种),如果是触摸事件,UIEvent中保存着同当前触摸序列相关的UITouch对象(一个或多个)
- UIResponder是用于响应和处理事件的类,直接继承于NSObject,所有能响应用户事件的类都要继承于它,比如UIApplication、UIViewController、UIView(包括UIWindow),UIResponder类中定义了响应各种事件的接口
当我们点击了屏幕上的某个View,这个动作由硬件层传导到操作系统,然后底层会将这个动作封装成一个事件(Event)拴着view的层级网上传导,一直找到含有这个触摸点并且层级最高(相当于离手指最近)的view来响应这个事件,这个view叫做hit-test view
在这个过程中,决定最后的hit-test view是谁 是通过递归调用响应者链中每个view的-(UIView *) hitTest:withEvent:
方法和-(BOOL)pointInside:withEvent:
方法来实现的
用一个Demo来试一试, 我们自定义几个view,然后重写相关的方法:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"进入AView hitTest:withEvent:");
UIView *view = [super hitTest:point withEvent:event];
NSLog(@"离开AView hitTest:withEvent: ----view: %@",[view class]);
return view;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"进入AView pointInside:withEvent:");
BOOL isInside = [super pointInside:point withEvent:event];
NSLog(@"离开AView pointInside:withEvent: ---- isInside: %d",isInside);
return isInside;
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
NSLog(@"AView touchesBegan");
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
NSLog(@"AView touchesMoved");
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
NSLog(@"AView touchesEnded");
}
在rootController的view中添加这几个view:
此时我们点击AView,打印的内容为:
可以看到触摸事件最先传递到了AView(其实在这之前已经经过了UIWindow和rootViewController的view,只不过我们没有重写他们相应的方法),说明AView可以处理这个事件,然后判断触摸点是否在AView上,结果是ture,然后继续遍历AView的子控件,就是BView和CView,但是触摸点都不在这两个View中,所以这两个view的hitTest:withEvent:方法返回的View都为空,最后得到的结果是AView是最适合处理这个事件的View,由AView来处理这个事件
同时我们看到,系统会在决定了由哪个View来处理事件后,才会触发这个View的touches系列方法
我们再点击一次CView,此时的打印内容为:
可以看到这次点击事件经过AView之后,遍历子控件,进入了CView,此时触摸点在CView内,而CView也没有任何子视图了,所以系统就会直接将CView作为了处理这次事件的View(事件并没有进入BView)。
我们再点击一次DView,打印内容为:
可以看到这次依次经过了AView -> CView -> BView -> EView -> DView,当事件传递给某一个View的时候,这个View会调用hitTest:withEvent:方法寻找最适合的View,如果子View更合适,就会一直遍历,直到找到那个能响应事件并且触摸点在本身范围内的View,然后所有在这个有效的链上的View的hitTest:withEvent:方法都会返回这个最适合的View,使用此View来响应这个事件。
需要注意的是:当某个view符合以下三种条件时,它是不可以响应事件的
- isUserInteractionEnabled 属性为 NO
- isHidden 属性为YES
- alpha属性的值小于等于0.01
我们可以试试,将DView的isUserInteractionEnabled 属性设置为NO,然后再点击DView
可以看到此时响应点击事件的View为BView
这个寻找最适合View的流程用代码表示大概是这样:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
//如果符合这三个条件,则这个View不能响应事件
if (!self.userInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
//如果触摸点在这个View的范围内
if ([self pointInside:point withEvent:event]) {
//遍历这个View的所有子视图,用reverseObjectEnumerator个人猜想是因为如果你在同一个父视图上添加若干个子视图,如果子视图有重叠的情况下,应该是最上层的这个视图来响应事件,而这个视图应该是后被添加的,这样可以更加快速地找到响应者
for (UIView *subView in [self.subviews reverseObjectEnumerator]) {
//转换坐标
CGPoint convertedPoint = [subView convertPoint:point fromView:self];
UIView *hitTestView = [subView hitTest:convertedPoint withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
return self;
}
return nil;
}
找到hit-test view后,这个view会有最高的优先级来响应诸暨传递过来的Event,如果它不能响应就会传递给它的superView,以此类推,一直到UIApplication都无响应者的话这个Event就被系统丢弃了
此时我们把EView的Frame改变一下,让它一部分超出父视图以外,然后点击它超出父视图的那部分
此时的打印内容为:
可以看到这次响应点击事件的View是AView,个人理解是因为触摸点没有在BView范围内,所以寻找响应者对象的时候就不会遍历BView的子视图了,最后由AView来响应
我们将BView的- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event 重写 直接返回YES 此时EView就可以响应了
在实际开发中,我们可以通过这个机制来实现一些比较实用的功能,比如一个button很小,我们想让他的点击范围扩大,又不想将改变他的外观,就可以重写UIBUtton的-(BOOL)pointInside:withEvent:方法来实现
我们新建一个UIButton的子类,重写这个方法
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGRect bounds = self.bounds;
bounds = CGRectInset(bounds, -20, -20);
return CGRectContainsPoint(bounds, point);
}
此时我们就会发现当点击button周围的范围时也会触发button的action