转载

iOS响应链(Responder Chain)

原文

最近在写一个图片浏览的需求,一些地方我使用了响应者来处理,顺便又去看看了官方文档,这里记录一下官方文档,并给出一些示例加深理解。

概述

App使用响应者对象接收和处理事件,响应者对象是任何UIResponder的实例。UIResponder的子类包括UIView,UIViewController,UIApplication等。响应者接收到原始事件数据,必须处理事件或者转发到另一个响应者对象。当你的App接收到一个事件时,UIKit自动引导事件到最合适的响应者对象,也叫做第一响应者。

不能处理的事件被传递到响应链中,这是App响应者对象动态配置的。在App中没有单一的响应链,UIKit定义了默认的规则关于对象如何被传递在一个响应者到另一个响应者,但是你可以重写响应者对象中适当的属性来改变这些规则。

下图是官方给出的一个默认响应链:

iOS响应链(Responder Chain)

Default Responder Chain

App中包含一个UILable,UITextField,UIButton,以及2个backgroundView,如果UITextField不能响应事件,UIKit发送事件到UITextField的父视图(UIView)对象,随后是UIWindow的根视图(UIView)。从根视图,响应者链在事件传递到UIWindow之前,先转移到所拥有的UIViewController。如果UIWindow不能处理事件,UIKit传递事件到UIApplication对象,也可能到app delegate如果对象是UIResponder的实例并且不是响应链的一部分。

确定事件的第一响应者

事件的每个类型,UIKit指定一个第一响应者,然后最先发送事件到这个对象。第一响应者基于事件的类型而变化。

  • Touch event

    第一响应者是触摸事件产生的view

  • Press event

    第一响应者是焦点响应者。

  • Shake-motion events,Remote-control events,Editing menu messages

    第一响应者是你或者UIKit指定的对象。

注意:运动事件相关的加速度计、陀螺仪、磁强计都不属于响应者链。而是由CoreMotion传递事件给你指定的对象。Core Motion

控件直接与它相关的target对象使用action消息通信。当用户与控件交互时,控件调用target对象的action方法,换句话说,控件发送action消息到目标对象。Action消息不是事件,但是它仍然可以利用响应链。当控件的target对象为nil,UIKit从target对象和响应链走,直到找到一个对象实现了合适的action方法。

如果视图有添加手势识别器,手势识别器接收touch和press事件在视图接收事件之前。如果所有的视图的手势识别器都不能识别它们的手势,这些事件会传递到视图处理。如果视图不能处理它们,UIKit传递事件到响应链。

确定哪个响应者包含Touch事件

UIKit使用基于视图的hit-testing来确定Touch事件在哪里产生。UIKit将Touch位置与视图层级中的视图对象的边界进行了比较。UIView的hitTest:withEvent:方法在视图层级中执行,寻找最深的包含指定Touch的子视图,这个视图将成为Touch事件的第一响应者。

注意:如果Touch位置超过视图边界,hitTest:withEvent方法将忽略这个视图和它的所有子视图。结果就是,当视图的ciipsToBounds属性为NO,子视图超过视图边界也不会返回,即使它们包含发生的Touch。

UIKit不变的分配每一个Touch给包含它的视图。UIKit创建UITouch对象当touch第一次产生时,释放这个UITouch对象在touch结束时。当touch位置或者其他参数改变时,UIKit更新UITouch对象新的信息。只有包含它的视图这个属性不会改变。甚至这个touch位置移动刀初始视图的外面,这个属性也不会改变。

hitTest:withEvent

这个方法返回最远的子视图在视图层级中,这个子视图是能接收包含指定点的(包括它本身)。

这个方法遍历视图层级让每个子视图调用poiotInside:withEvent:方法确定哪个子视图应该接收这个touch事件。如果poiotInside:withEvent: 返回YES,那么子视图的层次是类似遍历,直到找到最前面的视图包含指定点的。如果一个视图不包含该点,那么其分支视图可以被忽略。很少需要自己调用这个方法。但是可以重写它去隐藏touch事件在子视图中。

这个方法忽略以下情况:

  • 视图是隐藏的 hidden = YES

  • 用户交互关闭的 userInteractionEnabled = NO

  • 透明度小于0.01的 alpha < 0.01

这个方法在确定命中的时候,不考虑视图的内容。因此,即使指定的点位于该视图内容的透明范围,仍然可以返回视图。

点在接收者的范围之外不会被命中,即使它们实际上处于接收者的子视图之内。如果当前视图的cilpsToBounds属性被设置为NO,影响了子视图超过当前视图会产生这种情况。

改变响应链

你可以改变响应链通过重写你的响应对象的nextResponder属性。当你这样做了之后,下一个响应者就是你设置的。

许多UIKit的类已经重写了这个属性然后返回了指定的对象。

  • UIView 如果视图是ViewController的根视图,下一个响应者为ViewController,否者是视图的父视图。

  • UIViewController 如果视图控制器是window的根视图下一个响应者为window对象。如果视图控制器是由另一个视图控制器推出来,那么下一个响应者为正在推出的视图控制器。

    -UIWindow 下一个响应者为UIApplication对象。

  • UIApplication 下一个响应者为app delegate,但是代理应该是UIResponder的一个实例 而不是 UIView,UIViewController或者app对象本身。

