[iOS开发]事件处理与响应者链

前言

Apple官方文档《Event Handling Guide for iOS》

外加一张最近看到的继承关系的图
在这里插入图片描述

一些新概念

UIKit框架

UIKit框架提供一系列的Class(类)来建立和管理iPhone OS应用程序的用户界面( UI )接口、应用程序对象、事件控制、绘图模型、窗口、视图和用于控制触摸屏等的接口。(PS1: 可以认为是操纵界面的一个API库)

响应链

当iOS捕获到某个事件时,就会将此事件传递给某个看上去最适合处理该事件的对象,比如触摸事件传递给手指刚刚触摸位置的那个视图(view),如果这个对象无法处理该事件,iOS系统就继续将该事件传递给更深层的对象,直到找到能够对该事件作出响应处理的对象为止。这一连串的对象序列被称作为“响应链”(responder chain),iOS系统就是沿着此响应链,由最外层逐步向内存对象传递该事件,亦即将处理该事件的责任进行传递。 iOS的这种机制,使得事件处理具有协调性和动态性。
下图就是一个最常见的响应链
在这里插入图片描述

响应者

在iOS中,能够响应事件的对象都是UIResponder的子类对象。当事件来到时,系统会将事件传递给合适的响应者,并且将其成为第一响应者。第一响应者未处理的事件,将会在响应者链中进行传递,传递规则由UIResponder的nextResponder决定,可以通过重写该属性来决定传递规则。当一个事件到来时,第一响应者没有接收消息,则顺着响应者链向后传递。

UIResponder、UIEvent 和 UIControl的介绍、联系与区别

UIResponder

我们最熟悉的UIApplication、UIView、UIViewController这几个类是直接继承自UIResponder,UIResponder类是专门用来响应用户的操作处理各种事件(UIEvent)的。
UIResponder提供了用户点击、按压检测(presses)以及手势检测(motion)的回调方法,分别对应用户开始、移动、结束以及取消,其中只有在程序强制退出或者来电时,取消事件才会调用。

我们以点击事件为例
在这里插入图片描述

我们可以自己测试一下这四个方法什么时候调用 鼠标点下 即开始begin 不松鼠标拖动在屏幕中拖动 开始Moved 松开鼠标Ended 只有在程序强制退出或者来电时,取消点击事件才会调用。
在这里插入图片描述
注意:
如果两个手指同时触摸一个view,那么view只会调用1次touchesBegin,参数touches里面有2个UITouch对象;
如果两个手指一前一后分开触摸,则view会分别调用2次touchesBegin,每次调用时的touches参数中只包含1个UITouch对象;

UIEvent

是由硬件捕获到的一个表示用户操作设备的对象,事件分为三类:包括触摸事件(Touch Events对应就是UITouch)、运动事件(Motion Events)、远程控制事件(Remote Control Events)。
在这里插入图片描述

UIControl

如果说UIResponder 实例对象可以对随机事件进行响应并处理,那么UIEvent 代表一个单一并只含有一种类型的事件,这个类型可以是触摸、远程控制或者按压,对应的子类具体一点可能是设备的摇动(为了处理系统事件,UIResponder 的子类可以通过重写一些对应的方法从而让它们可处理具体的 UIEvent 类型)。

在某种程度上,你可以将 UIEvents 视为通知。虽然 UIEvents 可以被子类化并且 sendEvent 可以被手动调用,但它们并不真正意味着可以这么做,至少不是通过正常方式。由于你无法创建自定义类型,派发自定义事件会出现问题,因为非预期的响应者可能会错误地 “处理” 你的事件。尽管如此,你仍然可以使用它们,除了系统事件,UIResponder 还可以以 Selector 的形式响应任意 “事件”。

虽然 UIResponder 可以完全检测触摸事件,但处理它们并非易事。 那你要如何区分不同类型的触摸事件呢?

这就是 UIControl 擅长的地方,UIControl相当于就是对UIResponder进行了一次封装,已经将手势与View进行了封装绑定,比如我们的UIButton为什么可以检测到双击,单击等等操作,也都是在UIControl里写好的

typedef NS_OPTIONS(NSUInteger, UIControlEvents) {
    UIControlEventTouchDown                                         
    UIControlEventTouchDownRepeat                                   
    UIControlEventTouchDragInside                                   
    UIControlEventTouchDragOutside                                  
    UIControlEventTouchDragEnter                                    
    UIControlEventTouchDragExit                                     
    UIControlEventTouchUpInside                                     
    UIControlEventTouchUpOutside                                    
    UIControlEventTouchCancel                                       
    UIControlEventValueChanged                                      
    UIControlEventPrimaryActionTriggered NS_ENUM_AVAILABLE_IOS(9_0) 
    UIControlEventEditingDidBegin                                   
    UIControlEventEditingChanged                                    
    UIControlEventEditingDidEnd                                     
    UIControlEventEditingDidEndOnExit                               
    UIControlEventAllTouchEvents                                    
    UIControlEventAllEditingEvents                                  
    UIControlEventApplicationReserved                               
    UIControlEventSystemReserved                                    
    UIControlEventAllEvents                                        
};
复制代码

