文章

iOS点击事件响应全过程

小球是一位码农,拿出他心爱的iPhone,解除锁屏,滑动屏幕找到今日头条APP,打开他并点击了其中一篇新闻,进入到了新闻详情页。这时他突然意识到,我从拿出手机到看到新闻详情,具体的实现细节是什么??

屏幕解锁

操作系统层面的,硬件,驱动,系统唤醒

触摸屏幕

userclickeventprogress

  1. 操作系统层面,硬件驱动处理
  2. IOKit.framework为系统内核库,封装事件为IOHIDEvent
  3. 通过mach port转发给SpringBoard.app(IPC进程间通信)。SpringBoard.app可以认为是桌面
  4. 如果当前手机就在桌面,例如用户滑屏,或是点击App icon,交由系统消化处理
  5. 当前存在活跃的APP,继续通过mach port转发,给到当前APP主线程
  6. 触发APP进程内,runloop中处理的Source1回调
  7. Source1触发Source0回调,将IOHIDEvent封装为UIEvent
  8. Source0调用UIApplication的sendEvent,将UIEvent传递给UIWindow
  9. 响应链逻辑:事件传递链和事件响应链

总结来说:触摸屏幕的整个过程如上图,硬件驱动唤醒系统的runloop,进程间通信传递到APP中,APP的runloop被唤醒,开始调动查找第一响应者,响应者通过消息转发机制找到响应的方法并执行。

响应链

能响应的控件,都是继承至UIResponder

UI继承结构

1955077-5ba49a0ea1553ec4

先着眼UIResponder,看看他和其他的同一层的兄弟类有什么区别,比如他和UIScreen、UIColor的区别。进入UIResonder.h文件,一眼就能看见,属性方法都是和用户的操作有关的内容,其中主要内容如下

  • responder相关,nextResponderbecomeFirstResponderresignFirstResponderisFirstResponder
  • touch相关,began、moved、ended、cancelled等
  • press相关,began、changed、ended、cancelled等
  • motion相关,began、ended、cancelled等

UIView和UIButton的区别

同样都是继承至UIResponder,这两个又有什么区别呢,为什么view不能点击呢?

首先,需要了解他们的继承关系:

NSObject->UIRespopnder->UIView->UIControl->UIButton

然后我们在看看UIView.hUIControl.hUIButton.h三个文件,很容易得出结论:

  • UIView是UIButton的父类
  • UIView的关键方法为:hitTest:withEvent:pointInside:withEvent:
  • UIControl的关键方法:addTarget:action:forControlEvents:
  • UIButton中的内容都是有关按钮控件界面的东西了
  1. UIView和CALayer的关系和区别?
  2. UIView中的frame和bounds的关系和区别?

查找第一响应者

iOS事件链有两条:Hit-Testing事件的传递链、事件的响应链:

  • 传递链:由系统向离用户最近的view传递。UIKit –> active app's event queue –> window –> root view –> …… –> lowest view

  • 响应链:由离用户最近的view向系统传递。initial view –> super view –> ….. –> view controller –> window –> Application –> AppDelegate

事件传递链

eventsend

  • 事件传递的两个核心方法

    - (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;   // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
    - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;   // default returns YES if point is in bounds
    

    详细实现如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    -(UIView *)hitTes:(CGPoint)point withEvent:(UIEvent *)event {
    	//判断自己是否能接受事件
    	if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) {
    	return nil:
    	}
    	// 判断当前点 在不在自己身上.
    	if (![self pointInside:point withEvent:event]) {
    		return nil;
    	}
    	// 查看自己是不是最合适的view,从后往前遍历自己的子控件.
    	int count = (int)self.subviews.count;
    	for (int i = count -1 ; i >= 0; i--) {
    		UIView *childView = self.subviews[i];
    		CGPoint childP = [self convertPoint:point toView:childView];
    		UIView *view = [childView hitTest:childP withEvent:event];
    		if (view) {
    			return view;
    		}
    	}
    	return self;
    }
    // 该方法判断触摸点是否在控件身上,是则返回YES,否则返回NO,point参数必须是方法调用者的坐标系
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {   
        CGFloat x1 = point.x;
        CGFloat y1 = point.y;
          
        CGFloat x2 = self.frame.size.width / 2;
        CGFloat y2 = self.frame.size.height / 2;
      
        //判断是否在圆形区域内
        double dis = sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
        if (dis <= self.frame.size.width / 2) {
            return YES;
        } else {
            return NO;
        }
    }
    
    • 第一个方法返回的是一个UIView,是用来寻找最终哪一个视图来响应这个事件

    • 第二个方法是用来判断某一个点击的位置是否在视图范围内,如果在就返回YES

    • 其中UIView不接受事件处理的情况有

      1. alpha <0.01
      2. userInteractionEnabled = NO
      3. hidden = YES
  • 流程概述

    1. 我们点击屏幕产生触摸事件,系统将这个事件加入到一个由UIApplication管理的事件队列中,UIApplication会从消息队列里取事件分发下去,首先传给UIWindow
    2. UIWindow中就会调用hitTest:withEvent:方法去返回一个最终响应的视图
    3. hitTest:withEvent:方法中就会去调用pointInside: withEvent:去判断当前点击的point是否在UIWindow范围内,如果是的话,就会去遍历它的子视图来查找最终响应的子视图
    4. 遍历的方式是使用倒序的方式来遍历子视图,也就是说最后添加的子视图会最先遍历,在每一个视图中都回去调用它的hitTest:withEvent:方法,可以理解为是一个递归调用
    5. 最终会返回一个响应视图,如果返回视图有值,那么这个视图就作为最终响应视图,结束整个事件传递;如果没有值,那么就会将UIWindow作为响应者

