浅析ios事件的响应及传递

响应者Responders

说到事件,不得不从UIResponder说起; UIResponder是用于响应和处理事件的抽象接口,UIResponder的实例构成了UIKit的事件处理主干,许多UIKit类也都是继承自UIResponder,包括UIApplication, UIViewController以及UIView(包括UIWindow),它们的实例都是响应者:(对用户交互动作事件进行响应的对象),当事件发生时,UIKit将它们分派到应用的responder对象中进行处理。

UI类的关系

UIResponder响应的事件有以下几种:

  • touch events ```
  • (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
  • (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
  • (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
  • (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
  • (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1); ```
  • motion events ```
  • (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
  • (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
  • (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0); ```
  • remote-control events ```
  • (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0); ```
  • press events ```
  • (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
  • (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
  • (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
  • (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0); ```

响应者链Responder Chain

由多个响应者组合起来的链条,就是响应者链。它表示了每个响应者之间的联系,并且可以使得一个事件可选择多个对象处理。UIResponder有nextResponder方法,返回响应链的下一个响应者;一般的,响应者的下个响应者是它的父视图(如果响应者是UIViewController的view,这个响应者的下个响应者是UIViewController)。当然我们也可以重写nextResponder方法来指定下个响应者。 官方文档举了个通俗易懂的例子来描述响应者链:

Responder Chain

如果text field没有处理事件,UIKit会将事件发送给text field的父视图UIView对象,如果UIView对象没有处理事件,事件会被发送给UIViewController的根视图;之后事件会依次传递到根视图下个响应者即视图所属的视图控制器,然后是UIWindow对象,然后是UIApplication对象,如果该对象是UIResponder的一个实例并且不是响应者链的一部分,可能会传递给AppDelegate。

使用touch events来验证下:

red

viewController和redView都加上touch event

// viewController
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%s",__func__);
}

// redView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%s",__func__);
}

当点击红色view时,很显然只有红色view响应事件。注释redView中touch方法后,点击红色view时viewController的touchesBegan事件触发了。如何让viewController和redView都能响应这个touch事件呢,可以在redView中主动传递给下个响应者:

// redView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.nextResponder touchesBegan:touches withEvent:event];
    // 或者  [super touchesBegan:touches withEvent:event];
    NSLog(@"%s",__func__);
}

Hit-Test 机制

当一个touch events产生的时候,系统是如何找到第一响应者(即最适合处理这个事件的对象)的呢?这里就是使用了Hit-Test 机制: Hit-Test有关的两个方法:

- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;   // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;   // default returns YES if point is in bounds
  • 当产生一个touch事件,Runloop会接收到事件并把其加入UIApplication事件队列里;
  • UIApplication从事件队列中取出最新的事件进行分发传递给UIWindow进行处理;
  • UIWindow会调用hitTest:withEvent:方法在视图层次结构中找到一个最合适的UIView来处理这个事件;分发的顺序和响应链基本相反:UIApplication -> UIWindow -> Root View -> ··· -> subview:
  • hitTest:withEvent:方法会调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内;
  • 若pointInside:withEvent:方法返回NO,说明触摸点不在当前视图内,则当前视图的hitTest:withEvent:返回nil;
  • 若pointInside:withEvent:方法返回YES,说明触摸点在当前视图内,则遍历当前视图的所有子视图(subviews),调用子视图的hitTest:withEvent:方法(子视图重复同样的步骤),子视图的遍历顺序是栈的形式,即从最后面添加的子视图至最早添加的子视图,直到有子视图的hitTest:withEvent:方法返回非空对象或者全部子视图遍历完毕;
  • 若有子视图的hitTest:withEvent:方法返回非空对象(第一响应对象为子视图),则当前视图的hitTest:withEvent:方法就返回此对象,处理结束;
  • 若所有子视图的hitTest:withEvent:方法都返回nil(触摸点不在子视图上),则当前视图的hitTest:withEvent:方法返回当前视图self;

同样,还是来验证下:

subviews

在之前的redView上添加了2个subviews:blueView和yellowView;这三个view分别重写hitTest:withEvent:和pointInside:withEvent:方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%@ start hit",[self class]);
    UIView *view = [super hitTest:point withEvent:event];
    NSLog(@"%@ hitView:%@",[self class],[view class]);
    return view;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    BOOL inside = [super pointInside:point withEvent:event];
    NSLog(@"%@ pointInside:%@",[self class],inside ? @"YES" : @"NO");
    return inside;
}

首先,点击yellowView,输出log如下:

RedView start hit RedView pointInside:YES YellowView start hit YellowView pointInside:YES YellowView hitView:YellowView

结果不重要,重要的是过程,我们来分析下这个过程:

  1. redView是父视图,首先会调用redView的hitTest:withEvent:方法,在获取hitView的时候会调用pointInside:withEvent:方法判断点击的point是否在当前视图frame内;
  2. pointInside:withEvent:返回YES,则依次分发给redView的子视图;由于yellowView是后面添加的,会先分发给yellowView调用hitTest:withEvent:。
  3. 和之前redView同样的流程,yellowView判断后point在当前视图frame内,由于yellowView没有子视图分发结束;hitTest:withEvent:返回yellowView对象,也即这个事件找到了第一响应者yellowView;然后父视图redView的hitTest:withEvent:也返回yellowView对象;

综上,事件流程其实可以用一张图表示:

事件流程

实际应用

以上知识,在实际开发中有什么用途呢?

  • 通过nextResponser方法实现解耦: 需求:在自定义UIView中获取ViewController对象;一般的做法是在自定义View中声明并引用ViewController对象,但这样做耦合性高了。可以使用nextResponser获取: ``` @implementation UIView (Tool)

  • (UIViewController *)hh_viewController { UIResponder *rp = [self nextResponder]; while (rp) { if ([rp isKindOfClass:[UIViewController class]]) { return (UIViewController *)rp; } rp = [rp nextResponder]; } return nil; }

@end

- 重写hitTest:withEvent:或pointInside:withEvent:方法,解决某些事件不能响应的情况:
控件不能响应事件,一般有如下情况:
>1.userInteractionEnabled = NO(这也是UIImageView控件及其子视图不能响应事件的原因)
2.hidden = YES
3.alpha小于等于0.01
4.子视图超出了父视图frame

项目中一个常见的需求就是让超出父视图范围的子视图也能响应事件,比如TableBar中突出的按钮;现在简单模拟一下这种情况:

![HEH](https://upload-images.jianshu.io/upload_images/2427856-09c8cdb03721358a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

blueView是redView的subview,并且有一半已超出父视图范围;这时点击blueView的上半部分,肯定没有任何反应;这是因为父视图的pointInside:withEvent:方法返回了NO,就不会遍历子视图了。可以重写redView的pointInside:withEvent:方法解决此问题。
  • (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { BOOL inside = [super pointInside:point withEvent:event]; if (!inside) { UIView *subview = self.subviews[0]; CGRect subRect = subview.frame; if (CGRectContainsPoint(subRect, point)) { inside = YES; } } return inside; } ```
Written on March 24, 2019