您的位置:首页 > 移动开发 > IOS开发

iOS事件分发机制(一) hit-Testing

2016-02-15 18:26 567 查看
http://suenblog.duapp.com/blog/100031/iOS%E4%BA%8B%E4%BB%B6%E5%88%86%E5%8F%91%E6%9C%BA%E5%88%B6%EF%BC%88%E4%B8%80%EF%BC%89%20hit-Testing


iOS事件分发机制(一) hit-Testing

iOS中的事件大概分为三种,分别是 Milti-Touch Events, Motion Events 和Remote Control Events(events for controlling multimedia)。

本文将主要针对TouchEvents的分发,做一个详细的介绍。先抛出一个问题,文章的后续部分会对问题进行解答:iOS7原生的自带NavigationController可以实现从最左侧拖动PopViewController(大约13pt),不管当前可见的ViewController有没有其他的滑动手势或者事件,这是为什么?如何实现。

我们已经处理过太多触摸事件了,比如按钮的点击事件,一些View的手势等等。那到底我们点一下屏幕,当前的View是如何知道他被点击了呢,这个就要通过HitTest来确定了

每当我们点击了一下iOS设备的屏幕,UIKit就会生成一个事件对象UIEvent,然后会把这个Event分发给当前active的app(官方原文说:Then it places the event object in the active app’s event queue.)

告知当前活动的app有事件之后,UIApplication 单例就会从事件队列中去取最新的事件,然后分发给能够处理该事件的对象。UIApplication 获取到Event之后,Application就纠结于到底要把这个事件传递给谁,这时候就要依靠HitTest来决定了。

iOS中,hit-Testing的作用就是找出这个触摸点下面的View是什么,HitTest会检测这个点击的点是不是发生在这个View上,如果是的话,就会去遍历这个View的subviews,直到找到最小的能够处理事件的view,如果整了一圈没找到能够处理的view,则返回自身。来一个简单的图说明一下



假设我们现在点击到了图中的E,hit-testing将进行如下步骤的检测(不包含重写hit-test并且返回非默认View的情况)

1、触摸点在ViewA内,所以检查ViewA的Subview B、C

2、触摸点不在ViewB内,触摸点在ViewC内部,所以检查ViewC的Subview D、E

3、触摸点不在ViewD内,触摸点发生在ViewE内部,并且ViewE没有subview,所以ViewE属于ViewA中包含这个点的最小单位,所以ViewE变成了该次触摸事件的hit-TestView

PS.

1、默认的hit-testing顺序是按照UIView中Subviews的逆顺序

2、如果View的同级别Subview中有重叠的部分,则优先检查顶部的Subview,如果顶部的Subview返回nil, 再检查底部的Subview

3、Hit-Test也是比较聪明的,检测过程中有这么一点,就是说如果点击没有发生在某View中,那么该事件就不可能发生在View的Subview中,所以检测过程中发现该事件不在ViewB内,也直接就不会检测在不在ViewF内。也就是说,如果你的Subview设置了clipsToBounds=NO,实际显示区域可能超出了superView的frame,你点击超出的部分,是不会处理你的事件的,就是这么任性!

Hit-Test的检查机制如上所示,当确定了Hit-TestView时,如果当前的application没有忽略触摸事件 (UIApplication:isIgnoringInteractionEvents),则application就会去分发事件(sendEvent:->keywindow:sendEvent:)

