基础应用

iOS事件传递,响应链,手势识别

2021-11-10  本文已影响0人  潇潇不逍遥

一、事件传递

1、事件的分类

multitouch events:所谓的多点触摸事件,即用户触摸屏幕交互产生的事件类型;

motion events:所谓的移动事件。是指用户在摇晃、移动和倾斜手机的时候产生的事件称为移动事件。这类事件依赖于iPhone手机里边的加速器,陀螺仪等传感器;

remote control events:所谓的远程控制事件。指的是用户在操作多媒体的时候产生的事件。比如,播放音乐,视频等。

2、触摸事件

UIEvent

iOS将触摸事件定义第一个手指开始触摸屏幕到最后一个手指离开屏幕为一个触摸事件。用类UIEvent表示。

UITouch

一个手指第一次点屏幕,会形成一个UITouch对象,知道离开销毁,表示触碰。UITouch对象能表明了当前手指触碰屏幕的位置,状态。状态分为开始触碰,移动和离开。

根据定义,UIEvent实际包括了多个UITouch对象。有几个手指触碰,就会有几个UITouch对象。代码定义如下:

extension UIEvent {
public enum Phase : Int {        
        case began = 0

        case moved = 1

        case stationary = 2

        case ended = 3

        case cancelled = 4
    }
}
open class UIEvent : NSObject {
    
    @available(iOS 3.0, *)
    open var type: UIEvent.EventType { get }

    @available(iOS 3.0, *)
    open var subtype: UIEvent.EventSubtype { get }

    open var timestamp: TimeInterval { get }

    open var allTouches: Set<UITouch>? { get }

    open func touches(for window: UIWindow) -> Set<UITouch>?

    open func touches(for view: UIView) -> Set<UITouch>?

    @available(iOS 3.2, *)
    open func touches(for gesture: UIGestureRecognizer) -> Set<UITouch>?
}

其中UIEventType表明了事件类型,UIEvent表示了三大事件。
allTouches是该事件所有UITouch对象的集合。

extension UITouch {
    public enum Phase : Int {

        case began = 0

        case moved = 1

        case stationary = 2

        case ended = 3

        case cancelled = 4
    }
}
@available(iOS 2.0, *)
open class UITouch : NSObject {

        open var timestamp: TimeInterval { get }

    open var phase: UITouch.Phase { get }

    open var tapCount: Int { get } // touch down within a certain point within a certain amount of time

    @available(iOS 9.0, *)
    open var type: UITouch.TouchType { get }

    // majorRadius and majorRadiusTolerance are in points
    // The majorRadius will be accurate +/- the majorRadiusTolerance
    @available(iOS 8.0, *)
    open var majorRadius: CGFloat { get }

    @available(iOS 8.0, *)
    open var majorRadiusTolerance: CGFloat { get }

    
    open var window: UIWindow? { get }

    open var view: UIView? { get }

    @available(iOS 3.2, *)
    open var gestureRecognizers: [UIGestureRecognizer]? { get }

    open func location(in view: UIView?) -> CGPoint

    open func previousLocation(in view: UIView?) -> CGPoint

}

UITouch中phase表明了手指移动的状态,包括 1.开始点击;2.移动;3.保持; 4.离开;5.被取消(手指没有离开屏幕,但是系统不再跟踪它了)

综上,UIEvent就是一组UITouch。每当该组中任何一个UITouch对象的phase发生变化,系统都会产生一条TouchMessage。也就是说每次用户手指的移动和变化,UITouch都会形成状态改变,系统变回会形成Touch message进行传递和派发。

3、Responder

Responder是用来接收和处理事件的类。Responder的属性和方法

@available(iOS 2.0, *)
open class UIResponder : NSObject, UIResponderStandardEditActions {

    
    open var next: UIResponder? { get }

    
    open var canBecomeFirstResponder: Bool { get } // default is NO

    open func becomeFirstResponder() -> Bool

    
    open var canResignFirstResponder: Bool { get } // default is YES

