前言
今天面试滴滴的时候聊到了自己以前遇到的一个坑,其中涉及了一些响应者链条的事。本身这个bug就很有代表性(代表了自己对这块非常的不熟悉😃)。无独有偶,自己的好基友前几天腾讯电面一面时也遇到了这个问题。回来好好看了大神的博客。就顺手写个学习笔记自我总结下吧。
先聊聊响应链
UIResponder
是所有可以响应事件的类的基类(从名字应该就可以看出来了),其中包括最常见的UIView和UIViewController
甚至是UIApplication
,所以我们的UIView
和UIViewController
都是作为响应事件的载体。
Apple🍎爸爸是这么说的:
The UIResponder class defines an interface for objects that respond to and handle events. It is the superclass of UIApplication, UIView and its subclasses (which include UIWindow). Instances of these classes are sometimes referred to as responder objects or, simply, responders.
先看看这个UIResponder
的头文件:
#import <Foundation/Foundation.h>
#import <UIKit/UIKitDefines.h>
#import <UIKit/UIEvent.h>
NS_ASSUME_NONNULL_BEGIN
@class UIPress;
@class UIPressesEvent;
NS_CLASS_AVAILABLE_IOS(2_0) @interface UIResponder : NSObject
- (nullable UIResponder*)nextResponder;
- (BOOL)canBecomeFirstResponder; // default is NO
- (BOOL)becomeFirstResponder;
- (BOOL)canResignFirstResponder; // default is YES
- (BOOL)resignFirstResponder;
- (BOOL)isFirstResponder;
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(nullable NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet * _Nonnull)touches NS_AVAILABLE_IOS(9_1);
注:这里我删除了一些此文无关的定义
那么UIResponder
和我们讲的相应者链条到底是什么关系呢?其实在iOS
系统中,所谓响应者链条就是由这些UIResponder
链接起来的。你可以想象成链表,链接他们的就是上面定义的属性nextResponder
链接起来的。
![](https://img.haomeiwen.com/i1792274/621de0157a4b77a3.png)
Hit-Testing View
简单了解响应者链条是什么后,就要今天的主角登场了:Hit-Testing View
。
上节我们讲到了响应者链条,但是并没有说清楚它的工作流程。比如上图中的intial view
是怎么寻找到的?系统正是通过一个叫做Hit-Test
过程找到这个initial obje
的。
Hit-Test
的目的就是寻找目前手指点击到的那个最前的view
,也可以理解为responder
。
这个过程对应的方法在UIView
里:
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; // default returns YES if point is in bounds
当用户点击了手机屏幕时,UIApplication
就会调用UIWindow
的hitTest: withEvent:
方法。这个方法最终返回一个UiView
。也就是我们要找到的那个最前的view
。那这个方法具体流程是怎么样的呢?
我们拿下图说明下:
![](https://img.haomeiwen.com/i1792274/001b9095d1e63fe3.png)
UIWindow有一个MianVIew,MainView里面有三个subView:view A、view B、view C,他们各自有两个subView,他们层级关系是:view A在最下面,view B中间,view C最上(也就是addSubview的顺序,越晚add进去越在上面)。
现在假设手指点击在了绿色的View B
上:
此时,UIApplication
会调用UIWindow
的hitTest:WithEvent:
方法,接着UIWindow
调用上面的第二个方法:pointInside:withEvent:
。现在手指点击的位置显然在MainView
的bounds
内,于是这个方法返回yes。紧接着,MainView
开始遍历subviews
。此处需要注意的是,这个方法会从index值较大的位置开始遍历,比如此处就会先找到VIew C
上。因为它是最后一个被加入subviews
数组里的。在调用了pointInside:withEvent:
方法后,返回了NO。于是继续在MainView
的subview.index - 1的位置继续调用pointInside:withEvent:
方法。如此循环,最终找到手指此时触摸的位置:View B.1
。
![](https://img.haomeiwen.com/i1792274/6ab6cb6e961b46b0.png)
完整流程:
![](https://img.haomeiwen.com/i1792274/3f5f137ab5e2631f.png)
我们可以看到:判断当前这个view
是不是hitView
时,需要同时满足以下四个条件:
view.userInteractionEnable == YES
view.hidden == NO
view.alpha > 0.01f
[view pointInside:point withEvent:event] == YES
代码实现起来还是比较简单的😄:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
if (self.hidden || self.alpha <= 0.01f || [self pointInside:point withEvent:event] || !self.userInteractionEnabled) {
return nil;
}
for (UIView *subview in [self.subviews reverseObjectEnumerator]) {//注意倒叙
CGPoint newPoint = [subview convertPoint:point fromView:self];
UIView *hitView = [subview hitTest:newPoint withEvent:event];
if (hitView) {
return hitView;
}
}
return self;
}
Hit-Testing View
应用
最常用的:扩大按钮热区
项目中经常有扩大某个按钮热区的需求,相信实际做过项目的童鞋都有过这样的经历。以前小弟的做法是把按钮宽高调大一点,但是这样也会导致按钮图片位移,还要改按钮的UIEdgeInsets
等属性,很是蛋疼。有了Hit-Testing View
,我们就可以这样写:😄
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
CGRect originRect = self.bounds;
originRect.origin = CGPointMake(-15.0f, -10.0f);
originRect.size = CGSizeMake(originRect.size.width + 30.0f, originRect.size.height + 10.0f + 10.0f);
CGPoint touchPoint = [self convertPoint:point toView:self];
if (CGRectContainsPoint(originRect, touchPoint)) {
return self;
}
return [super hitTest:point withEvent:event];
}
事件的传递
有了响应者链条,事件的传递也就水到渠成了。在UIApplication
调用了UIWIndow
的hitTest:withEevent:
方法并反悔了一个hitView
后,就会通过sendEvent:
这个方法将事件传递给当前的hitView
。如果当前的hitView
处理不了该事件,就会将事件交由自己的nextResponder
处理,如此递归。若最后交由UIApplication
仍然处理不了该事件,系统就会抛弃该事件。
摘抄一段大神的博客:
如果
view
重写了touch
方法,我们一般会看到的效果是,这个view
响应了事件之后,它nextResponder
不会收到这个事件,即使重写了nextResponder
的touch
方法。这个时候如果想事件继续传递下去,可以调用[super touchesBegan:touches withEvent:event],
不建议直接调用[self.nextRespondertouchesBegan:touches withEvent:event]
。
网友评论
if 的判断条件 应该是 ![self pointInside:point withEvent:event] 吧?
if (CGRectContainsPoint(newRect, point)) {
return nil;
}
哥们,你这个地方写错了吧? 应该return self吧?