事件响应链学习整理

2016-06-23 » iOS开发基础

在iOS中不是任何对象都能处理事件,只有继承了UIResponder的对象才能接收并处理事件。我们称之为“响应者对象”,UIApplication、UIViewController、UIView都继承自UIResponder,因此它们都是响应者对象,都能够接收并处理事件。

事件分类

1、触摸事件: 通过触摸、手势进行触发(例如手指点击、缩放、旋转、按压)


//一根或者多根手指开始触摸view,系统会自动调用view的下面方法
open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)

//一根或者多根手指在view上移动,系统会自动调用view的下面方法(随着手指的移动,会持续调用该方法)
open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)

//一根或者多根手指离开view,系统会自动调用view的下面方法
open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)

//触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,系统会自动调用view的下面方法
open func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)

长按API @available(iOS 9.0, *)

//开始按压的时候调用
open func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?)

// 按压改变的时候调用
open func pressesChanged(_ presses: Set<UIPress>, with event: UIPressesEvent?)

// 按压结束的时候调用
open func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?)

// 当系统发出取消按压事件的时候调用
open func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?)


2、运动事件:通过加速器进行触发(例如手机晃动)

3、远程控制事件:通过其他远程设备触发(例如耳机控制按钮)

2、运动事件(加速事件):通过加速器进行触发(例如手机晃动)

//运动开始时执行
override func motionBegan(_ motion: UIEvent.EventSubtype, with event: UIEvent?)

//运动结束后执行
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?)

//运动被意外取消时执行
override func motionCancelled(_ motion: UIEvent.EventSubtype, with event: UIEvent?) 

3、远程控制事件:通过其他远程设备触发(例如耳机控制按钮)

//接收到远程控制消息时执行
func remoteControlReceived(with event: UIEvent?)

事件我们需要理解两个对象: UITouch、UIEvent,响应者我们需要理解 UIResponder

UIResponder、UITouch 和 UIEvent

在UIKit中,UIApplication、UIView、UIViewController这几个类都是直接继承自UIResponder类。因此UIKit中的视图、控件、视图控制器,以及我们自定义的视图及视图控制器都有响应事件的能力。这些对象通常被称为响应对象,或者是响应者(以下我们统一使用响应者)。

UIResponder提供了四个用户点击的回调方法,分别对应用户点击开始、移动、点击结束以及取消点击,其中只有在程序强制退出或者来电时,取消点击事件才会调用。

open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)

open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)

open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)

open func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)

在自定义UIView为基类的控件时,我们可以重写这几个方法来进行点击回调。在回调中,我们可以看到方法接收两个参数,一个UITouch对象的集合,还有一个UIEvent对象。这两个参数分别代表的是点击对象和事件对象。

UIEvent

1、iOS使用UIEvent表示用户交互的事件对象,在UIEvent中,我们可以看到有一个type: UIEventType类型的属性,这个属性表示了当前的响应事件类型。

2、一个 UIEvent 代表一个事件,它是比 UITouch 更为抽象的对象,相当于把 UITouch 或者其他行为包装了一下。 一个 UIEvent 可以包含一个 UITouch(单指触控)或者 多个 UITouch (多指触控)。也可以包含设备(如 iPhone )晃动、远程遥控(如通过耳机线调整音量)等行为。

3、在一个用户点击事件处理过程中,UIEvent对象是唯一的。

UITouch

1、一个 UITouch 对象, 就是一根手指触摸一次屏幕。 它包含了 “触摸屏幕 — 滑动 — 离开屏幕” 整个过程。 所以 UITouch 有个 phase: UITouchPhase 属性,记录了 整个过程的所有3个状态(began、moved、ended)。由于一个电话或者其他事件可能会突然中断用户的操作,所以 UITouch 还外有 1 个取消状态 (cancelled)。以及1个 stationary(少出现)。

