iOS事件响应链介绍

iOS事件响应链介绍

官方介绍

官方文档Understanding Event Handling, Responders, and the Responder Chain中,有这样的叙述:

app使用响应者对象接收和处理事件。响应者对象是UIResponder类的实例,常见的子类包括UIViewUIViewControllerUIApplication。响应者接收原始事件数据,并且必须处理该事件或将其转发给另一个响应者对象。当你的app收到一个事件时,UIKit会自动将该事件指向最合适的响应者对象,称为第一响应者(first responder)。

未被处理的事件会从响应者传递到活动的响应者链(active responder chain)中的响应者,响应者链是应用程序的响应者对象的动态配置(Unhandled events are passed from responder to responder in the active responder chain, which is a dynamic configuration of your app’s responder objects)

在你的app中没有单个响应者链。 UIKit为对象如何从一个响应者传递到另一个响应者定义了默认的规则,但是你可以通过覆盖响应者对象中相应的属性来改变这些规则。

下图显示了一个应用程序中默认的响应者链,其接口包含label,text field,一个button和两个背景 view。如果text field不处理事件,则UIKit将该事件发送到文本字段的父级UIView对象,然后是该window的根view。在根view中,响应者链在将事件引导到window之前转移到拥有该view的view controller。如果窗口不处理事件,则UIKit将事件传递给UIApplication对象,如果该对象是UIResponder的实例并且已经不是响应者链的一部分,则可能传递给app delegate。

iOS事件响应链介绍_第1张图片

决定事件的第一响应者

对于每种类型的事件,UIKit都会指定一个第一响应者,并首先将该事件发送给该对象。 第一响应者根据事件的类型而变化。

  • Touch events
    第一响应者是触摸发生的view
  • Press events
    第一个响应者是焦点的响应者
  • Shake-motion events
    第一个响应者是你(或者UIKit)指定的第一个响应者
  • Remote-control events
    第一个响应者是你(或者UIKit)指定的第一个响应者
  • Editing menu messages
    第一个响应者是你(或者UIKit)指定的第一个响应者

注意
加速计、陀螺仪、磁力计相关的Motion事件,并不遵循响应者链。Core Motion会将直接分发这些事件到指定的对象。可参考Core Motion Framework

Controls使用action消息直接与其关联的目标对象进行通信。 当用户与控件进行交互时,控件调用其目标对象的动作方法 - 换句话说,它向其目标对象发送一个动作消息。 Action消息不是事件,但它们仍然可以利用响应者链。 当控件的目标对象为nil时,UIKit从目标对象开始,遍历响应者链,直到找到实现相应操作方法的对象。 例如,UIKit编辑菜单使用此行为来搜索响应者对象,这些对象实现了名称为cut(_ :)copy(_ :)paste(_ :)的方法

如果view附加有的gesture recognizer,则gesture recognizer在view接收之前接收touch和press事件。 如果view的所有的gesture recognizer都无法识别其手势,则将事件传递给view进行处理。 如果view不处理touches,则UIKit将事件传递给响应者链。 有关使用手势识别器处理事件的更多信息,请参阅处理UIKit手势。

确定哪个响应者包含触摸事件

UIKit使用基于视图的hit-testing来确定触摸事件发生的位置。 具体来说,UIKit将触摸位置与视图层次结构中视图对象的边界进行比较。 UIViewhitTest(_:with :)方法遍历视图层次结构,查找包含指定touch的最深的子视图。 该view成为触摸事件的第一响应者。

注意
如果触摸位置超出view边界,则hitTest(_:with :)方法会忽略该view及其所有子view。 因此,当一个视图的clipsToBounds属性为false时,即使这些子视图碰巧包含触摸,也不会返回该view边界外的子视图。