事件的产生、传递和响应过程

UIApplication–>UIWindow–>递归找到最合适处理的控件–>控件调用 touches 方法–>判断是否实现 touches 方法–>没有实现默认会将事件传递给上一个响应者–>找到上一个响应者–>找不到方法作废

传递过程

  1. 当触摸事件发生时,压力转为电信号,iOS系统将产生UIEvent对象,记录事件产生的时间和类型。
  2. 当检测到一个系统事件,例如屏幕上的点击,UIKit 内部创建一个 UIEvent 实例并且记录事件产生的时间和类型,然后系统将事件加入到一个由UIApplication管理的事件队列中。
  3. UIApplication 会从事件队列中取出最前面的事件,并将事件分发下去以便处理,通常会先发送事件给应用程序的主窗口(keyWindow)
  4. 主窗口会在视图层次结构中找到一个最合适的视图来处理触摸事件

在这里插入图片描述
5. 找到合适的视图控件后,就会调用视图控件的 touches 方法来作事件的具体处理:
touchesBegin… touchesMoved…touchesEnded 等。如果找到了最合适的响应者,但是如果其没有实现touches方法,就会调用其上一个响应者对象的touches方法

如果父控件接受不到触摸事件,那么子控件就不可能接收到触摸事件 UIView 不能接收触摸

  1. userInteractionEnabled属性为YES,该属性表示允许控件同用户交互。
  2. Hidden属性为NO。控件都看不见,自然不存在触摸
  3. alpha属性值不为0 ~0.01。
  4. 触摸点在这个UIView的范围内。

hit-Test

用户的触摸事件首先会由系统截获,进行包装处理等。然后递归去遍历 view 层级,直到找到合适的响应者来处理事件,这个过程也叫做 Hit-Test。

Hit-Testing 先检查触摸对象所在的位置是否在对应任意屏幕上的视图对象的区域范围内。
如果在的话,就开始对此视图对象的子视图对象进行同样的检查。
视图树中最底层那个包含此触摸点位置的视图对象,就是要查找的 hit-test 视图对象。
iOS 一旦确定 hit-test 视图对象,就会把触摸事件传递给它进行处理。

在这里插入图片描述

以这个为例子 假设用户触摸了E

那么顺序就是这样

  • 触摸点在视图 A 的区域范围内,然后开始检查子视图 B 和 C
  • 触摸点不在 B 的范围而在 C 的范围,于是就开始检查 D 和 E 视图
  • 触摸点不在 D 的范围而在 E 的范围,而 E 视图是视图树最底层的并包含触摸点的视图对象,所以 E 就成为了 hit-test 视图。

hitTest:withEvent:方法处理流程:

  • 首先调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内:

若pointInside:withEvent:方法返回NO,说明触摸点不在当前视图内,则当前视图的hitTest:withEvent:返回nil
若pointInside:withEvent:方法返回YES,说明触摸点在当前视图内,则遍历当前视图的所有子视图(subviews),调用子视图的hitTest:withEvent:方法重复前面的步骤,子视图的遍历顺序是从top到bottom,即从subviews数组的末尾向前遍历,直到有子视图的hitTest:withEvent:方法返回非空对象或者全部子视图遍历完毕
UIApplication对象维护着自己的一个响应者栈,当pointInSide: withEvent:返回yes的时候,响应者入栈。传递链中是没有 controller 的,因为 controller 本身不具有大小的概念。但是响应链中是有 controller 的,因为 controller 继承自 UIResponder。所以controller可能是个单独的例外,其不需要pointInside方法就可以自己进入响应者栈
在这里插入图片描述

  • 若第一次有子视图的hitTest:withEvent:方法返回非空对象,则当前视图的hitTest:withEvent:方法就返回此对象,处理结束

若所有子视图的hitTest:withEvent:方法都返回nil,则当前视图的hitTest:withEvent:方法返回当前视图自身(self)

hit-Test内部实现

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    if (self.hidden || !self.userInteractionEnabled || self.alpha < 0.01 || ![self pointInside:point withEvent:event] || ![self _isAnimatedUserInteractionEnabled]) {
        return nil;
    } else {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:self] withEvent:event];
            if (hitView) {
                return hitView;
            }
        }
        return self;
    }
}
这里遍历子视图时是逆序的,即遍历 subview 时,是从上往下顺序遍历的,即 view.subviews 的 lastObject 到 firstObject 的顺序,找到合适的响应者view,即停止遍历.
复制代码

