应用程序使用响应者对象接收和处理事件。响应者对象是UIResponder类的任何实例,常见的子类包括UIView、UIViewController和UIApplication。响应者接收原始事件数据,必须处理该事件或将其转发给另一个响应程序对象。当应用程序接收到事件时,UIKit会自动将该事件定向到最合适的响应程序对象,即第一响应者。
在iOS程序中响应者对象的摆放是有前后关系的,多个响应者对象有序的连接起来的链条就叫“响应者链”。
创建一个UIView的子类ChainView,重写touchesBegan方法:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSLog(@"%@ --- touchesBegan withEvent ---", self.name);
UIResponder * next = [self nextResponder];
NSMutableString * prefix = @"".mutableCopy;
while (next != nil) {
NSLog(@"%@%@", prefix, [next class]);
[prefix appendString: @"--"];
next = [next nextResponder];
}
}
创建一个ChainView实例添加到ViewController.view,点击log输出:
2020-09-24 11:08:56.494864+0800 001--ClientDemo[83858:1377593] greenView --- touchesBegan withEvent ---
ClientDemo[83858:1377593] UIView
ClientDemo[83858:1377593] --ViewController
ClientDemo[83858:1377593] ----UIWindow
ClientDemo[83858:1377593] ------UIApplication
ClientDemo[83858:1377593] --------AppDelegate
选择不同版本iOS有不同输出,在iOS12之前是上面输出,iOS12及以后输出如下
2020-09-24 11:11:41.146777+0800 001--ClientDemo[84116:1402680] greenView --- touchesBegan withEvent ---
ClientDemo[84116:1402680] UIView
ClientDemo[84116:1402680] --ViewController
ClientDemo[84116:1402680] ----UIDropShadowView
ClientDemo[84116:1402680] ------UITransitionView
ClientDemo[84116:1402680] --------UIWindow
ClientDemo[84116:1402680] ----------UIWindowScene
ClientDemo[84116:1402680] ------------UIApplication
ClientDemo[84116:1402680] --------------AppDelegate
这里及以下都以iOS11上为例。上面打印的是本例中事件响应传递关系也即事件响应者链,响应者链是事件传递链的逆序,则事件传递链如下图:
事件传递链UIView 的 controller 是直接管理它的 UIViewController (也就是 VC.view.nextResponder = VC ),如果当前 View 不是 ViewController 直接管理的 View,则 nextResponder 是它的 superView( view.nextResponder = view.superView )。
UIViewController 的 nextResponder 是它直接管理的 View 的 superView( VC.nextResponder = VC.view.superView )。
UIWindow 的 nextResponder 是 UIApplication 。
UIApplication 的 nextResponder 是 AppDelegate。
有了响应链,并且找到了第一个响应事件的对象,接下来就是把事件发送给这个响应者了。 UIApplication中有个sendEvent:的方法,在UIWindow中同样也可以发现一个同样的方法。UIApplication是通过这个方法把事件发送给UIWindow,然后UIWindow通过同样的API,把事件发送给hit-testview。
响应链工作步骤
- 事件产生
- 事件顺着传递链 AppDelegate---> UIApplication --->UIWindow ---> 查找第一响应者,查找过程主要用到两个函数:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
- 找到最合适响应者视图后,视图会调用自己的touches方法处理事件。如果自身没有做处理,UIKit那么会逆着事件传递链,向上查找,直到找到能够响应这个事件的视图。当将事件传递给UIApplication对象时,如果该对象是UIResponder实例,而不属于响应程序链的一部分,则可能会将事件传递给应用程序委托AppDelegate。最终如果没有对象响应该事件,该事件会被丢弃(不是Crash!)。
示例
左侧是实现效果,其中设置了cyanView.userInteractionEnabled = NO,右侧是当前程序的响应者链(同级视图左边线与右边添加到父视图上)。
1600846386263.jpg
修改上面提到的的ChainView实现:
@interface ChainView : UIView
@property(nonatomic, copy) NSString *name;
@end
@implementation ChainView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSLog(@"%@ --- touchesBegan withEvent ---", self.name);
}
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
NSLog(@"%@ --- hitTest withEvent", self.name);
UIView * view = [super hitTest:point withEvent:event];
NSLog(@"%@ --- hitTest withEvent --- hitTestView:%@", self.name, ((ChainView *)view).name);
return view;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
BOOL isInside = [super pointInside:point withEvent:event];
NSLog(@"%@ --- pointInside withEvent --- isInside:%d", self.name, isInside);
return isInside;
}
@end
点击blueView和cyanView的重叠处,log输出:
2020-09-23 16:48:39.439271+0800 TT[72680:1155021] redView --- hitTest withEvent
2020-09-23 16:48:39.439426+0800 TT[72680:1155021] redView --- pointInside withEvent
2020-09-23 16:48:39.439538+0800 TT[72680:1155021] redView --- pointInside withEvent --- isInside:1
2020-09-23 16:48:39.439644+0800 TT[72680:1155021] yllowView --- hitTest withEvent
2020-09-23 16:48:39.439933+0800 TT[72680:1155021] yllowView --- pointInside withEvent
2020-09-23 16:48:39.440452+0800 TT[72680:1155021] yllowView --- pointInside withEvent --- isInside:1
2020-09-23 16:48:39.440935+0800 TT[72680:1155021] purpleView --- hitTest withEvent
2020-09-23 16:48:39.441447+0800 TT[72680:1155021] purpleView --- pointInside withEvent
2020-09-23 16:48:39.442443+0800 TT[72680:1155021] purpleView --- pointInside withEvent --- isInside:0
2020-09-23 16:48:39.442865+0800 TT[72680:1155021] purpleView --- hitTest withEvent --- hitTestView:(null)
2020-09-23 16:48:39.443304+0800 TT[72680:1155021] orangeView --- hitTest withEvent
2020-09-23 16:48:39.443728+0800 TT[72680:1155021] orangeView --- pointInside withEvent
2020-09-23 16:48:39.444398+0800 TT[72680:1155021] orangeView --- pointInside withEvent --- isInside:1
2020-09-23 16:48:39.444861+0800 TT[72680:1155021] cyanView --- hitTest withEvent
2020-09-23 16:48:39.445325+0800 TT[72680:1155021] cyanView --- hitTest withEvent --- hitTestView:(null)
2020-09-23 16:48:39.445743+0800 TT[72680:1155021] blueView --- hitTest withEvent
2020-09-23 16:48:39.446166+0800 TT[72680:1155021] blueView --- pointInside withEvent
2020-09-23 16:48:39.446590+0800 TT[72680:1155021] blueView --- pointInside withEvent --- isInside:1
2020-09-23 16:48:39.446985+0800 TT[72680:1155021] blueView --- hitTest withEvent --- hitTestView:blueView
2020-09-23 16:48:39.447281+0800 TT[72680:1155021] orangeView --- hitTest withEvent --- hitTestView:blueView
2020-09-23 16:48:39.447678+0800 TT[72680:1155021] yllowView --- hitTest withEvent --- hitTestView:blueView
2020-09-23 16:48:39.448096+0800 TT[72680:1155021] redView --- hitTest withEvent --- hitTestView:blueView
- 首先是redView的hitTest被调用,hitTest里通过pointInside判断事件在redView相应范围内,向下判断redView的子视图;
- 子视图的判断是逆序的,所以先判断yellowView,事件在yellowView响应范围,继续判断yellowView的子视图;
- 依然是因为子视图的判断是逆序的,所以先判断purpleView,没有在purpleView响应范围;判断orangeView通过,继续向下判断orangeView的子视图;
- 在判断cyanView的时候,因为设置了cyanView.userInteractionEnabled = NO,所以cyanView不能响应事件;
- 最后判断blueView是第一响应者。逆着响应者链通知前面的响应者 blueView是第一响应者,blueView的touches事件调用。
事件拦截
改变第一响应者
image.png在需要拦截的 view 中重写 hitTest 方法改变第一响应者。
比如上面示例中,让事件在yellowView中断,由yellowView响应:
在ChainView中重新实现hitTest:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <=
0.01) {
return nil;
}
if ([self pointInside:point withEvent:event]) {
if([self.name isEqualToString:@"yellowView"]){
return self;
}
}
return nil;
}
限制第一响应者范围
下图中的按钮,只有中间圆形部分可以点击,四个角上不响应事件:
CustomButton
创建一个继承自UIButton的CustomButton,实现:
@implementation CustomButton
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
// 判断触摸位置是否在当前视图内
if ([self pointInside:point withEvent:event]) {
//逆序遍历当前对象的子视图
__block UIView *hitView = nil;
[self.subviews enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
//坐标转换系 使坐标基于子视图
CGPoint convertedPoint = [self convertPoint:point toView:obj];
//调用子视图的 hitTest 方法
hitView = [obj hitTest:convertedPoint withEvent:event];
// 如果子视图返回一个view 遍历终止
if (hitView) {
*stop = YES;
}
}];
//如果子视图返回一个view 返回这个view
if(hitView) {
return hitView;
}
//所有子视图都返回 nil, 则返回自身.
return self;
}
return nil;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
CGFloat x1 = point.x;
CGFloat y1 = point.y;
CGFloat x2 = self.frame.size.width/2;
CGFloat y2 = self.frame.size.height/2;
double dist = sqrt((x1-x2) * (x1-x2) + (y1-y2) * (y1-y2));
//在以当前控件中心为圆心,直径为控件宽度的圆内
if(dist <= self.frame.size.width/2){
return YES;
}
else{
return NO;
}
// return [super pointInside:point withEvent:event];
}
具体使用和UIButton一样,有兴趣可以自己测试一下效果。
事件转发
有时候还需要将事件转发出去。让本不能响应事件的 view 响应事件,最常用的场景就是让子视图超出父视图的部分也能响应事件
image.png
cyanView的右下区域超出了父视图 orangeView 的区域,如果不作处理,那么点击超出的区域是无法响应事件的,因为超出区域的坐标不在orangeView的范围内,当执行到orangeView的 pointInside 的时候就会返回 NO。
想要让超出区域响应事件,就需要重写父视图的 pointInside 或 hitTest 方法让 pointInside 返回 YES 或 让hitTest 直接返回cyanView
重写hitTest
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
NSLog(@"%@ --- hitTest withEvent", self.name);
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
// 触摸点在视图范围内 则交由父类处理
if ([self pointInside:point withEvent:event]) {
return [super hitTest:point withEvent:event];
}
// 如果触摸点不在范围内 而在子视图范围内依旧返回子视图
NSArray<UIView *> * superViews = self.subviews;
// 倒序 从最上面的一个视图开始查找
for (NSUInteger i = superViews.count; i > 0; i--) {
UIView * subview = superViews[i - 1];
// 转换坐标系 使坐标基于子视图
CGPoint newPoint = [self convertPoint:point toView:subview];
// 得到子视图 hitTest 方法返回的值
UIView * view = [subview hitTest:newPoint withEvent:event];
// 如果子视图返回一个view 就直接返回 不在继续遍历
if (view) {
return view;
}
}
return nil;
重写 pointInside 方法原理相同, 重点注意转换坐标系,就算他们不是一条响应链上,也可以通过重写 hitTest 方法转发事件。
网友评论