UIKit将每个触摸永久分配给包含它的视图。 UIKit在触摸第一次出现时创建每个UITouch对象,并且只有在触摸结束后才释放该触摸对象。 随着触摸位置或其他参数的更改,UIKit会使用新信息更新UITouch对象。 唯一不变的属性就是view(即使touch的位置移动到了原view的外面,touch的view 属性值也不会发生变更)。当touch结束时,UIKit会释放掉这个UITouch对象

改变响应者链

通过重写响应者对象的next属性,你可以修改响应者链。这样做后,下一个响应者就是你返回的对象

许多UIKit对象,已经重写了这个属性,返回了指定的对象,包括:

  • UIView对象。如果view是view controller的root view,则下一个响应者就是view controller;否则,下一个响应者就是view的superview
  • UIViewController对象
  • 如果view controller的view是window的root view,下一个响应者就是window对象
  • 如果view controller由另一个view controller展示(present),则一个响应者就是presenting view controller
  • UIWindow对象。window的下一个响应者对象就是 UIApplication 对象
  • UIApplication 对象。下一个响应者对象就是app代理,但只有app代理是UIResponder的一个实例,而不是一个view、 view controller或app自身时,才是这样的

事件传递和响应

iOS中事件的分类:

  • 运动事件 - 例如陀螺仪、加速计等
  • 远程控制事件 - 一般指耳机的插入拔出事件
  • 触摸事件 - 本文主要分析触摸事件

触摸事件调用层次如下:

iOS事件响应链介绍_第2张图片

查找第一响应者主要涉及以下两个方法:

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

hitTest(_:with:)找到view的流程

hitTest(_:with:)返回包含指定点receiver的视图层级(包括它本身)中的最深的后代。(Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point.)

此方法通过遍历视图层级,调用每个子视图的point(inside:with:)方法来确定哪个子视图应该接收触摸事件。如果point(inside:with:)返回true,那么类似的遍历子视图的视图层次,直到找到包含指定点的最前面的view。如果视图不包含该点,则该视图层级的分支将被忽略。

此方法忽略隐藏的视图对象、禁用了用户交互,或者alpha级别小于0.01的view

Points that lie outside the receiver’s bounds are never reported as hits, even if they actually lie within one of the receiver’s subviews. This can occur if the current view’s clipsToBounds property is set to NO and the affected subview extends beyond the view’s bounds.
大意是说,即使clipsToBounds设置为NO,但如果subview超出了view的边际,它也不会响应hit

参考Hit-Testing in iOS,hitTest(_:with:)内部实现可能是这样的

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

例子说明

如下的view层级:

  • viewcontroller的view
    • grayView
      • redView
      • blueView
    • yellowView

grayViewyellowView是控制器view的子view,而redViewblueViewgrayView的子view,如下:

iOS事件响应链介绍_第3张图片

重写每个自定义的view中的hitTest:withEvent:pointInside:withEvent:方法,以及相关的touchesBegan:等方法,如下:

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    NSLog(@"%@ pointInside", self.EOCBgColorString);
    return [super pointInside:point withEvent:event];
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    NSLog(@"%@ hitTest", self.EOCBgColorString);
    return [self hitTest:point event:event];
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    EOCLog(@"%@ touchBegan", self.EOCBgColorString);
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    EOCLog(@"%@ touchesMoved", self.EOCBgColorString);
    [super touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
    EOCLog(@"%@ touchesEnded", self.EOCBgColorString);
    [super touchesEnded:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
    EOCLog(@"%@ touchesCancelled", self.EOCBgColorString);
    [super touchesCancelled:touches withEvent:event];
}

现在如果在redView进行点击,方法的输出顺序如下:

iOS事件响应链介绍_第4张图片

可以看到系统输出了2次,顺序为:

yellowColorView->ligthGrayColorView->blueColorView->redColorView

可见,总是先找view层级中,最前端的子view,依次递归遍历,而且调用hitTest:withEvent:在前,pointInside:withEvent:在后

可以理解为:hitTest:withEvent:会调用自身的pointInside:withEvent:方法,如果pointInside:withEvent:返回为YES,则会继续遍历子view,如果pointInside:withEvent:返回为NO,则返回nil。与上面介绍的hitTest(_:with:)可能的内部实现是一致的

可以利用上面的调用过程,做出一些非常有用的功能,例如:

1.扩大Button的点击区域

自定义Button,重写- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event方法

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%s", __func__);
    CGRect frame = [self getScaleFrame];  //扩大点击区域
    return CGRectContainsPoint(frame, point);
}

