iOS 点击事件分发机制
本文将简单介绍 iOS 的点击事件( TouchEvents )分发机制和一些使用场景。详解请看参考部分。
从以下两个方面介绍:
1. 寻找 hit-TestView 的过程(事件的传递过程)
2. 响应链(事件的响应过程)
一些应用场景:
- 一个内容是圆形的按钮(指定只允许视图的 frame 内某个区域可以响应事件)
- tabBar 上中间凸起的按钮(让超出父视图边界的子视图区域也能响应事件)
开始
寻找 hit-TestView 的过程的总结
在 iOS 中,当产生一个 touch 事件之后(点击屏幕),通过 hit-Testing 找到触摸点所在的 View( hit-TestView )。寻找过程总结如下(默认情况下):
寻找顺序如下:
1. 从视图层级最底层的 window 开始遍历它的子 View。
2. 默认的遍历顺序是按照 UIView 中 Subviews 的逆顺序。
3. 找到 hit-TestView 之后,寻找过程就结束了。
确定一个 View 是不是 hit-TestView 的过程如下:
1. 如果 View 的 userInteractionEnabled = NO,enabled = NO( UIControl ),或者 alpha <= 0.01, hidden = YES 等情况的时候,直接返回 nil(不再往下判断)。
2. 如果触摸点不在 view 中,直接返回 nil。
3. 如果触摸点在 view 中,逆序遍历它的子 View ,重复上面的过程。
4. 如果 view 的 子view 都返回 nil(都不是 hit-TestVeiw ),那么返回自身(自身是 hit-TestView )。
UIView 提供两个方法来来确定 hit-TestView:
// 返回一个 hit-TestView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
// 判断触摸点是否在 view 中
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event; // default returns YES if point is in bounds
hitTest:withEvent: 方法的具体实现可以写成这样:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
//1
if (self.alpha <= 0.01 || !self.userInteractionEnabled || self.hidden) {
return nil;
}
//2
if (![self pointInside:point withEvent:event]) {
return nil;
}
//3
NSEnumerator *enumerator = [self.subviews reverseObjectEnumerator];
for (UIView *subview in enumerator) {
UIView *hitTestView = [subview hitTest:point withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
//4
return self;
}
看完了理论,再结合实际,这样就好理解了。
以下讲解基于这样的视图层级结构
视图层级结构.png+-UIWindow
+-MainView
+-RedView
| +-UIButton
| +-UIButtonLabel
+-YellowView
+-UILabel
下面是测试过程中的一些日志(请结合上面的总结来分析):
ps:在实际项目中点击一次视图会打印两次下面的信息中间插入一次 UIStatusBarWindow 的信息,目前也不知道什么原因,如果有知道的请分享出来,非常感谢!
点击红色 View 时:
UIWindow:[hitTest:withEvent:]
----MainView:[hitTest:withEvent:]
--------YellowView:[hitTest:withEvent:]
--------RedView:[hitTest:withEvent:]
------------UIButton:[hitTest:withEvent:]
hit-TestView is RedView !
分析:
1. 先使用了 YellowView 的 [hitTest:withEvent:] 方法可以看出:默认的遍历顺序是按照 UIView(MainView) 中 Subviews 的逆顺序。
2. 当判断 YellowView 是不是 hit-TestView 的时候,判断触摸点不在 YellowView 上就不会再遍历它的子 View(UILabel) 了。
3. 触摸点在 RedView 上,所以会继续遍历它的子 View( UIButton ),触摸点不在 UIButton 上,所以返回 nil ( UIButton 不是 hit-TestView ),所以返回它本身( 是 hit-TestView )。
点击灰色 button 时:
UIWindow:[hitTest:withEvent:]
----MainView:[hitTest:withEvent:]
--------YellowView:[hitTest:withEvent:]
--------RedView:[hitTest:withEvent:]
------------UIButton:[hitTest:withEvent:]
----------------UIButtonLabel:[hitTest:withEvent:]
hit-TestView is UIButton !
根据上面的分析,触摸点在 RedView 上,所以会继续遍历它的子 View( UIButton ),触摸点在 UIButton 上,所以返回它本身( 是 hit-TestView )。
点击黄色 View 时:
UIWindow:[hitTest:withEvent:]
----MainView:[hitTest:withEvent:]
--------YellowView:[hitTest:withEvent:]
------------UILabel:[hitTest:withEvent:]
hit-TestView is YellowView !
分析:
触摸点在 YellowView 上,遍历它的子 View( UILabel ),触摸点不在 UILabel 上,所以返回 nil,所以 YellowView 是 hit-TestView。找到 hit-TestView 后,就不再检查 RedView 了。
点击 label 时:
UIWindow:[hitTest:withEvent:]
UIWindow pointInside:1
----MainView:[hitTest:withEvent:]
MainView pointInside:1
--------YellowView:[hitTest:withEvent:]
YellowView pointInside:1
------------UILabel:[hitTest:withEvent:]
hit-TestView is YellowView !
分析:
触摸点在 YellowView 上,所以遍历它的子 View( UILabel ),但是 UILabel 的 userInteractionEnabled = NO,所以返回 nil,这个时候其实还没有判断触摸点是不是在 UILabel上。
响应链
找到 hit-TestView 之后,事件就交给它来处理,hit-TestView 就是 firstResponder(第一响应者),如果它无法响应事件(不处理事件),则把事件交给它的 nextResponder(下一个响应者),直到有处理事件的响应者或者结束(传递到 AppDelegate 为止)。这一系列的响应者和事件的传递方向就是响应链(很形象)。在响应链中,所有响应者的基类都是 UIResponder,也就是说所有可以响应事件的类都是 UIResponder 的子类,UIApplication/UIView/UIViewController 都是 UIResponder 的子类。
ps: View 处理事件的方式有手势或者重写 touchesEvent 方法或者利用系统封装好的组件( UIControls )。
只要知道 nextResponder 是什么,就可以确定响应链了。
nextResponder 查找过程如下:
1. UIView 的 nextResponder 是直接管理它的 UIViewController (也就是 VC.view.nextResponder = VC ),如果当前 View 不是 ViewController 直接管理的 View,则 nextResponder 是它的 superView( view.nextResponder = view.superView )。
2. UIViewController 的 nextResponder 是它直接管理的 View 的 superView( VC.nextResponder = VC.view.superView )。
3. UIWindow 的 nextResponder 是 UIApplication 。
4. UIApplication 的 nextResponder 是 AppDelegate。
下面是测试过程中的一些日志:
点击红色 View 时:
------------------The Responder Chain------------------
RedView
|
MainView
|
ViewController
|
UIWindow
|
UIApplication
|
AppDelegate
------------------The Responder Chain------------------
分析:
1. RedView 不是 UIViewController 管理的 View,所以它的 nextResponder 是它的 superView( MainView )。
2. MainView 是 UIViewController 管理的 View,所以它的 nextResponder 是管理它的 ViewController。
3. ViewController 的 nextResponder 是它管理的 MainView 的superView( UIWindow )。
4. UIWindow 的 nextResponder 是 UIApplication。
5. UIApplication 的 nextResponder 是 AppDelegate。
一般来说,某个 UIResponder 的子类想要自己处理一些事件,就需要重写它的这些方法:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
响应链上的某个对象处理事件之后可以选择让事件传递继续下去或者终止,如果需要让事件继续传递下去则需要在 touchesBegan 方法里面,调用父类对应的方法:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
// Responding to Touch Events
[super touchesBegan:touches withEvent:event];
}
下面分享一个实际开发中的应用场景
场景:自定义一个这样的 tabBar,中间有个凸起一丢丢的 item。
UI的实现:自定义一个大小和 tabBar 一样的 View 覆盖在 tabBar 上,然后然后中间的 item 超出自定义 View 的边界,让自定义的 View 的 clipsToBounds 为 NO,把超出边界的部分也显示出来。
分析:
根据寻找 hit-TestView 过程的原理可以知道,如果点击超出边界的部分(凸起的那一丢丢)是不能响应事件的。
解决过程:
1. 打印view的层级
+-UIWindow
+-UILayoutContainerView
+-UITransitionView
| +-UIViewControllerWrapperView
| +-UILayoutContainerView
| +-UINavigationTransitionView
| | +-UIViewControllerWrapperView
| | +-UIView
| +-UINavigationBar
| +-_UINavigationBarBackground
| | +-_UIBackdropView
| | | +-_UIBackdropEffectView
| | | +-UIView
| | +-UIImageView
| +-UINavigationItemView
| | +-UILabel
| +-_UINavigationBarBackIndicatorView
+-MSCustomTabBar
+-_UITabBarBackgroundView
| +-_UIBackdropView
| +-_UIBackdropEffectView
| +-UIView
+-UITabBarButton
+-UITabBarButton
+-UITabBarButton
+-UITabBarButton
+-UIImageView
+-MSTabBarView
+-UIButton
| +-UIImageView
+-MSVerticalCenterButton
| +-UIImageView
| +-UIButtonLabel
+-MSVerticalCenterButton
| +-UIImageView
| +-UIButtonLabel
+-MSVerticalCenterButton
| +-UIImageView
| +-UIButtonLabel
+-MSVerticalCenterButton
+-UIImageView
+-UIButtonLabel
分析:(有点长,不过只要看 MSCustomTabBar 那部分就可以了)
- MSTabBarView 就是自定义覆盖在 MSCustomTabBar 上面的 View,它的子 ViewUIButton 就是中间凸起一丢丢的 item。
- 如果我们点击了 tabBar 的内部,寻找 hit-TestView 的时候是会查询自定义的 MSTabBarView 的,从而它的子 View 也会被查询,所以只要触摸点在 view 的范围内就可以响应事件了,所以没有任何问题。
- 如果我们点击了凸起的那一丢丢部分,寻找 hit-TestView 的时候,查询到 MSCustomTabBar 之后,由于触摸点不在它的内部,所以不会查询它的子 View( MSTabBarView ),所以凸起的那一丢丢是响应不了事件的。所以我们需要重写 MSCustomTabBar 的 [hitTest:withEvent:] 方法。
分析 view 的层级主要是为了确定在哪里重写 [hitTest:withEvent:] 方法。
2. 重写 [hitTest:withEvent:] 方法,让超出 tabBar 的那部分也能响应事件
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// 先使用默认的方法来寻找 hit-TestView
UIView *result = [super hitTest:point withEvent:event];
// 如果 result 不为 nil,说明触摸事件发生在 tabbar 里面,直接返回就可以了
if (result) {
return result;
}
// 到这里说明触摸事件不发生在 tabBar 里面
// 这里遍历那些超出的部分就可以了,不过这么写比较通用。
for (UIView *subview in self.tabBarView.subviews) {
// 把这个坐标从tabbar的坐标系转为subview的坐标系
CGPoint subPoint = [subview convertPoint:point fromView:self];
result = [subview hitTest:subPoint withEvent:event];
// 如果事件发生在subView里就返回
if (result) {
return result;
}
}
return nil;
}
分析:
如果触摸点在 tabBar 里面的时候,使用默认方法就可以找到 hit-TestView 了,所以先使用 [super hitTest:point withEvent:event] (因为我们是重写方法,所以使用 super 就是使用原始的方法)来寻找,如果找不到,说明触摸点不在 tabBar 里面,这个时候就需要我们手动的判断触摸点在不在超出的那一丢丢里面了。(其实只要判断凸起的 View 就可以了,不过遍历所有 子View 比较通用,如果有多个凸起的 view 也可以这么写),先把坐标转换为 子View 的坐标(这样才能使用默认的 [pointInside:withEvent:] 方法来判断触摸点是否在 view 里面),然后遍历 子View 调用默认的 [hitTest:withEvent:] 方法,如果触摸点在 view 的内部,就能找到 hit-TestView,如果遍历完所有 子View 都没有找到 hit-TestView 说明触摸点也不在凸起的那一丢丢里面,然后返回 nil 就可以了。
分享一个demo
非矩形区域的点击:比如一个圆角为宽度一半的Button,只有点击圆形区域才会响应事件。
圆形的 button.png分析:
因为触摸点在 View 内,想要限制 view 内的点击区域,所以重写 button 的 [pointInside:withEvent:] 这个方法。如下:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
// 圆形区域的半径
CGFloat maxRadius = CGRectGetWidth(self.frame)/2;
// 触摸点相对圆心的坐标
CGFloat xOffset = point.x - maxRadius;
CGFloat yOffset = point.y - maxRadius;
// 触摸点的半径
CGFloat radius = sqrt(xOffset * xOffset + yOffset * yOffset);
return radius <= maxRadius;
}
demo 比较简单,稍微动手一下就可以掌握了。