大数据

最好的输入方式:iOS中的触摸事件

乔帮主在发布会上提到,用户的手才是最好的输入设备,的确,iPhone之后,非触屏手机再已难觅。触摸是最基本的用户输入事件,理解iOS特有的触摸事件响应机制,能够良好管理程序中触摸响应方法,避免冲突的发生。

iOS中的事件

iOS中的事件主要分为三类:

  1. UIControl Actions: 使用target/action注册的SEL。
  2. User Events: 用户与应用之间的交互:触摸,输入文字,摇晃,远程控制等。
  3. System Events: 应用启动,切前后台,低内存等。
    cocoa和cocoa touch的程序启动后,,会首先初始化一些基本资源:在主线程创建一个main event loop;初始化主UIWindow

    应用启动过程-w500

    main event loop本质上是一个NSRunLoop,与其他辅助线程的run loop不同,其是自创建后自动开始运行的。主消息循环最大的特点是:它在创建时就与负责捕获用户事件的系统底层建立了连接,所以它的input source可以收到系统传递过来的用户事件。UIApplication对象会将当前要处理的用户事件封装成UIEvent,发送给UIWindow,在由UIWindow转发给对应的响应者。

    iOS响应用户事件

    UIEvent表示用户与iOS产生交互的事件,UIWindow将触摸事件发送给hitTest View,其他事件发送给first responder,若它们不能处理该事件,事件在响应链向上传递,找到最终的响应者或丢弃。
    本文主要介绍触摸事件的响应机制。

    iOS中能够捕获触摸事件的类

    iOS程序中,有三种类可以接受用户的触摸事件并响应,分别是:UIControl, UIReponder, UIGestureRecognizer,这三个类在参与触摸响应机制的时机不同,在实际使用时要加以注意。

    iOS中的触摸事件

    iOS中使用UItouch来表示用户的一根手指在屏幕上的触摸行为。当用户触摸屏幕时,硬件会捕捉到触摸行为,将触摸点的半径、力度和坐标等发送给iOS,经过UIKit封装后,得到UITouch对象。通过UITouch对象,我们可以获得其关联的视图(hitTest View),在视图中的坐标,生命周期的当前阶段,点击数等信息。。一次用户点击多次的事件,其只包含一个UITouch
    触摸类型的UIEvent包含至少一个UITouch,也就是用户在屏幕上的一次手势操作的手指运动,其会持有此次事件相关联的UITouches序列。,即在一次手势操作中,其中一个手指中途离开屏幕,它所对应的UITouch依然存在于该事件中。响应者会在touchesBegan:withEvent:等方法中获取UITouch对应的UIEvent
    UITouches序列在用户第一根手指触摸屏幕时开始,最后一根手指离开时结束,当手指状态变化时,iOS会将序列中的UITouch对象发送给UIEvent对象。

    一个UItouches序列和UItouch的不同生命周期

    iOS的触摸事件响应机制

    当用户触摸屏幕时,对应的触摸事件会加入到UIApplication事件队列中,当下一个RunLoop来临时,UIApplication会将出列最前端的事件,发送给当前的UIWindow(key window)。
    UIWindow会调用hitTest:withEvent:方法,开始hit-testing流程寻找包含触摸点的视图。该流程会返回包含触摸点的层级最低的视图。
    每当用户触摸屏幕时,UIKit都会执行hit-testing,之后再从hitTest视图开始寻找事件的响应者。当hitTest视图决定后,它就关联了对应的触摸事件,会持续收到触摸事件生命周期的方法,(touchBegan, touchMove, touchCancel/touchEnd),即使是触摸点已经在touchMove阶段移出了hitTest视图,它依然能够收到后续的消息。

    Note: A touch object is associated with its hit-test view for its lifetime, even if the touch later moves outside the view.

iOS的触摸事件响应机制

hit-testing流程

iOS中hit-testing使用逆前序的深度遍历算法来确定用户点按的最低层级(最靠近用户)的视图,该hitTest视图是触摸事件的响应链头结点。
逆前序的深度遍历算法:根节点–>右子树–>左子树。
当收到触摸事件后,UIApplication在当前视图层级中,从key window开始(最顶级),从上往下遍历子视图调用hitTest:withEvent:,若找到hitTest视图则停止遍历并返回。
当视图收到hitTest:withEvent:方法后,通过下列条件判断是否在该视图执行hit-testing。

  1. pointInside:withEvent:方法返回YES。pointInside:withEvent:方法用来判断触摸点是否在当前视图内。
  2. hidden == NO。
  3. userInteractionEnabled == YES。
  4. alpha >= 0.01。若view的content绘制为透明的,则不受影响。