UIResponder和响应链的组成

另外,通过上面的输出可以看到touchesBegan:withEvent:调用顺序为:

redColorView touchBegan
ligthGrayColorView touchBegan
-[EOCTouchEventViewCtrl touchesBegan:withEvent:]

redColorView首先响应,然后再是它的父view,然后再是父view的superview,即控制器EOCTouchEventViewCtrl,与上面的hitTest的顺序就一样了,这其实就跟响应链相关了

官方问答对UIResponder的介绍如下:

Responder objects—that is, instances of UIResponder—constitute the event-handling backbone of a UIKit app. Many key objects are also responders, including the UIApplication object, UIViewController objects, and all UIView objects (which includes UIWindow). As events occur, UIKit dispatches them to your app’s responder objects for handling.
许多关键的对象都是responder,包括UIApplication、UIViewController和UIView
当event发生时,UIKit把它派发给app的响应对象来处理
There are several kinds of events, including touch events, motion events, remote-control events, and press events. To handle a specific type of event, a responder must override the corresponding methods. For example, to handle touch events, a responder implements the touchesBegan(:with:), touchesMoved(:with:), touchesEnded(:with:), and touchesCancelled(:with:) methods. In the case of touches, the responder uses the event information provided by UIKit to track changes to those touches and to update the app’s interface appropriately.
In addition to handling events, UIKit responders also manage the forwarding of unhandled events to other parts of your app. If a given responder does not handle an event, it forwards that event to the next event in the responder chain. UIKit manages the responder chain dynamically, using predefined rules to determine which object should be next to receive an event. For example, a view forwards events to its superview, and the root view of a hierarchy forwards events to its view controller.

可通过nextResponder属性来返回下一个响应值对象,如下:
iOS事件响应链介绍_第5张图片

所以如果在redView中,重写的touchesBegan:withEvent:方法,不加上[super touchesBegan:touches withEvent:event];,则事件不会向上面传递,相当于中断了响应者链

手势事件

1.手势与hitTest的关系

如下的例子:
自定义一个拖动手势RedColorTapGesture,重写touch相关方法

@interface RedColorTapGesture : UIPanGestureRecognizer<UIGestureRecognizerDelegate>