事件响应链

eventanswer

  • 响应者链的事件传递过程总结如下

    1. 响应链是通过nextResponder属性组成的链表
    2. 在事件的响应中,如果某个控件实现了touches...方法,则这个事件将由该控件来接受,如果调用了[super touches….];就会将事件顺着响应者链条往上传递,传递给上一个响应者;接着就会调用上一个响应者的touches….方法。
    3. 如果view的控制器存在,就传递给控制器处理;如果控制器不存在,则传递给它的父视图
    4. 在视图层次结构的最顶层,如果也不能处理收到的事件,则将事件传递给UIWindow对象进行处理
    5. 如果UIWindow对象也不处理,则将事件传递给UIApplication对象
    6. 如果UIApplication也不能处理该事件,则将该事件丢弃
  • 如果button上面叠加添加了手势,手势会覆盖住不执行target方法,除非设置手势的cancelsTouchesInView = NO

    如果button上有subview,subview需要设置userInteractionEnabled=NO https://segmentfault.com/q/1010000004153263

  • 通过在Button子类中重写touches的方法,发现如果不调用supertouches对应的方法则不会响应点击事件。由此可以大致推断出UIControl其子类响应点击原理大致为:根据添加target:action:时设置的UIControlEvents,在touches的合适方法调用target的action方法。

  • 通过重写tableView子类touches方法,发现如果不调用supertouches对应的方法则不会走tableview:didSelectRowAtIndexPath:方法。由此可以大致推断出UIScrollView其子类是在其touches方法中处理点击事件的。

  • gestureRecognizer优先级更高 手势和touch的关系

  • 手势和Control的关系

eventanswer2

响应链应用示例

  1. 扩大Button的点击区域
1
2
3
4
5
6
  - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
      if (CGRectContainsPoint(CGRectInset(self.bounds, -20, -20), point)) {
          return YES;
      }
      return NO;
  }
  1. view超出了父viewbounds响应事件

    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
    
    - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
        BOOL flag = NO;
        for (UIView *view in self.subviews) {
            if (CGRectContainsPoint(view.frame, point)){
                flag = YES;
                break;
            }
        }
        return flag;
    }
       
    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
            return nil;
        }
        if ([self pointInside:point withEvent:event]) {
            for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
                CGPoint convertedPoint = [subview convertPoint:point fromView:self];
                UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
                if (hitTestView) {
                    return hitTestView;
                }
            }
            return self;
        }
        return nil;
    }
    
  2. 如果Button被一个View遮住,在触摸View时,希望该Button能够响应事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    
    // in View.m里
    // 击View及View的非交互子View(例如UIImageView),则该Button可以响应事件
    - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
        BOOL next = YES;
        for (UIView *view in self.subviews) {
            if ([view isKindOfClass:[UIControl class]]) {
                if (CGRectContainsPoint(view.frame, point)){
                    next = NO;
                    break;
                }
            }
        }
        return !next;
    }
       
    // in View.m里
    // 点击View本身Button会响应该事件,点击View的任何一个子View,Button不会响应事件
    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        UIView *view = [super hitTest:point withEvent:event];
        if (view == self) {
            return nil;
        }
        return view;
    }
    

消息转发

详细见下一篇文章

本文由作者按照 CC BY 4.0 进行授权