UIView中提供两个方法用来确定hit-testing View,如下所示
-
(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system


-
(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event; // default returns YES if point is in bounds


当一个View收到hitTest消息时,会调用自己的pointInside:withEvent:方法,如果pointInside返回YES,则表明触摸事件发生在我自己内部,则会遍历自己的所有Subview去寻找最小单位(没有任何子view)的UIView,如果当前View.userInteractionEnabled = NO,enabled=NO(UIControl),或者alpha<=0.01, hidden等情况的时候,hitTest就不会调用自己的pointInside了,直接返回nil,然后系统就回去遍历兄弟节点。简而言之,可以写成这样
[st_hitTest:withEvent:]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (self.alpha <= 0.01 || !self.userInteractionEnabled || self.hidden) {
return nil;
}
BOOL inside = [self pointInside:point withEvent:event];
UIView *hitView = nil;
if (inside) {
NSEnumerator *enumerator = [self.subviews reverseObjectEnumerator];
for (UIView *subview in enumerator) {
hitView = [subview hitTest:point withEvent:event];
if (hitView) {
break;
}
}
if (!hitView) {
hitView = self;
}
return hitView;
} else {
return nil;
}
}

hit-Test 是事件分发的第一步,就算你的app忽略了事件,也会发生hit-Test。确定了hit-TestView之后,才会开始进行下一步的事件分发。

我们可以利用hit-Test做一些事情,比如我们点击了ViewA,我们想让ViewB响应,这个时候,我们只需要重写View's hitTest方法,返回ViewB就可以了,虽然可能用不到,但是偶尔还是会用到的。大概代码如下:
[STPView]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2223
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

@interface STPView : UIView

@end

@implementation STPView

- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
button.frame = CGRectMake(0, 0, CGRectGetWidth(frame), CGRectGetHeight(frame) / 2);
button.tag = 10001;
button.backgroundColor = [UIColor grayColor];
[button setTitle:@"Button1" forState:UIControlStateNormal];
[self addSubview:button];
[button addTarget:self action:@selector(_buttonActionFired:) forControlEvents:UIControlEventTouchDown];

UIButton *button2 = [UIButton buttonWithType:UIButtonTypeCustom];
button2.frame = CGRectMake(0, CGRectGetHeight(frame) / 2, CGRectGetWidth(frame), CGRectGetHeight(frame) / 2);
button2.tag = 10002;
button2.backgroundColor = [UIColor darkGrayColor];
[button2 setTitle:@"Button2" forState:UIControlStateNormal];
[self addSubview:button2];
[button2 addTarget:self action:@selector(_buttonActionFired:) forControlEvents:UIControlEventTouchDown];
}
return self;
}

- (void)_buttonActionFired:(UIButton *)button {
NSLog(@"=====Button Titled %@ ActionFired ", [button titleForState:UIControlStateNormal]);
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitView = [super hitTest:point withEvent:event];
if (hitView == [self viewWithTag:10001]) {
return [self viewWithTag:10002];
}
return hitView;
}

@end

大家可以试一试,上述代码在点击上面的按钮的时候,实际会触发下面按钮的事件,不是经常用到,但是也算是涨姿势了,这里给大家提供一个Category,来自STKit,这个category的目的就是方便的编写hitTest方法,由于hitTest方法是override,而不是delegate,所以使用默认的实现方式就比较麻烦。Category如下
[UIView+HitTest.h]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

/**
* @abstract hitTestBlock
*
* @param 其余参数 参考UIView hitTest:withEvent:
* @param returnSuper 是否返回Super的值。如果*returnSuper=YES,则代表会返回 super hitTest:withEvent:, 否则则按照block的返回值(即使是nil)
*
* @discussion 切记,千万不要在这个block中调用self hitTest:withPoint,否则则会造成递归调用。这个方法就是hitTest:withEvent的一个代替。
*/
typedef UIView * (^STHitTestViewBlock)(CGPoint point, UIEvent *event, BOOL *returnSuper);
typedef BOOL (^STPointInsideBlock)(CGPoint point, UIEvent *event, BOOL *returnSuper);

@interface UIView (STHitTest)
/// althought this is strong ,but i deal it with copy
@property(nonatomic, strong) STHitTestViewBlock hitTestBlock;
@property(nonatomic, strong) STPointInsideBlock pointInsideBlock;

@end

[UIView+HitTest.m]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2223
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

@implementation UIView (STHitTest)

const static NSString *STHitTestViewBlockKey = @"STHitTestViewBlockKey";
const static NSString *STPointInsideBlockKey = @"STPointInsideBlockKey";

+ (void)load {
method_exchangeImplementations(class_getInstanceMethod(self, @selector(hitTest:withEvent:)),
class_getInstanceMethod(self, @selector(st_hitTest:withEvent:)));
method_exchangeImplementations(class_getInstanceMethod(self, @selector(pointInside:withEvent:)),
class_getInstanceMethod(self, @selector(st_pointInside:withEvent:)));
}