    open func resignFirstResponder() -> Bool

    
    open var isFirstResponder: Bool { get }

    
    // 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.
    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?)

    @available(iOS 9.1, *)
    open func touchesEstimatedPropertiesUpdated(_ touches: Set<UITouch>)

    
    // Generally, all responders which do custom press handling should override all four of these methods.
    // Your responder will receive either pressesEnded:withEvent or pressesCancelled:withEvent: for each
    // press it is handling (those presses it received in pressesBegan:withEvent:).
    // pressesChanged:withEvent: will be invoked for presses that provide an analog value
    // (like thumbsticks or analog push buttons)
    // *** You must handle cancelled presses to ensure correct behavior in your application.  Failure to
    // do so is very likely to lead to incorrect behavior or crashes.
    @available(iOS 9.0, *)
    open func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?)

    @available(iOS 9.0, *)
    open func pressesChanged(_ presses: Set<UIPress>, with event: UIPressesEvent?)

    @available(iOS 9.0, *)
    open func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?)

    @available(iOS 9.0, *)
    open func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?)

    
    @available(iOS 3.0, *)
    open func motionBegan(_ motion: UIEvent.EventSubtype, with event: UIEvent?)

    @available(iOS 3.0, *)
    open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?)

    @available(iOS 3.0, *)
    open func motionCancelled(_ motion: UIEvent.EventSubtype, with event: UIEvent?)

    
    @available(iOS 4.0, *)
    open func remoteControlReceived(with event: UIEvent?)

    
    @available(iOS 3.0, *)
    open func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool

    // Allows an action to be forwarded to another target. By default checks -canPerformAction:withSender: to either return self, or go up the responder chain.
    @available(iOS 7.0, *)
    open func target(forAction action: Selector, withSender sender: Any?) -> Any?

    
    // Overrides for menu building and validation
    @available(iOS 13.0, *)
    open func buildMenu(with builder: UIMenuBuilder)

    @available(iOS 13.0, *)
    open func validate(_ command: UICommand)

    
    @available(iOS 3.0, *)
    open var undoManager: UndoManager? { get }

    
    // Productivity editing interaction support for undo/redo/cut/copy/paste gestures
    @available(iOS 13.0, *)
    open var editingInteractionConfiguration: UIEditingInteractionConfiguration { get }
}

注意有个很中重要的方法,nextResponder,表明响应是一个链表结构,通过nextResponder找到下一个responder。这里是从第一个responder开始通过nextResponder传递事件,直到有responder响应了事件就停止传递;如果传递到最后一个responder都没有被响应,那么该事件就被抛弃。

UIResponser包括了各种Touch message的处理,比如说开始,移动,停止等等。常见的UIResponser有UIView及子类。UIApplication、UIWindow、UIViewController、UIView都是继承UIResponder,都可以传递和响应事件。

程序启动
UIApplication会生成一个单例,并会关联一个APPDelegate。APPDelegate作为整个响应链的根建立起来,而UIApplication会将自己与这个单例链接,即UIApplication的nextResponder(下一个事件处理者)为APPDelegate
创建UIWindow
程序启动后,任何的UIWindow被创建时,UIWindow内部都会把nextResponser设置为UIApplication单例。
UIWindow初始化rootViewController, rootViewController的nextResponser会设置为UIWindow
UIViewController初始化
loadView, VC的view的nextResponser会被设置为VC.
addSubView
addSubView操作过程中,如果子subView不是VC的View,那么subView的nextResponser会被设置为superView。如果是VC的View,那就是 subView -> subView.VC ->superView

4、事件的传递

事件传递流程.png

4.1 事件传递的流程

触摸事件的传递是从父控件传递到子控件
用户触摸(Touch)屏幕进行交互时,系统首先要找到响应者(Responder)。系统检测到手指触摸(Touch)操作时,将Touch 以UIEvent的方式加入UIApplication事件队列中。UIApplication从事件队列中取出最新的触摸事件进行分发传递到UIWindow进行处理。 UIWindow寻找处理事件的最合适的view
注意:如果父控件不能接受触摸事件,那么子控件就不可能接收到触摸事件。

4.2 如何寻找最合适的控件来处理事件

①.首先判断主窗口(keyWindou)自己是否能接受触摸事件
②.判断触摸点是否在自己身上
③.子控件数组中从后往前遍历子控件,重复前面两个步骤(所谓从后往前遍历子控件,就是首先查找子控件数组中最后一个元素,然后执行1、2步骤)
④.如果没有符合条件的子控件,那么就认为自己最合适处理这个事件,也就是自己是最合适的view。

4.3 两个重要的方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

1> 在UIWindow中就会调用hitTest:withEvent:方法去返回一个最终响应的视图
2> 在hitTest:withEvent:方法中就会去调用pointInside: withEvent:去判断当前点击的point是否在UIWindow范围内,如果是的话,就会去遍历它的子视图来查找最终响应的子视图
3> 遍历的方式是使用倒序的方式来遍历子视图,也就是说最后添加的子视图会最先遍历,在每一个视图中都回去调用它的hitTest:withEvent:方法,可以理解为是一个递归调用
4> 最终会返回一个响应视图,如果返回视图有值,那么这个视图就作为最终响应视图,结束整个事件传递;如果没有值,那么就会将UIWindow作为响应者