@end
@implementation RedColorTapGesture

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{    
    NSLog(@"RedColorTapGesture touchBegan ");
    [super touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"RedColorTapGesture touchesMoved");
    [super touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"RedColorTapGesture touchesEnded");
    [super touchesEnded:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSLog(@"RedColorTapGesture touchesCancelled");
    [super touchesCancelled:touches withEvent:event];
}

@end

在redView上添加一个RedColorTapGesture手势,拖动,观察控制台输出
iOS事件响应链介绍_第6张图片

2018-11-30 15:15:04.618614+0800 事件层次分析[42539:2949533] redColorView hitTest
2018-11-30 15:15:04.618790+0800 事件层次分析[42539:2949533] redColorView pointInside
2018-11-30 15:15:04.618989+0800 事件层次分析[42539:2949533] redColorView hitTest
2018-11-30 15:15:04.619083+0800 事件层次分析[42539:2949533] redColorView pointInside
2018-11-30 15:15:04.619487+0800 事件层次分析[42539:2949533] RedColorTapGesture touchBegan
2018-11-30 15:15:04.619863+0800 事件层次分析[42539:2949533] redColorView touchBegan
2018-11-30 15:15:04.741574+0800 事件层次分析[42539:2949533] RedColorTapGesture touchesMoved
2018-11-30 15:15:04.741806+0800 事件层次分析[42539:2949533] redColorView touchesMoved
2018-11-30 15:15:04.765998+0800 事件层次分析[42539:2949533] RedColorTapGesture touchesMoved
2018-11-30 15:15:04.766234+0800 事件层次分析[42539:2949533] redColorView touchesMoved
......
2018-11-30 15:15:04.922074+0800 事件层次分析[42539:2949533] -[EOCGestureEventViewCtrl panAction]
2018-11-30 15:15:04.922265+0800 事件层次分析[42539:2949533] redColorView touchesCancelled
2018-11-30 15:15:04.943999+0800 事件层次分析[42539:2949533] RedColorTapGesture touchesMoved
2018-11-30 15:15:04.944203+0800 事件层次分析[42539:2949533] -[EOCGestureEventViewCtrl panAction]
2018-11-30 15:15:04.944327+0800 事件层次分析[42539:2949533] -[EOCGestureEventViewCtrl panAction]
2018-11-30 15:15:04.966473+0800 事件层次分析[42539:2949533] RedColorTapGesture touchesMoved
2018-11-30 15:15:04.966673+0800 事件层次分析[42539:2949533] -[EOCGestureEventViewCtrl panAction]
2018-11-30 15:15:04.989167+0800 事件层次分析[42539:2949533] RedColorTapGesture touchesMoved
2018-11-30 15:15:04.989408+0800 事件层次分析[42539:2949533] -[EOCGestureEventViewCtrl panAction]
2018-11-30 15:15:05.011762+0800 事件层次分析[42539:2949533] RedColorTapGesture touchesMoved
2018-11-30 15:15:05.012001+0800 事件层次分析[42539:2949533] -[EOCGestureEventViewCtrl panAction]
2018-11-30 15:15:05.034189+0800 事件层次分析[42539:2949533] RedColorTapGesture touchesMoved
2018-11-30 15:15:05.034418+0800 事件层次分析[42539:2949533] -[EOCGestureEventViewCtrl panAction]
2018-11-30 15:15:05.259231+0800 事件层次分析[42539:2949533] RedColorTapGesture touchesEnded
2018-11-30 15:15:05.259419+0800 事件层次分析[42539:2949533] -[EOCGestureEventViewCtrl panAction]

如果重写redView的hitTest:withEvent:方法,直接返回nil

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    NSLog(@"%@ hitTest", self.EOCBgColorString);
    
    return nil;
}

此时拖动时,只有redView

2018-11-30 15:18:15.156214+0800 事件层次分析[42570:2963426] redColorView hitTest
2018-11-30 15:18:15.156555+0800 事件层次分析[42570:2963426] redColorView hitTest
2018-11-30 15:18:18.154568+0800 事件层次分析[42570:2963426] redColorView hitTest
2018-11-30 15:18:18.154864+0800 事件层次分析[42570:2963426] redColorView hitTest

所以:手势和pointInside以及hitTest:必须得先找到view,然后才能触发手势

如果,子view没有手势,而父view上添加了手势,那么子View可以响应父View上的手势


如下的例子,button是有颜色的view的子view,但是button在view的边界之外,此时点击button,并不会响应button的事件

iOS事件响应链介绍_第7张图片

可以这样重写-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法,来使button响应action事件:

重写有颜色view的-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    UIView *view = [super hitTest:point withEvent:event];
    if (view) {
        return view;
    }
    for (UIView *subView in self.subviews) {
        CGPoint cPoint = [self convertPoint:point toView:subView];
        if ([subView hitTest:cPoint withEvent:event]) {
            return subView;
        }
    }
    return nil;
}

其它应用包括:

参考:

  • iOS事件响应链中Hit-Test View的应用
  • Hit-Testing in iOS
  • iOS hitTest-点击事件分发分析

1.扩大响应的区域
2.将触摸事件传递给下面的视图
3.将触摸事件传递给子视图

你可能感兴趣的:(iOS,进阶)