只看理论肯定是很迷茫的,下面我通过简单的一些示例代码演示部分内容。

在iOS中能够响应事件的都是UIResponder的子类对象。 UIResponder里有4个点击回调的方法。

- (void)touchesBegan:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(nullable UIEvent *)event;

参数里可以看到有一个UITouch和一个UIEvent对象,分别代表点击对象和事件对象。

为了便于测试我先添加了一个UIView类别。

#import "UIView+Responder.h"

static inline void swizzling_exchangeMethod(Class class ,SEL originalSelector, SEL swizzledSelector) {

    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

    BOOL success = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
    if (success) {
        class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
    }else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
}

@implementation UIView (Responder)


+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        swizzling_exchangeMethod([UIView class], @selector(touchesBegan:withEvent:), @selector(ds_touchesBegan:withEvent:));
        swizzling_exchangeMethod([UIView class], @selector(touchesMoved:withEvent:), @selector(ds_touchesMoved:withEvent:));
        swizzling_exchangeMethod([UIView class], @selector(touchesEnded:withEvent:), @selector(ds_touchesEnded:withEvent:));
    });
}


#pragma mark - 

- (void)ds_touchesBegan: (NSSet *)touches withEvent: (UIEvent *)event
{
    NSLog(@"%@ touch begin", self.class);
    UIResponder *next = [self nextResponder];
    while (next) {
        NSLog(@"%@",next.class);
        next = [next nextResponder];
    }
}

- (void)ds_touchesMoved: (NSSet *)touches withEvent: (UIEvent *)event
{
    NSLog(@"%@ touch move", self.class);
}

- (void)ds_touchesEnded: (NSSet *)touches withEvent: (UIEvent *)event
{
    NSLog(@"%@ touch end", self.class);
}

接着创建了4个继承于UIView的子View,AView的子视图为 BView、DView。BView的子视图为CView。

iOS响应链(Responder Chain)

视图层级

首先是模拟官方的例子,我们点击CView,控制台输出如下:

iOS响应链(Responder Chain)

寻找响应者

因为CView并不能响应这个事件,所以会一直往上寻找,和官方给的例子完全符合。

如果view上有手势呢?给AView添加一个单击手势。

    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(aviewAction)];
    [aview addGestureRecognizer:tap];

- (void)aviewAction {
    NSLog(@"单击");
}

单击之后控制台显示:

iOS响应链(Responder Chain)

识别到手势

长按后

iOS响应链(Responder Chain)


长按后没识别到手势事件交给视图

可以发现,无论有没有手势都会调用begin方法,如果识别到手势,UIView自己的end方法不调用了,会执行单击事件。如果没有识别到手势,则会调用end方法,接着交给UIView自己处理。至于响应链的输出在前面是因为我写在了begin方法里,在使用正常使用场景里,我们点击完松开手了才响应事件,也就是end之后才响应,有手势就执行手势方法 忽略了end,所以说手势接收事件在视图接收事件之前。

现在来看一下系统是怎么通过hit-test找到究竟是哪一个View产生的Touch,也就是包含Touch事件。

为了模拟系统的实现,在+(void)load()方法里添加。然后写一下方法实现。

swizzling_exchangeMethod([UIView class], @selector(hitTest:withEvent:), @selector(ds_hitTest:withEvent:));
swizzling_exchangeMethod([UIView class], @selector(pointInside:withEvent:), @selector(ds_pointInside:withEvent:));
//模拟一下,系统真正的实现肯定不是这样的,毕竟事件我都没用上。。
- (UIView *)ds_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];
            //递归调用hitTest:withEvent继续判断
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                //在这里打印self.class可以看到递归返回的顺序。
                return hitTestView;
            }
        }
        //这里就是该视图没有子视图了 点在该视图中,所以直接返回本身,上面的hitTestView就是这个。
        NSLog(@"命中的view:%@",self.class);
        return self;
    }
    //不在这个视图直接返回nil
    return nil;
}

- (BOOL)ds_pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event {
    BOOL success = CGRectContainsPoint(self.bounds, point);
    if (success) {
        NSLog(@"点在%@里",self.class);
    }else {
        NSLog(@"点不在%@里",self.class);
    }
    return success;
}

我点击了CView,控制台输出如下:

iOS响应链(Responder Chain)

从(1)这里可以看出会从UIWindow一层层的开始往子视图查找,直到找到一个视图,touch点还在这个视图里,但是该视图没有子视图,这个就是最深层的。

在(2)这里我也不明白为什么会调用2次,没找到相关资料。但是看名字应该是导航栏上的那些,最后命中的是UIStatusBarWindow,我感觉应该就是UIWindow后面的一层吧,但是UIWindow又不是加在它上面的,否则不会命中它。

在这里,(3)就是响应链了。命中CView后,立即调用了begin方法。

至于其他情况和其他视图的点击,我这里就不贴出来了。把上面代码拿去测试一下就行了。

实际使用

不规则图形的点击事件,或者扩大缩小点击范围,

还有像Tarbar中间那个凸起的按钮我感觉用这个也可以实现(这个我自己没试过) ,只要重写pointInside:withEvent:方法就行了。

觉得对你有帮助点个赞吧。有什么错误欢迎指出,谢谢。

正文到此结束
Loading...