2、一个 UITouch 每当进入一次新的状态,它的一些显而易见的属性都会随之变化。如,位置、前一个位置、时间戳(timestamp: NSTimeInterval ,简单来讲,时间戳记录了自从上次开机的时间间隔)。不过当一个 UITouch 表面上从一个 View 移动到另一个 View 上时,UITouch 的 view 和 window 属性也不会变化。换句话说,UITouch 一发生就和 UITouch 最开始发生的( initial )的 view 绑定了。
3、每次点击发生的时候,点击对象都放在一个集合中传入前面列的四个 UIResponder的回调方法中,我们通过集合中对象获取用户点击的位置。

    - (CGPoint)locationInView:(nullable UIView *)view获取当前点击坐标点,
    - (CGPoint)previousLocationInView:(nullable UIView *)view获取上个点击位置的坐标点。

4、如果一个 UITouch 紧接着上一个 UITouch 发生,只要满足两个 UITouch 在一定时间、一定范围的条件,那么第二个 UITouch 就不算一个完全独立的 UITouch。一个明显的属性是 tapcount,即点击次数,其实这里理解成第几次点击更为确切。tapCount 至少为 1,可以为 2,3,4... ... 等等。他们分别意味着单击,双击,三连击等等动作。UITouch 有个 UIGestureRecognizers 数组,里面装了所有接受该 UITouch GR。如果没有 GR 接收,那么该数组为空。

事件响应链

窗口对象使用点击检测(hit-testing)以及响应链(responder chain) 来查找接收该触摸事件的视图。

1、发生触摸事件后,系统会将该事件加入到一个由UIApplication管理的事件队列中

2、UIApplication会从事件队列中取出最前面的事件,并将事件分发处理,通常,先发送事件给程序的keyWindow

3、keyWindow会在视图层次中找到对应的视图处理触摸事件,这也就是响应者查找链

4、找到对应视图后,会调用视图控件对应的touch方法响应具体操作

响应链

IOS获取到了用户进行了“单击”这一行为,操作系统把包含这些点击事件的信息包装成UITouch和UIEvent形式的实例,然后找到当前运行的程序,逐级寻找能够响应这个事件的对象,直到没有响应者响应。这一寻找的过程,被称作事件的响应链。

点击检测 和 点击判断

//用来寻找最合适的View处理事件,只要一个事件传递给一个控件就会调用控件的hitTest方法,参数point 表示方法调用者坐标系上的点
open func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? 

//用来判断当前这个点在不在方法调用者上,点必须在方法调用者的坐标系中,判断才会准确
open func point(inside point: CGPoint, with event: UIEvent?) -> Bool 

UIApplication会从事件队列中取出触摸事件并传递给key window(当前接收用户事件的窗口)处理 , window对象首先会使用hitTest:withEvent:方法寻找此Touch操作初始点所在的视图(View) 如下Demo:

ViewOne里面有一个ViewTwo,ViewTwo里面有一个ViewThree。

三个View 都重写 hitTest、point、touchesBegan、touchesEnded方法 如下:

//MARK: - hitTest 查找
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    print("进入\(self.classForCoder) hitTest方法")
    let view = super.hitTest(point, with: event)
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "HH:mm:ss SSS"

    print("\n当前View : \(self.classForCoder)\n返回的View : \(view?.classForCoder)\n\(event)\n\(dateFormatter.string(from: Date()))\n");
    return view
}
//MARK: - 检查是否在点击范围
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    print("进入\(self.classForCoder) point方法")
    let result = super.point(inside: point, with: event)
    print("是否点击在\(self.classForCoder) 区域范围内 - \(result)")
    return result
}

//MARK: - 触摸事件开始
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    print("\(self.classForCoder) touch begin")
    var next = self.next;
    var str = "-"
    while next != nil {
        print("\(str) \(next!.classForCoder)")
        next = next?.next
        str = str + "-"
    }
}
//MARK: - 触摸事件结束
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    print("\(self.classForCoder) touch end")
}

点击ViewTwo中的非ViewThree区域(蓝色可见区域),如下打印结果 :