传递的大致的过程 application –> window –> root view –>……–>lowest view

响应过程

响应者链

在这里插入图片描述

在这里插入图片描述

  • Response Chain,响应链,一般我们称之为响应者链。
  • 在我们的 app 中,所有的视图都是按照一定的结构组织起来的,即树状层次结构,每个 view 都有自己的 superView,包括 controller 的 topmost view(即 controller 的 self.view)。
  • 当一个 view 被 add 到 superView 上的时候,它的 nextResponder 属性就会被指向它的 superView。
  • 当 controller 被初始化的时候,self.view(topmost view) 的 nextResponder 会被指向所在的 controller,而 controller 的 nextResponder 会被指向 self.view的superView。
  • 这样,整个 app 就通过 nextResponder 串成了一条链,这就是我们所说的响应者链。
  • 所以响应者链是一条虚拟的链,并没有一个对象来专门存储这样的一条链,而是通过 UIResponder 的属性串联起来的。

@property(nonatomic, readonly, nullable) UIResponder *nextResponder;

响应过程

如果没有实现touches方法那么一般默认做法是控件将事件顺着响应者链向上传递,将事件交给上一个响应者进行处理。那么如何判断当前响应者的上一个响应者是谁呢?

  • 判断当前是否是控制器的 View,如果是控制器的 View,上一个响应者就是控制器
  • 如果不是控制器的 View,上一个响应者就是父控件

当有 view 能够处理触摸事件后,开始响应事件。

系统会调用上面讲过的那个四个方法

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
复制代码

利用响应者链条我们可以通过调用 touches 的 super 方法,让多个响应者同时响应该事件。

需要特别注意的一点是,传递链中时没有 controller 的,因为 controller 本身不具有大小的概念。但是响应链中是有 controller 的,因为 controller 继承自 UIResponder。(hitTest中间有说到)

响应的大致的过程 initial view –> super view –> …..–> view controller –> window –>Application

响应者链相关问题

扩大button点击范围

解决:给button加分类然后重写pointInside或者hitTest方法

重写pointInside方法

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    CGRect bounds = CGRectInset(self.bounds, -50, -50);
    return CGRectContainsPoint(bounds, point);
}
复制代码

bounds很熟悉,==frame是相对于父视图自己的位置,bounds是自己的起始位置,== 其有两个属性,size和origin,origin初始都为0(如果设置了setBounds当我没说),setBounds中前两个变量为负数是什么意思?==为何(-30,-30)的偏移量,却可以让view向右下角移动呢?==

这是因为==setBounds的作用是==:强制将自己(view1)坐标系的左上角点,改为(-30,-30)。那么view1的原点,自然就向在右下方偏移(30,30)。

这里Inset也是同样道理,-50,-50,rect 的坐标(origin)按照(dx,dy) 进行平移,然后将rect的大小(size) 宽度缩小2倍的dx,高度缩小2倍的dy;

举个例子:所以inset 值为正,缩小,值为负,放大。

- (void)demoTest {
    UIView *view1 = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
    view1.backgroundColor = [UIColor redColor];
    [self.view addSubview:view1];
    NSLog(@"111=%@", NSStringFromCGRect(view1.frame));
    // 111={{100, 100}, {200, 200}}
    
    CGRect rect = CGRectInset(view1.frame, 30, 30);
    UIView *view2 = [[UIView alloc] initWithFrame:rect];
    view2.backgroundColor = [UIColor yellowColor];
    [self.view addSubview:view2];
    NSLog(@"222=%@", NSStringFromCGRect(view2.frame));
    // {{130, 130}, {140, 140}}
}
复制代码

最后一行 CGRectContainsPoint(bounds, point);布尔变量的函数,返回矩形是否包含指定的点。

重写HitTest方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    CGRect bounds = CGRectInset(self.bounds, -50, -50);
    if (CGRectContainsPoint(bounds, point)) {
        return self;
    } else {
        return nil;
    }
    return self;
}
复制代码

穿透事件

解决方法:

在这里插入图片描述

  • 点击按钮1的区域,按钮2响应事件,那肯定要重写按钮1的hitTest方法
  • 在hitTest方法中,将触摸点的坐标系从按钮1转换到按钮2上,即以按钮2左上角为原点
  • 坐标系转换后,判断触摸点是否在按钮2上,如果是,直接返回按钮2(严谨一点的做法是调用按钮2的hitTest方法),如果不是,那就调用系统的方法,让系统去处理
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    CGPoint pointTest = [self convertPoint:point toView:self.button];
        if ([self.button pointInside:pointTest withEvent:event]) {
            return self.button;
        } else {
            return [super hitTest:point withEvent:event];
        }
   }
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享