iOS开发技术分享iOS魔法书iOS学习笔记

响应链传递底层原理和底层代码猜测重写完美替代

2017-03-21  本文已影响1373人  喵子G

1, 简单层面的touch事件的响应

在控制器的self.view中添加一个灰色的view,再将一个红色的view添加到灰色的view中:
代码:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    JKRLightGrayView *lightGrayView = [[JKRLightGrayView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    [self.view addSubview:lightGrayView];
    JKRRedView *redView = [[JKRRedView alloc] initWithFrame:CGRectMake(10, 10, 30, 30)];
    [lightGrayView addSubview:redView];
}

效果:


效果

重写灰色view、红色view、控制器的view的touchBegin方法:

controller:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"controller touch");
}

lightGrayView:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"lightGrayView touch");
}

redView:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"redView touch");
}

现在点击红色view,log输出只有redView touch;点击浅灰色view,log输出只有lightGrayView touch;点击屏幕黑色区域(控制器的view的空白区域),log输出只有controller touch。目前为止,这就是简单touch事件的使用,获取view的点击事件并通过touch相关方法回调来进行相关操作。

分析一:

注释掉redView的touchesBegan方法,

//- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
//    NSLog(@"redView touch");
//}

然后重复分别点击每一个view,可以发现:点击红色view、灰色view时log输出都只有lightGrayView touch;点击控制器viewlog空白区域输出只有controller touch。
这里可以得出一个规律:默认状态下,如果子视图的view接收并重写了touch事件,那么只会在子视图中响应,父视图不会重复响应。

分析二:

将redView的尺寸调大,使它超过父视图lightGrayView:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.view.backgroundColor = [UIColor blackColor];
    
    JKRLightGrayView *lightGrayView = [[JKRLightGrayView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    [self.view addSubview:lightGrayView];
    JKRRedView *redView = [[JKRRedView alloc] initWithFrame:CGRectMake(10, 10, 120, 120)];
    [lightGrayView addSubview:redView];
}
QQ20170321-120740.png

这时点击超出的部分,发现输出的是:controller touch,即点击事件并没有被redView响应,而是被后面的控制器的view响应。
这里可以得到一个规律:默认状态下,子视图的响应范围不会超过它的父视图。

分析三:

将所有的touchesBegan方法中都加上

[super touchesBegan:touches withEvent:event];

重新点击redView输出如下:

redView touch  
lightGrayView touch
controller touch

这里可以得到一个规律:在touesBegan方法中调用super方法下,当前响应touch方法的视图的父视图也会响应touch方法。

2,深入判断响应对象的方法 hitTest调用规律测试

上面只是说了文档中描述顺序,下面听过判断响应者的相关方法,来验证之前描述的规律。
我们构建如上的视图:

视图结构

对每一个视图都做详细log输出,代码示例:

- (void)drawRect:(CGRect)rect {
    [super drawRect:rect];
    NSString *index = @"2";
    [index drawInRect:CGRectMake(rect.origin.x + 220, rect.origin.y, 20, 20) withAttributes:@{NSForegroundColorAttributeName:[UIColor blackColor], NSFontAttributeName:[UIFont systemFontOfSize:18]}];
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"lightGray view inside");
    BOOL isInside = [super pointInside:point withEvent:event];
    NSLog(@"lightGray view is inside: %zd", isInside);
    return isInside;
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"lightGray view hit");
    UIView *view = [super hitTest:point withEvent:event];
    //UIView *view = [self jkr_hitTest:point withEvent:event];
    NSLog(@"lightGray view hit: %@", view);
    return view;
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"lightGray view touchBegan");
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"lightGray view touchCancelled");
    [super touchesCancelled:touches withEvent:event];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"lightGray view touchMoved");
    [super touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"lightGray view touchEnded");
    [super touchesEnded:touches withEvent:event];
}

点击RedView输出如下:

yellow view hit
yellow view inside
yellow view is inside: 0
yellow view hit: (null)
lightGray view hit
lightGray view inside
lightGray view is inside: 1
green view hit
green view inside
green view is inside: 0
green view hit: (null)
red view hit
red view inside
red view is inside: 1
red view hit: <RedView: 0x7fb058c07ea0; frame = (0 0; 196 100); autoresize = RM+BM; layer = <CALayer: 0x60000003e940>>
lightGray view hit: <RedView: 0x7fb058c07ea0; frame = (0 0; 196 100); autoresize = RM+BM; layer = <CALayer: 0x60000003e940>>
red view touchBegan
lightGray view touchBegan
rootView touchBegan
red view touchEnded
lightGray view touchEnded
rootView view touchEnded

