理解iOS Event Handling

写在前面

最近的一个iOS App项目中遇到了这么问题:通过App访问服务器的大多数资源不需要登录,但是访问某些资源是需要用户提供验证的,一般来说,通常App的做法(譬如美团App)将这些资源放在“我的”模块,在未登录情况下,当点击“我的”某个模块时,以modally给出一个界面用于登录,我不晓得美团是怎么实现的;但在我看来,比较优雅的方式是在网络底层实现“modally呈现登录界面”,具体来说,当用户欲访问“我的购物券”时,在网络访问层发现用户未登录,就弹出一个登陆界面,用户登录成功后,就将这个登录界面给dismiss掉。

这种实现的好处是使得登录模块管理比较集中,便于维护。其实现也并不难,modally方式呈现一个页面的代码很简单,如下:

1
2
UIViewController *VC = [][UIViewController alloc] init];
[self presentViewController:VC animated:YES completion:nil];

这对于在网络层处理有点麻烦,因为presentViewController:animated:completion:是UIViewController中定义的方法,所以,modally呈现一个页面的第一个任务是需要知道当前所呈现的view controller。搜索“iOS获取当前view controller”在这里貌似找到一个比较好的解决方案。所以最后我的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
UIViewController *result = nil;
 
// 1. get current window
UIWindow * window = [[UIApplication sharedApplication] keyWindow];
if (window.windowLevel != UIWindowLevelNormal) {
NSArray *windows = [[UIApplication sharedApplication] windows];
for(UIWindow * tmpWin in windows) {
if (tmpWin.windowLevel == UIWindowLevelNormal) {
window = tmpWin;
break;
}
}
}
 
// 2. get current View Controller
UIView *frontView = [[window subviews] objectAtIndex:0];
id nextResponder = [frontView nextResponder];
 
if ([nextResponder isKindOfClass:[UIViewController class]]) {
result = nextResponder;
} else {
result = window.rootViewController;
}
 
// 3. present Login VC modally
// 3.1 init VC
LoginNavigationController *nav = [[LoginNavigationController alloc] init];
 
// 3.2 show VC
[result presentViewController:nav animated:YES completion:nil];

从未对这段代码进行验证,今天得空决定对这段简单的代码进行简单的分析。之前对UIResponder并不熟悉,得补充补充相关知识点,笔者参考的文档是官方文档《Event Handling Guide for iOS》。

在iOS中,把“对事件的处理”称为responder。

Hit-Testing

iOS的views之间有各种覆盖关系,譬如view A是view B上的subview,那么如果在view A上产生了一个touch event,那么说“touch event在A上发生”还是“touch event在B上发生”呢?Hit-Testing就是为了解决这个问题而生的概念。

关于Hit-testing,官方文档是这么描述的:

iOS uses hit-testing to find the view that is under a touch. Hit-testing involves checking whether a touch is within the bounds of any relevant view objects. If it is, it recursively checks all of what view’s subviews. The lowest view in the view hierarchy that contains the touch point becomes the hit-test view. After iOS determines the hit-test view, it passes the touch event to that view for handling.

来用图文对此过程进行描述,如下图:

uiresponder-views

假设在view E上发生了一个user touch event,iOS的hit-test过程如下:

  1. touch event发生在view A的bounds内,继续检查subview B和subview C;
  2. touch event没有发生在view B的bounds内,但发生在view C的bounds内,检查view C的subview D和subview E;
  3. touch event没有发生在view D的bounds内,但发生在view E的bounds内,而view E之下再无subviews,所以确定view E是所谓的hit-test view。

回到上述的引文,笔者阅读这段文字时产生了一个疑问:如果view A确实是view B的subview,但是view A所对应的区域不在view B的bounds之内的,譬如下图,那么在view A上发生的touch event还能被检测到吗?

20150215

P.S:经过笔者的测验,得到的结果是不能!

在viewDidLoad方法中添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
self.view.backgroundColor = [UIColor whiteColor];
 
UIView *lightGrayView = ({
UIView *view = [[UIView alloc] init];
view.backgroundColor = UIColor.lightGrayColor;
view;
});
 
[self.view addSubview:lightGrayView];
 
[lightGrayView makeConstraints:^(MASConstraintMaker *make) {
UIView *superView = self.view;
int padding = 10.0;
 
make.left.equalTo(superView.left).offset(padding);
make.right.equalTo(superView.right).offset(-padding);
make.height.equalTo(lightGrayView.width).offset(-padding);
make.centerY.equalTo(superView.centerY).offset(0);
}];
 
