iOS 触摸、事件和响应

iOS 触摸、事件和响应_第1张图片
touch

掏出手机->解锁手机->点开APP->低头刷手机,这一套流程是现在每个手机党每天重复最多的一套连击操作,那么作为开发人员的你有没有考虑过,你点击触摸手机屏幕的时候,手机系统做了些什么可以及时响应用户呢?

一.触摸&&事件&&响应者
1.触摸

触摸是这一切的源头,这是用户跟手机最直接的交互方式,这个物理行为对应到代码层面就是一个UITouch对象

  • 一根手指触摸屏幕一次就生成一个UITouch对象。相应的,如果是多根手指触摸屏幕的话就会生成多个UITouch对象。
  • 如果是多根手指先后触摸的话,系统会根据触摸位置来判断是否应该更新同一个UITouch对象。
  • 如果两根手指先后触摸同一位置,或者一个时间间隔内同一根手指同时触摸同一位置的时候,系统会在第一次触摸的时候生成一个UITouch对象,第二次触摸的时候会更新UITouch对象的TapCount值(1->2),当然了位置不一样的话就会生成两个没有关系的UITouch对象。
  • 手指离开屏幕并在一定时间内没有再次点击同一个位置的话,该UITouch对象就会被释放,使用代码测试的这个一定的时间大概是1.56毫秒。
  • UITouch对象中包含很多信息,包括但是不限于时间戳、状态、点击次数、类别、力度等等,更多信息可以参考文档。
@property(nonatomic,readonly) NSTimeInterval      timestamp;
@property(nonatomic,readonly) UITouchPhase        phase;
@property(nonatomic,readonly) NSUInteger          tapCount;   // touch down within a certain point within a certain amount of time
@property(nonatomic,readonly) UITouchType         type NS_AVAILABLE_IOS(9_0);

// majorRadius and majorRadiusTolerance are in points
// The majorRadius will be accurate +/- the majorRadiusTolerance
@property(nonatomic,readonly) CGFloat majorRadius NS_AVAILABLE_IOS(8_0);
@property(nonatomic,readonly) CGFloat majorRadiusTolerance NS_AVAILABLE_IOS(8_0);

@property(nullable,nonatomic,readonly,strong) UIWindow                        *window;
@property(nullable,nonatomic,readonly,strong) UIView                          *view;
@property(nullable,nonatomic,readonly,copy)   NSArray  *gestureRecognizers NS_AVAILABLE_IOS(3_2);

注意:使用demo去查看UITouch对象生成的过程中,需要把UIView的属性multipleTouchEnabled置为true,否则UIView每次只能响应一个触摸,不管几根手指。。(别问我是咋知道的)

2.事件

触摸等操作的目的是为了生成事件以供响应者处理,一个事件对应一个UIEvent对象。

  • iOS系统中的事件分很多种,每种事件对应的交互也不一样,通过UIEvent的type属性就可以区分出来,
typedef NS_ENUM(NSInteger, UIEventType) {
    UIEventTypeTouches,
    UIEventTypeMotion,
    UIEventTypeRemoteControl,
    UIEventTypePresses NS_ENUM_AVAILABLE_IOS(9_0),
};

最常见的就是由触摸产生的触摸事件,还有按压(3D touch)事件、远程控制事件和硬件运动事件。

  • 以触摸事件为例,一个触摸事件可能是有多个触摸或者说是有多根手指触摸产生的,而触摸的对象都是可以通过事件对象的allTouches获取到。
3.响应者

事件产生后只有两种结果,要么就是被第一响应者接收并处理掉或者传递给别的响应者并处理掉,要么就是被第一响应者接收、传递但是这一过程中没有处理掉而被直接丢弃释放掉,这两种结果都需要一个重要的角色那就是响应者(UIResponder)。
响应者可以使任何继承自UIResponder的子类,像常见的UIViewUIViewControllerAppDelegateUIApplication等等。开发者可以通过UIResponder中的API去监听事件的生命周期,例如触摸事件的相关的几个API。

// Generally, all responders which do custom touch handling should override all four of these methods.
// Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
// touch it is handling (those touches it received in touchesBegan:withEvent:).
// *** You must handle cancelled touches to ensure correct behavior in your application.  Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet *)touches NS_AVAILABLE_IOS(9_1);
二.接收事件&&传递事件&&处理事件

触摸屏幕到最后响应触摸事件整个过程看似在瞬间就完成了,其实这中间还是分很多步骤的,大体可以分为两个过程。(本文涉及到的触摸事件处理过程更多的属于软件层次,不涉及硬件层次)(Touch Demo)

1.寻找事件的第一响应者

当前应用程序接收到触摸事件后,会把触摸(UITouch)包裹在事件对象(UIEvent)中,再把该事件对象插入到一个由应用程序的UIApplication维护的事件队列中,并由UIApplication按照队列顺序依次分发。一次分发一个事件,但是响应者(UIResponder)众多,那事件到底应该分发给哪一个响应者呢?所以这第一步就是寻找第一响应者的这么一个过程。(疑问点:我一直没弄明白这个事件分发队列是由系统维护的还是由当前应用程序的UIApplication维护的,若是由当前应用程序的UIApplication维护的话,那如果此时手机在桌面上的时候呢?这时候没有启动任何APP的,难道桌面其实也是一个APP??有大佬可以解惑下吗?欢迎在评论区留言。。)

查找第一响应者的过程本质上就是不断调用UIView的API去做试探

注意这里还不涉及到UIResponder的API哦!涉及的关键API有如下,而且都是UIView的API,所以寻找第一响应者的过程也叫做hitTest过程。

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

简述一下查找第一响应者的过程就是:

  • 触摸事件被UIApplication接收后,会被插入到事件队列中等待分发。
  • UIApplication会将队列中的事件按照FIFO的顺序将事件出队列,首先分发给UIWindow对象,后显示的UIWindow对象优先级更高。
  • 若UIWindow对象不能响应事件,则将事件传递给同级的其他UIWindow对象,若可以响应事件,则按照子视图的添加顺序,优先询问后添加的子视图。
  • 若子视图不能响应事件,则将事件传递给同级的上一个子视图,若能响应,就遍历子视图,按照子视图的添加顺序,优先询问后添加的子视图。
  • 循环上面的操作,若当前视图能响应事件,但是子视图无法响应事件,那么当前视图就是最合适的响应者。
    上面的过程中涉及到两个问题:

a.如何判断当前视图能不能响应事件呢?

视图在以下情况下是不能响应事件的:

  • 不允许交互:userInteractionEnabled = NO
  • 隐藏:hidden = YES
  • 透明度小于等于0.01:alpha <= 0.01
  • 触摸点是否在视图的坐标范围内,通过下面的API判断:
// default returns YES if point is in bounds
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;   

b.视图如何把事件传递给子视图呢?

把事件传递的方式子视图的方式就是让循环子视图并让子视图调用hitTest:withEvent:,返回的UIView对象,就是当前视图层次中的响应者。

  • 若当前视图无法响应事件,则返回nil
  • 若当前视图可以响应事件,按照子视图添加顺序从后往前遍历子视图,获取子视图中的事件响应者,并返回
  • 若当前视图可以响应事件,但是无子视图可以响应事件,那么就返回当前视图作为响应者
    以Touch Demo为例:
    iOS 触摸、事件和响应_第2张图片
    Touch Demo

    Demo中的视图结构如下,从从下往上是父子关系,从左到右为兄弟视图,左边比右边先加入父视图
    iOS 触摸、事件和响应_第3张图片
    Demo视图层级

    点击E视图,查看日志如下,可以看到各个视图中hitTest:withEvent:pointInside:withEvent:的调用顺序,但是有一个很奇怪的现象就是,这个hitTest过程执行的两遍,这个暂时没弄清楚,欢迎在评论区留言。。
    iOS 触摸、事件和响应_第4张图片
    点击日志

    综合Touch Demo推测hitTest:withEvent:的实现代码:
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event{
    if (!self.userInteractionEnabled ||
        !self.hidden ||
        self.alpha <= 0.01 ||
        ![self pointInside:point withEvent:event]) {
        //当前视图无法响应事件
        return nil;
    }
    
    __block UIView *fitView = self;
    __weak typeof(self) weakSelf = self;
    [self.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull subView, NSUInteger idx, BOOL * _Nonnull stop) {
        CGPoint convertPoint = [weakSelf convertPoint:point toView:subView];
        UIView *subFitView = [subView hitTest:convertPoint withEvent:event];
        if (subFitView) {
            fitView = subFitView;
            *stop = YES;
        }
    }];
    
    return fitView;
}
2.事件的响应及传递

第一响应者找到后,它对事件具有处理权,它可以选择自己处理事件,也可以将事件传递给下一个响应者,而这个由响应者构成的视图链就称之为响应链。(敲黑板!这是重点,考试要考的。。)
响应者对于事件的拦截和传递都是通过touchesBegan:withEvent:方法控制的,任何一个UIResponder的对象都可以是响应者,对象中都会默认实现该方法,但是默认不会对事件做任何处理,只是将事件沿响应链传递,所以想要做自定义操作就需要重写方法。响应者一般对事件的处理方式有:

  • 默认操作,事件沿着默认的响应链向下传递
  • 处理事件并终止事件的传递
  • 处理事件,让事件继续往下传递

后两者的区别就在于有没有调用父类的touchesBegan:withEvent:方法。

响应链中的事件传递规则又是什么样的呢?

UIResponder有一个nextResponder API,用于获取当前响应者在响应链中的下一个响应者,因此第一响应者确定后,默认的响应链都已经通过nextResponder串起来了。
默认的nextResponder的规则

  • 若一个视图(UIView对象)是一个视图控制器(UIViewController对象)的根视图,那么该视图的nextResponder就是该视图控制器。否则该视图的nextResponder就是其父视图。
  • 若一个视图控制器(UIViewController对象)是window(UIWindow对象)的rootViewController,那么该视图控制器的nextResponder就是该window。否则其nextResponder为presenting view controller。
  • window(UIWindow对象)的nextResponder为UIApplication对象。
  • 若当前应用的app delegate是一个UIResponder对象,且不是UIView、UIViewController或app本身,则UIApplication的nextResponder为app delegate。
    以Touch Demo为例,通过nextResponder打印的响应链日志
    如下:
2019-05-18 23:49:09.869219+0800 LYTouchDemo[70497:6617796] LYEView -> LYCView -> LYAView -> LYRootView -> UIViewController -> UIViewControllerWrapperView -> UINavigationTransitionView -> UILayoutContainerView -> UINavigationController -> LYWindow -> LYApplication -> AppDelegate
iOS 触摸、事件和响应_第5张图片
响应链
总结

以上就是我对触摸到响应事件的探究,总结下来就是:自下往上的找第一响应者,然后自上往下的传递响应事件,那么系统的触摸就这么多吗?当然不是,除了UIResponder外,UIController和UIGestureRecognizer对事件也有处理能力,关于这两者的探究,后期有时间再填这个坑吧!

2019.5.12更新
针对寻找事件的第一响应者中的疑问点有大神提出了解答:

事件响应分为系统响应阶段 和app响应阶段 ,如果发生触摸事件时是在桌面,此时会触发系统进程的runloop,由桌面系统处理,如果触摸发生时在某app,则由桌面系统交给当前app进程处理(由当前应用程序的UIApplication维护)。

你可能感兴趣的:(iOS 触摸、事件和响应)