- (UIView *)st_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSMutableString *spaces = [NSMutableString stringWithCapacity:20];
UIView *superView = self.superview;
while (superView) {
[spaces appendString:@"----"];
superView = superView.superview;
}
NSLog(@"%@%@:[hitTest:withEvent:]", spaces, NSStringFromClass(self.class));
UIView *deliveredView = nil;
// 如果有hitTestBlock的实现,则调用block
if (self.hitTestBlock) {
BOOL returnSuper = NO;
deliveredView = self.hitTestBlock(point, event, &returnSuper);
if (returnSuper) {
deliveredView = [self st_hitTest:point withEvent:event];
}
} else {
deliveredView = [self st_hitTest:point withEvent:event];
}
//    NSLog(@"%@%@:[hitTest:withEvent:] Result:%@", spaces, NSStringFromClass(self.class), NSStringFromClass(deliveredView.class));
return deliveredView;
}

- (BOOL)st_pointInside:(CGPoint)point withEvent:(UIEvent *)event {
NSMutableString *spaces = [NSMutableString stringWithCapacity:20];
UIView *superView = self.superview;
while (superView) {
[spaces appendString:@"----"];
superView = superView.superview;
}
NSLog(@"%@%@:[pointInside:withEvent:]", spaces, NSStringFromClass(self.class));
BOOL pointInside = NO;
if (self.pointInsideBlock) {
BOOL returnSuper = NO;
pointInside =  self.pointInsideBlock(point, event, &returnSuper);
if (returnSuper) {
pointInside = [self st_pointInside:point withEvent:event];
}
} else {
pointInside = [self st_pointInside:point withEvent:event];
}
return pointInside;
}

- (void)setHitTestBlock:(STHitTestViewBlock)hitTestBlock {
objc_setAssociatedObject(self, (__bridge const void *)(STHitTestViewBlockKey), hitTestBlock, OBJC_ASSOCIATION_COPY);
}

- (STHitTestViewBlock)hitTestBlock {
return objc_getAssociatedObject(self, (__bridge const void *)(STHitTestViewBlockKey));
}

- (void)setPointInsideBlock:(STPointInsideBlock)pointInsideBlock {
objc_setAssociatedObject(self, (__bridge const void *)(STPointInsideBlockKey), pointInsideBlock, OBJC_ASSOCIATION_COPY);
}

- (STPointInsideBlock)pointInsideBlock {
return objc_getAssociatedObject(self, (__bridge const void *)(STPointInsideBlockKey));
}

@end

代码很简单,就是利用iOS的runtime能力,在hitTest执行之前,插入了一个方法。如果有看不懂的,可以参考我以前的博客 iOS面向切面编程

现在回到我们开始提出的题目,其实题目很简单,就是简单的可以把题目转换为

如果我们触摸点的坐标 point.x < 13, 我们就让hit-Test 返回NavigationController.view, 把所有的事件入口交给他,否则就返回super,该怎么处理怎么处理

这样就能满足我们的条件,即使当前的VC上面有ScrollView,但是由于点击特定区域的时候,ScrollView根本得不到事件,所以系统会专心处理NavigationController的拖拽手势,而不是ScrollView的事件,当没有点击特定区域的时候,NavigationController的手势不会触发,系统会专心处理ScrollView的事件,互不影响,大家可以尝试实现,代码量不多。

虽然iOS8新增了UIScreenEdgePanGestureRecognizer 手势,但是单纯的用这个手势无法解决当前VC上面有ScrollView的问题,有关手势方面的事件分发,之后的文章会对此进行说明,这里就不多说了。

当我们确定了HitTestView之后,我们的事件分发就正式开始了,如果hitTestView可以直接处理的,就处理,不能处理的,则交给 The Responder Chain/ GestureRecognizer。后续文章会对分发进行进一步说明。

附上一些测试查找hitTestView过程中打印的日志,可以观察一下:
None

1
2
3
4
5
6
7
8
9
10
11
12
13
14

STPWindow:[hitTest:withEvent:]
----UIView:[hitTest:withEvent:]
--------STPView:[hitTest:withEvent:]
--------UICollectionView:[hitTest:withEvent:]
------------UIImageView:[hitTest:withEvent:]
------------UIImageView:[hitTest:withEvent:]
------------STDefaultRefreshControl:[hitTest:withEvent:]
------------STPFeedCell:[hitTest:withEvent:]
------------STPFeedCell:[hitTest:withEvent:]
----------------UIView:[hitTest:withEvent:]
--------------------UIImageView:[hitTest:withEvent:]
------------------------UIImageView:[hitTest:withEvent:]
------------------------UIView:[hitTest:withEvent:]
------------------------STImageView:[hitTest:withEvent:]

其中----表示View的层次结构
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: