iOS 点击事件分发机制

本文将简单介绍 iOS 的点击事件( TouchEvents )分发机制和一些使用场景。详解请看参考部分。

从以下两个方面介绍:

1. 寻找 hit-TestView 的过程(事件的传递过程)
2. 响应链(事件的响应过程)

一些应用场景:

  1. 一个内容是圆形的按钮(指定只允许视图的 frame 内某个区域可以响应事件)
  2. 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。

自定义 tabBar.png

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 比较简单,稍微动手一下就可以掌握了。

参考:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,817评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,329评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,354评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,498评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,600评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,829评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,979评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,722评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,189评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,519评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,654评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,329评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,940评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,762评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,993评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,382评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,543评论 2 349

推荐阅读更多精彩内容