UIView不能接收触摸事件的三种情况:

不允许交互:userInteractionEnabled = NO
隐藏:如果把父控件隐藏,那么子控件也会隐藏,隐藏的控件不能接受事件
透明度:如果设置一个控件的透明度<0.01,会直接影响子控件的透明度。alpha:0.0~0.01为透明。

整个过程的系统实现大致如下:


image.png

首先对 A 进行命中测试,显然🌟是在 A 内部的,按照流程接下来检查 A 是否有子视图。
我们发现 A 有两个子视图,那我们就需要按 FILO 原则遍历子视图,先对 D 进行命中测试,后对 B 进行命中测试。
我们对 D 进行命中测试,我们发现🌟不在 D 的内部,那就说明 D 及其子视图一定不是第一响应者。
按顺序接下来对 B 进行命中测试,我们发现🌟在 B 的内部,按照流程接下来检查 B 是否有子视图。
我们发现 B 有一个子视图 C,所以需要对 C 进行命中测试。
显然🌟不在 C 的内部,这时我们得到的信息是:触摸点在 B 的内部,但不在 B 的任一子视图内。
得到结论:B 是第一响应者,并且结束命中测试。
整个命中测试的走向是这样的:A✅ --> D❎ --> B✅ --> C❎ >>>> B

代码:

class HitTestExampleView: UIView {
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if !isUserInteractionEnabled || isHidden || alpha <= 0.01 {
            return nil // 此处指视图无法接受事件
        }
        if self.point(inside: point, with: event) { // 判断触摸点是否在自身内部
            for subview in subviews.reversed() { // 按 FILO 遍历子视图
                let convertedPoint = subview.convert(point, from: self)
                let resultView = subview.hitTest(convertedPoint, with: event) 
                // ⬆️这句是判断触摸点是否在子视图内部,在就返回视图,不在就返回nil
                if resultView != nil { return resultView }
            }
            return self // 此处指该视图的所有子视图都不符合要求,而触摸点又在该视图自身内部
        }
        return nil // 此处指触摸点是否不在该视图内部
    }
}

二、事件的响应

1. 触摸事件处理的整体过程

1 用户点击屏幕后产生的一个触摸事件,经过一系列的传递过程后,会找到最合适的视图控件来处理这个事件
2 找到最合适的视图控件后,就会调用控件的touches方法来作具体的事件处理touchesBegan…touchesMoved…touchedEnded…
3 这些touches方法的默认做法是将事件顺着响应者链条向上传递(也就是touch方法默认不处理事件,只传递事件),将事件交给上一个响应者进行处理

2. 响应者链条

在iOS程序中无论是最后面的UIWindow还是最前面的某个按钮,它们的摆放是有前后关系的,一个控件可以放到另一个控件上面或下面,那么用户点击某个控件时是触发上面的控件还是下面的控件呢,这种先后关系构成一个链条就叫响应者链。也可以说,响应者链是由多个响应者对象连接起来的链条。


响应者链条

事件响应会先从底层最合适的view开始,然后随着上一步找到的链一层一层响应touch事件。默认touch事件会传递给上一层。如果到了viewcontroller的view,就会传递给viewcontroller。如果viewcontroller不能处理,就会传递给UIWindow。如果UIWindow无法处理,就会传递给UIApplication。如果UIApplication无法处理,就会传递给UIApplicationDelegate。如果UIApplicationDelegate不能处理,则会丢弃该事件。

下面我们举一个例子。如下图,A B C 都是 UIView,我们将手指按照🌟的位置和箭头的方向在屏幕上移动一段距离,然后松开手。我们应该能在控制台看到下右图的输出。我们可以看到,A B C 三个视图都积极的响应了每一次事件,每次触摸的发生后,都会先触发 B 的响应方法,然后传递给 C,在传递给 A。但是这种「积极」的响应其实意味着在我们这个例子中,A B C 都不是这个触摸事件的合适接受者。他们之所以「积极」的将事件传递下去,是因为他们查看了这个事件的信息之后,认为自己并不是这个事件的合适处理者。(当然了,我们这边放的是三个 UIView,他们本身确实也不应该能处理事件)


view.png

那么如果我们把上图中的 C 换成平时使用的 UIControl类,控制台又会怎么打印呢?如右下图所示,会发现响应链的事件传递到 C 处就停止了,也就是 A 的 touches方法没有被触发。这意味着在响应链中,UIControl及其子类默认来说,是不会将事件传递下去的。在代码中,可以理解为 UIView默认会在其touches 方法中去调用其 next的 touches 方法,而 UIControl 默认不会去调用。这样就做到了,当某个控件接受了事件之后,事件的传递就会终止。另外,UIScrollView 也是这样的工作机制。