需要注意的是,当clipsToBounds == NO时,视图的子视图可能会超出其bounds,这种情况如果触摸点在子视图超出父视图的范围,那么hit-tesing不会再此视图树上执行。

hit-testing

如图,当用户触摸viewB.1时,UIApplication对象收到触摸事件,从key window开始执行hit-testing,首先访问viewC,由于pointInside:withEvent:方法返回NO,取消执行并访问viewB,满足执行,则从右往左开始访问其子视图(视图层级从下往上),找到viewB.1,它没有子视图,则返回自己。最终UIWindow对象将viewB.1作为hitTest视图返回给UIApplication对象。

hit-testing流程图

可以看到,当某一视图收到hitTest:withEvent:方法后,它会向所有子视图发送hitTest:withEvent:方法,若它的没有子视图或所有子视图返回nil,那么就返回自己,所有hit-testing流程最终一定会找到一个对象UIView/UIWindow去接收触摸事件。
以下是hitTest:withEvent:可能的实现。

- (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;
}

responder chain

responder chain是UIResponder对象组成的链形结构,它以first responder为头结点,UIApplication对象为尾节点,事件从头开始在响应链中向上传递。
UIResponder用来设计处理事件,UIApplication, UIViewController, UIView都是其子类,只要它们实现了UIResponder中的钩子方法,就可以响应对应的事件。

UIResponder的继承关系

其中first responder用来第一个接触事件,可以使用becomeFirstResponder来设置它,主要要在视图层级已经完全建立之后再设置。

If you try to assign the first responder in viewWillAppear:, your object graph is not yet established, so the becomeFirstResponder method returns NO

默认情况下,fist responder是当前UIWindow中最有可能响应事件的UIView,这由UIkit决定。
iOS中大部分的事件都依赖响应链来找到最终的响应者,在UIResponder的头文件中可以看到,Touch events,Motion events,Remote events,UIControl Action,Text editing,press events等事件都可以在响应链中传递。

寻找响应对象

UIApplication在处理的事件时,触摸事件会交给hitTest view开始的响应链处理,其他的动作事件,远程事件,系统事件等,会交给first responder开始的响应链处理。
UIKit会将用户事件发送给理论上最合适的对象。所以当程序中的响应者要经过很长的查找路径时,这时就要考虑是否实现是否设计合理了。

UIKit first sends the event to the object that is best suited to handle the event. For touch events, that object is the hit-test view, and for other events, that object is the first responder

对于触摸事件,hit-test视图获得了最先接受触摸对象的机会,但如果它不能处理对应的触摸事件,那么UIKit会沿着以hit-test开头的响应链寻找能够最终的响应者。

The responder chain on iOS

当找到响应者或已经到链尾(UIApplication)仍不能处理,UIKit会停止查找,对于后者,对应的事件会被丢弃。

除了UIResponder对象,UIGestureRecognizerUIControl也可以响应触摸事件,但它们参与触摸事件响应的方式不同。

  1. UIGestureRecognizer在响应链中的位置取决于依附的视图。
  2. UIControl参与响应的方式决定于其关联的target。
    UIGestureRecognizer要先于视图收到触摸事件,但需要注意的是,若该视图也可以响应触摸事件(实现了UITouch生命周期函数),那么手势对象并不会阻碍视图的响应,双方是同时响应的,只不过存在先后顺序。

    UIGestureRecognizer与UIView的接触事件的次序

    响应触摸事件

    当确定了响应链后,UIWindow会向hitTest View发送以下方法:
    ```

  3. (void)touchesBegan:(NSSet )touches withEvent:(UIEvent )event;
  4. (void)touchesMoved:(NSSet )touches withEvent:(UIEvent )event;
  5. (void)touchesEnded:(NSSet )touches withEvent:(UIEvent )event;
  6. (void)touchesCancelled:(NSSet )touches withEvent:(UIEvent )event;
    ``
    这是
    UIResponder用于响应触摸事件的方法,这些钩子方法的默认实现是向nextResponder转发方法。
    当触摸事件在响应链上传递时,判断当前
    UIResponder能否响应的条件是:其是否实现了touchesBegan方法。
    在这些
    UITouches序列的生命周期方法中,我们可以获取对应UIEventUITouch`,利用它们所提供的信息,进一步决定如何响应用户的触摸事件。