分析:通过log输出顺序可以发现,父视图hitTest方法首先调用,然后调用父视图pointInside方法,如果父视图有子视图,子视图继续吊用hitTest方法。从这里我们可以明显发现这个调用顺序是一个递归调用。

3,hitTest透析,重写hitTest方法完成响应者遍历

通过hitTest的调用顺序,模拟重写一个hitTest方法并将它定义成UIView的扩展方法:

#import <UIKit/UIKit.h>

@interface UIView (HitTest)

- (UIView *)jkr_hitTest:(CGPoint)point withEvent:(UIEvent *)event;

@end

#import "UIView+HitTest.h"

@implementation UIView (HitTest)

- (UIView *)jkr_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (self.alpha <= 0.01 || self.hidden || self.userInteractionEnabled == false) {
        return nil;
    }
    
    UIView *lastResultView = nil;
    if ([self pointInside:point withEvent:event]) {
        lastResultView = self;
        NSArray *subViews = [[self.subviews reverseObjectEnumerator] allObjects];
        if (subViews.count) {
            for (id view in subViews) {
                CGPoint convertPoint = [self convertPoint:point toView:view];
                UIView *currentResultView = [view hitTest:convertPoint withEvent:event];
                if (currentResultView) {
                    lastResultView = currentResultView;
                    break;
                }
            }
            return lastResultView;
        } else {
            return lastResultView;
        }
    }
    return nil;
}

@end

将视图的hitTest方法的super方法换成自定义的hitTest方法:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"lightGray view hit");
    //UIView *view = [super hitTest:point withEvent:event];
    UIView *view = [self jkr_hitTest:point withEvent:event];
    NSLog(@"lightGray view hit: %@", view);
    return view;
}

重新点击,发现touch事件结果和系统一样。

系统默认下,视图可以响应触摸事件的条件

1,透明度不低于0.01
2,hidden为NO
3,userInteractionEnabled为YES

hitTest和pointInside方法的关系:

一个视图的hitTest方法首先调用,hitTest方法在判断视图满足可以成为响应者的基本条件(透明度、是否隐藏,是否响应触摸事件)后,调用它的pointInside方法。如果pointInside方法返回为true(即touch的点在该视图的响应范围内),那么就继续判断该视图是否有子视图,如果有子视图,则调用子视图的hitTest方法。直到所有子视图的hitTest方法调用完毕,最后将结果递归调用回父视图。所有视图的hitTest方法返回值为递归的最终结果。

hitTest方法的修改和pointInside方法的修改。

1,系统遍历响应者时,是否遍历一个视图的子视图前提是该视图pointInside方法的调用的结果。如果直接修改这视图的hitTest方法的返回值,确没有调用该视图的super方法和pointInside方法,那么这个视图的子视图就不会被遍历到,该视图会拦截所有子视图的touch事件响应。
2,同样,由于遍历递归的返回结果是由子视图传递到父视图,如果修改了父视图的hitTest方法,如果点击了子视图,那么该父视图的hitTest返回值会替换掉子视图的返回值,导致最终hitTest返回结果是父视图返回的值。
3,综上,直接修改hitTest的值,在响应者遍历的时候,会一定程度的打乱递归返回结果的逻辑,是不太好的行为。如果要修改一个视图的响应范围或者屏蔽touch事件,最好是修改pointInside的值。

4,重写hitTest方法可以应用的地方

1,让透明度低于0.01的视图响应触摸事件
2,让hidden的视图响应触摸事件
3,让userInteractionEnabled为NO的视图响应触摸事件
4,重定义响应触摸事件的视图应满足的条件

5,pointInside方法的修改和应用

测试一:

在RedView、LightGrayView、GreenView、YellowView的pointInside方法中都添加打印point参数的代码:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"yellow point: %@", NSStringFromCGPoint(point));
    BOOL isInside = [super pointInside:point withEvent:event];
    NSLog(@"yellow view is inside: %zd", isInside);
    return isInside;
}

点击RedView查看输出如下:

yellow point: {23.666656494140625, -136}
yellow view is inside: 0
yellow view hit: (null)
lightGray point: {23.666656494140625, 20}
lightGray view is inside: 1
green point: {-114.33334350585938, -37}
green view is inside: 0
green view hit: (null)
red point: {23.666656494140625, 20}
red view is inside: 1
red view hit: <RedView: 0x7fdb23003e90; frame = (0 0; 196 100); autoresize = RM+BM; layer = <CALayer: 0x608000035080>>
lightGray view hit: <RedView: 0x7fdb23003e90; frame = (0 0; 196 100); autoresize = RM+BM; layer = <CALayer: 0x608000035080>>
red view touchBegan
lightGray view touchBegan
rootView touchBegan

过滤我们关注的point坐标输出如下:

yellow point: {23.666656494140625, -136}
lightGray point: {23.666656494140625, 20}
green point: {-114.33334350585938, -37}
red point: {23.666656494140625, 20}

通过输出可以得到结论,pointInside方法中的point的坐标并不是以屏幕为参照系,而是以当前视图为参照系。

测试二:

将yellowView的pointInside返回参数修改为true,点击LightGrayView和它的子视图:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"yellow point: %@", NSStringFromCGPoint(point));
    BOOL isInside = [super pointInside:point withEvent:event];
    isInside = YES;
    NSLog(@"yellow view is inside: %zd", isInside);
    return isInside;
}

查看输出如下:

yellow point: {112.66665649414062, -119.66667175292969}
yellow view is inside: 1
yellow view hit: <YellowView: 0x7fe5e8d14a00; frame = (33 303; 240 128); autoresize = RM+BM; layer = <CALayer: 0x608000028ae0>>
yellow view touchBegan
rootView touchBegan

可以看到,yellow会截取和它平级的在它之前添加的视图的点击事件。

应用一:缩减一个视图的响应范围

我们之前看到,红色视图有一部分被绿色遮盖


缩减一个视图的响应范围

当点击被遮盖部分的时候,是绿色视图响应点击事件。现在可以通过重写绿色视图的pointInside方法缩减绿色视图的响应范围,让红色视图来响应点击事件。之所以要修改绿色视图的pointInside方法,是因为GreenView是在RedView之前遍历到的,它的pointInside返回true后,会直接拦截和它平级的RedView的遍历。
修改代码如下:

//GreenView.m
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"green point: %@", NSStringFromCGPoint(point)); 
    if (point.x < 58 && point.y < 37) {
        return NO;
    } else {
        BOOL isInside = [super pointInside:point withEvent:event];
        NSLog(@"green view is inside: %zd", isInside);
        return isInside;        
    }
}

点击GreenView和RedView交叉的区域输出如下:

yellow point: {175.66665649414062, -78}
yellow view is inside: 0
yellow view hit: (null)
lightGray point: {175.66665649414062, 78}
lightGray view is inside: 1
green point: {37.666656494140625, 21}
green view hit: (null)
red point: {175.66665649414062, 78}
red view is inside: 1
red view hit: <RedView: 0x7f9041409110; frame = (0 0; 196 100); autoresize = RM+BM; layer = <CALayer: 0x60000003fb20>>
lightGray view hit: <RedView: 0x7f9041409110; frame = (0 0; 196 100); autoresize = RM+BM; layer = <CALayer: 0x60000003fb20>>
red view touchBegan
lightGray view touchBegan
rootView touchBegan

点击GreenView没有覆盖RedView的区域输出如下:

yellow point: {227.33332824707031, -44}
yellow view is inside: 0
yellow view hit: (null)
lightGray point: {227.33332824707031, 112}
lightGray view is inside: 1
green point: {89.333328247070312, 55}
green view is inside: 1
green view hit: <GreenView: 0x7f90414096e0; frame = (138 57; 94 63); autoresize = RM+BM; layer = <CALayer: 0x60000003fb80>>
lightGray view hit: <GreenView: 0x7f90414096e0; frame = (138 57; 94 63); autoresize = RM+BM; layer = <CALayer: 0x60000003fb80>>
green view touchBegan
lightGray view touchBegan
rootView touchBegan

可以看到,成功的缩减了GreenView接收touch事件的范围。

应用二:扩大一个视图的响应范围

还是看这个视图

扩大一个视图的响应范围

下面我们有一个需求,RedView是本来就有的视图,已经写好了touch事件的处理,现在在RedView上面添加了一个GreenView,即要让GreenView不遮盖RedView的点击事件,还要让点击GreenView的时候,也让RedView的touch事件来处理这个点击操作。

分析:

这里就需要做两部操作,首先,由于GreenView实在RedView之后添加的,所以当系统遍历响应者的时候GreenView如果满足响应条件会拦截和它平级的RedView的遍历。所以只扩大RedView的响应范围是不够的,需要先重写GreenView的pointInside方法让它不拦截:

//GreenView.m
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"green point: %@", NSStringFromCGPoint(point));
    return NO;
}

然后在扩大RedView的范围:

//RedView.m
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"red point: %@", NSStringFromCGPoint(point));
    CGRect extendFrame = CGRectMake(138, 57, 94, 63);
    BOOL isInside = [super pointInside:point withEvent:event] ||  CGRectContainsPoint(extendFrame, point);
    NSLog(@"red view is inside: %zd", isInside);
    return isInside;
}

现在无论点击RedView还是GreenView,都由RedView的touch事件来响应。

获取授权

上一篇下一篇

猜你喜欢

热点阅读