UIView *yellowView = ({
UIView *view = [[UIView alloc] init];
view.backgroundColor = UIColor.yellowColor;
view;
});
 
[lightGrayView addSubview:yellowView];
 
[yellowView makeConstraints:^(MASConstraintMaker *make) {
UIView *superView = lightGrayView;
 
make.width.equalTo(superView.width).multipliedBy(0.5).offset(0.0);
make.height.equalTo(yellowView.width).offset(0.0);
make.left.equalTo(superView.left).offset(0);
make.top.equalTo(superView.top).offset(-80);
}];
yellowView.userInteractionEnabled = YES;
[yellowView addGestureRecognizer:({
UITapGestureRecognizer *gesture =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(sayHello:)];
gesture.numberOfTapsRequired = 1;
gesture;
})];

P.S:代码中使用了第三方autolayout框架Masonry,结果如下图:

20150215-1

黄色view是灰色view的subview,但是黄色view并不全部包括在灰色view的bounds中,为黄色view添加一个tap gesture事件绑定,测试发现,当点击黄色view上半部分(其灰色bounds区域之外部分),tap event并未被触发,但是点击黄色view的下半部分,touch event会被触发。

故而,可以得出结论,如果view A是view B的subview,但view A并未在view B的bounds之内,则当view A的touch event发生在view B的bounds之外时,其响应其实并未发生(或说并不被承认)。

其实文档中也有相应的解释:

If the point passed into hitTest:withEvent: is not inside the bounds of the view, the first call to the pointInside:withEvent: method returns NO, the point is ignored, and hitTest:withEvent: returns nil. If a subview returns NO, that whole branch of the view hierarchy is ignored, because if the touch did not occur in that subview, it also did not occur in any of that subview’s subviews.This means that any point in a subview that is outside of its superview can’t receive touch events because the touch point has to be within the bounds of the superview and the subview.

找到Hit-test view又如何呢?hit-test view是第一个有机会对touch event进行处理的view;如果它不处理,则往上层走……这涉及responder chain这个概念。

The Responder Chain

responder chain在iOS中是一个非常重要的概念(相比在Android中也一样)。在了解responder chain这个概念之前,得先明白responder。文档如是说:

A responder object is an object that can responder to and handle events. The UIResponder class is the base class for all responder objects, and it defines the programmatic interface not only for event handling but also for common responder behavior.

UIApplication, UIViewController以及UIView这几个类的实例及其子类实例都是responders。

当iOS的hit-testing找到了发生的target view之后,event就在responder chain之间传送,第一个有机会处理event的responder当然是hit-testing view,如果它不能处理这个event,则会往上层(譬如super view)继续传送,但考虑到UIViewController和UIApplication的实例也是responder,所以可能传送路线不只是hit-testing view — super view — super view — super view — …那么简单,具体来说有两个的传递路线:

![QQ20150215-2]QQ20150215-2.png

为什么是两种呢?其实稍微看一下也很简单,第二种对应的是view controller嵌套的情况。

OK,回过头来看“获取当前view controller”的代码,第一个部分“get current window”没啥问题,第二部分代码(如下)就值得玩味了:

1
2
3
4
5
6
7
8
UIView *frontView = [[window subviews] objectAtIndex:0];
id nextResponder = [frontView nextResponder];
 
if ([nextResponder isKindOfClass:[UIViewController class]]) {
result = nextResponder;
} else {
result = window.rootViewController;
}

这段代码一般情况是没问题的,但根据不是特别好,因为这段代码执行的前提是key windows的subview只有一个,而不含其他的view,所以笔者稍作了一点修改:

1
2
3
4
5
6
7
8
9
10
11
// 2. get current View Controller
NSArray *subViews = [window subviews];
for (UIView *view in subViews) {
id nextResponder = [view nextResponder];
if ([nextResponder isKindOfClass:[UIViewController class]]) {
result = nextResponder;
}
}
if (result == nil) {
result = window.rootViewController;
}

OK,game over!关于responder,还有更多更“高端”的知识点,碰到了类似问题之后再说吧。

参考:
《Event Handling Guide for iOS》

posted @ 2015-03-19 17:39  脸大皮厚歌  阅读(331)  评论(0编辑  收藏  举报