进入ViewOne hitTest方法
进入ViewOne point方法
是否点击在ViewOne 区域范围内 - true
进入ViewTwo hitTest方法
进入ViewTwo point方法
是否点击在ViewTwo 区域范围内 - true
进入ViewThree hitTest方法
进入ViewThree point方法
是否点击在ViewThree 区域范围内 - false

当前View : ViewThree
返回的View : nil
Optional(<UITouchesEvent: 0x6000000f5a80> timestamp: 362942 touches: {(
)})
14:46:24.203

当前View : ViewTwo
返回的View : Optional(ShareDemo.ViewTwo)
Optional(<UITouchesEvent: 0x6000000f5a80> timestamp: 362942 touches: {(
)})
14:46:24 204

当前View : ViewOne
返回的View : Optional(ShareDemo.ViewTwo)
Optional(<UITouchesEvent: 0x6000000f5a80> timestamp: 362942 touches: {(
)})
14:46:24 204

ViewTwo touch begin
- ViewOne
-- UIView
--- HitTestViewController
---- UIViewControllerWrapperView
----- UINavigationTransitionView
------ UILayoutContainerView
------- UINavigationController
-------- UIWindow
--------- UIApplication
---------- AppDelegate

ViewTwo touch end

我们可以看到,整个执行顺序是:

1、先到最底层的ViewOne 的hitTest , 通过ViewOne 中得 point方法判断是否在ViewOne点击区域内

2、返回结果 true, 继续调用ViewOne 的subView 也就是ViewTwo的 hitTest ,  再通过ViewTwo 中的 point方法判断是否在ViewTwo点击区域内。

3、返回结果true ,继续调用ViewTwo的subView 也就是ViewThree的 hitTest , 再通过ViewThree 中的 point方法判断是否在ViewThree点击区域内。

4、返回结果false,点击区域不在ViewThree范围内,ViewThree即使存在subView,subView的hitTest也不会再调起,ViewThree的hitTest直接返回nil

5、ViewTwo的hitTest接收到自己的subView 也就是ViewThree返回的nil,并且自己没有其他subView,所以直接返回自身 ViewTwo

6、ViewOne的hitTest接收到自己的subView 也就是ViewTwo返回的 View (ViewTwo),一样返回出去这个对象,一直到事件处理(key window),同第5点,如果ViewOne接收到的返回结果是nil,那么它将返回自身。

在打印记录的最底下,打印了一个nextResponder 树,可以清晰看到整个响应链。


还有,整个响应链的UITouch都是同一个

其他

以下视图的hitTest:withEvent:方法会返回nil,导致自身和其所有子视图不能被hit-testing发现,无法响应触摸事件:

1.隐藏(hidden=YES)的视图

2.禁止用户操作(userInteractionEnabled=NO)的视图

3.alpha<0.01的视图

4.视图超出父视图的区域

关于事件响应链的应用

既然系统通过hitTest:withEvent:做传递链取回hit-test view,那么我们可以在其中一环修改传递回的对象,从而改变正常的事件响应链。应用情景:

1、在不影响其他view的情况下,屏蔽某些view的响应

不需要响应 的视图可以不响应hitTest方法,或者 设置 userInteractionEnabled等属性

2、视图超出父视图的区域也依然进行响应

在需要判断的视图的父视图点击区域判断中做判断

3、约束响应范围

在需要约束的视图里重写点击区域判断方法

4、如何使用事件响应链去检查事件失效的问题?

大部分场景都是对UIView点击 ,那就写个UIView的扩展,然后重写一个touchBegin方法来打印对应的事件链,检查事件传递到哪被拦截了?

@implementation UIView (Touch)

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [super touchesBegan:touches withEvent:event];
    NSLog(@"%@",self.nextResponder);
    UIResponder *resp = self.nextResponder;
    NSString *str = @"-";
    while (resp != nil) {
        NSLog(@"%@%@",str,resp.classForCoder);
        resp = resp.nextResponder;
        str = [NSString stringWithFormat:@"%@%@",str,@"-"];
    }
}

@end

手势部分

demo

demo