control.png

三、手势识别

1 不使用UIGestureRecognizer

在不使用UIGestureRecognizer 的基础上 触摸屏幕后事件的传递可以分为以下几个步骤:
1 通过「命中测试」来找到「第一响应者」
2 由「第一响应者」来确定「响应链」
3 将事件沿「响应链」传递
4 事件被某个响应者接收,或没有响应者接收从而被丢弃
如果使用了UIGestureRecognizer
在上文介绍了当屏幕上发生一次触摸之后,系统会如何寻找「第一响应者」,在寻找到「第一响应者」之后,如何确定「响应链」以及如何沿「响应链」传递事件。在上一篇文章的环境中,是不使用 UIGestureRecognizer的。但是在我们平时的开发中想要给一个 UIView加上处理事件的能力的话,使用UIGestureRecognizer 及其子类比继承一个UIView 的类、重写touches方法要方便的很多。这两种方法对事件的处理机制相互影响又有所不同。

2 当手势识别参与响应链

上文中,我们只讨论了下图中蓝色部分事件沿响应链传递的流程,但实际上,同时一起发生的还有图中下半部分手势识别的部分。


image.png

从图中我们可以看到,在通过命中测试找到第一响应者之后,会将UITouch分发给UIResponder 的 touches 系列方法(具体方法见上篇文章),同时也会分发给手势识别系统,让这两个处理系统同时工作。
首先要注意的是,上图中蓝色部分的流程并不会只执行一次,举例来说:当我们用一根手指在一个视图上缓慢滑动时,会产生一个 UITouch 对象,这个 UITouch 对象会随着你手指的滑动,不断的更新自身,同时也不断地触发 touches 系列方法。

UITouch 的 gestureRecognizers 属性中的存储了在寻找第一响应者的过程中收集到的手势,而在不断触发 touches 系列方法的过程中,手势识别系统也在在不停的判断当前这个 UITouch 是否符合收集到的某个手势。

当手势识别成功: 被触摸的那个视图,也就是第一响应者会收到 touchesCancelled 的消息,并且该视图不会再收到来自该 UITouch 的 touches 事件。同时也让该 UITouch 关联的其他手势也收到 touchesCancelled,并且之后不再收到此 UITouch 的 touches 事件。这样做就实现了该识别到的手势能够独占该 UITouch。

touchesBegan     // 手指触摸屏幕
touchesMoved     // 手指在屏幕上移动
touchesMoved     // ...
...
touchesMoved     // ...
touchesMoved     // 手指在屏幕上移动
// touchesEnded     // 手指离开屏幕

touchesCancelled // 手势识别成功,touches 系列方法被阻断
// 现在手指💅并没有离开屏幕
// 但如果继续滑动🛹的话
// 并不会触发 touches 系列方法

当手势识别未成功: 指暂时未识别出来,不代表以后不会识别成功,不会阻断响应链。注意这里指的是未成功,并不一定是失败。在手势的内部状态中,手势大部分情况下状态是 .possible,指的是UITouch 暂时与其不匹配,但之后可能有机会识别成功。而 .fail 是真的识别失败,指的是以目前的触摸情况来看已经不可能是这个手势了,并且在下个runloop 会从 gestureRecognizers 中移除该手势。

3 深入理解

image.png

从图中我们可以看到,当不带手势的情况下,手指按下去的时候,响应者的 touchBegan 方法会触发,随着手指的移动,touchMoved会不断触发,当手指结束移动并抬起来的时候,touchEnded会触发。在这个过程中,我们接收到一直是一个不断更新的 UITouch。
在该视图有添加一个UIPanGestureRecognizer 手势的情况下,我们多了下方这一条来表示与响应链同时工作的手势识别系统,可以看到手势识别系统也是在手指按下去那一刻就开始工作的,前半段处于一直正在识别的状态。在我们拖动了很小一段距离之后(注意这时候我们的手指还没抬起), 手势识别系统确定了该 UITouch 所做的动作是符合UIPanGestureRecognizer 的特点的,于是给该视图的响应链发送了touchCancelled 的信息,从而阻止这个 UITouch 继续触发这个视图的 touches 系列方法(同时也取消了别的相关手势的touches 系列方法,图中未体现)。在这之后,被调用的只有与手势关联的 target-action方法(也就是图中的墨绿色节点 call PanFunction)。

为了图片的美观和易读,在图片中隐去了不少细节,在此列出:
1.手势识别器的状态在图中未标出:

手势在图中 recognizing 的橙色节点处和recognized棕色节点处都处于 .possible 状态
手势在图中绿色节点处的状态变化是.began -> [.changed] -> ended

手势识别器不是响应者,但也有touches系列方法,比它所添加的视图的 touches方法更早那么一点触发
从图中也可以看出,手势那条线上的每个节点都稍靠左一些
手势那条线上的橙、棕、墨绿色节点处也可以看做手势识别器的touches 方法触发

更详细的触发顺序应当如下图所示(在一个 UIView 上添加了 UIPanGestureRecognizer,并单指在上面滑动一段距离的情况)

image.png

4 更多选择

1.我们可以通过配置手势的属性来改变它的表现,下面介绍三个常用的属性:
cancelsTouchesInView:该属性默认是 true。顾名思义,如果设置成 false,当手势识别成功时,将不会发送touchesCancelled给目标视图,从而也不会打断视图本身方法的触发,最后的结果是手势和本身方法同时触发。有的时候我们不希望手势覆盖掉视图本身的方法,就可以更改这个属性来达到效果。
2.delaysTouchesBegan:该属性默认是false。在上个例子中我们得知,在手指触摸屏幕之后,手势处于.possible状态时,视图的touches方法已经开始触发了,当手势识别成功之后,才会取消视图的 touches方法。当该属性时true 时,视图的 touches方法会被延迟到手势识别成功或者失败之后才开始。也就是说,假如设置该属性为 true ,在整个过程中识别手势又是成功的话,视图的touches 系列方法将不会被触发。
3.delaysTouchesEnded: 默认为YES。这种情况下发生一个touch时,在手势识别成功后,发送给touchesCancelled消息给hit-testview,手势识别失败时,会延迟大概0.15ms,期间没有接收到别的touch才会发送touchesEnded。如果设置为NO,则不会延迟,即会立即发送touchesEnded以结束当前触摸

5 UIControl 与手势识别

UIController 带有控制类控件的父类

由于 UIControl接收target-action方法的方式是在其touches 方法中识别、接收、处理,而手势的touches方法一定比其所在视图的 touches方法早触发。再根据上文的描述的触发规则,可以得到的结论是:对于自定义的UIControl来说,手势识别的优先级比UIControl 自身处理事件的优先级高。
`
举个例子来说:当我们给一个 UIControl 添加了一个 .touchupInside 的方法,又添加了一个UITapGestureRecognizer 之后。点击这个 UIControl,会看到与手势关联的方法触发了,并且给 UIControl 发送了touchCancelled,导致其自身的处理时间机制被中断,从而也没能触发那个 .touchupInside的方法。

同时这样的机制可能会导致一个问题:当我们给一个已经拥有点击手势的视图,添加一个 UIControl作为子视图,那么我们无论怎么给该 UIControl 添加点击类型的 target-action 方法,最后的结果都是触发其父视图的手势(因为在命中测试的过程中收集到了这个手势),并且中断 UIControl 的事件处理,导致添加的 target-action方法永远无法触发。

UITouch在寻找第一响应者的时候,会把整条响应链上的手势收集在自身的 gestureRecognizers 数组中,当找到第一响应者之后,在每次第一响应者触发 touches 方法之前,会先触发 UITouch 手势数组里手势的 touches 方法

那其实🍎已经给我们做了一个解决方案,UIKit 对部分控件(同时也是 UIControl 的子类)做了特殊处理,当这些控件的父视图上有与该控件冲突功能的手势时,会优先触发控件自身的方法,不会触发其父视图上的那个手势。

也举个例子来说:当我们给一个已经拥有点击手势的视图,添加一个 UIButton 作为子视图,并且给按钮添加点击类型的 target-action 方法,那么当点击按钮时,按钮的 target-action 方法会触发,手势的方法会被忽略。
并且文档中也提到了,如果不想要这种情况发生,那就应当把手势添加到目标控件上(因为手势比控件更早识别到事件,也就是上文提到的给 UIControl 添加了.touchupInside方法的例子),这样的话生效的就是手势了。

总结

总的来说,手势识别器在大多数情况下,识别屏幕触摸事件的优先级,比控件本身的方法的优先级高。

所以在开发的过程中,注意不要让手势覆盖控件本身的方法实现。同时也要理解默认情况下,手势识别在一开始实际上并不会阻止控件自身的touches 系列方法,而是在之后的某个时机去取消。另外在 UIKit中,也对部分情况做了特殊处理,让UIKit控件有机会跳过父视图的手势识别,去获得事件的控制权。

上一篇下一篇

猜你喜欢

热点阅读