【iOS】—— 响应者链和事件传递链


iOS事件链有两条: 事件的响应链Hit-Testing事件的传递链

  • 响应连:
    • 由离用户最近的view向系统传递。
    • initial view –> super view –> …… –> view controller –> window –> Application –> AppDelegate
  • 传递链:
    • 由系统向离用户最近的view传递。
    • UIKit –> active app's event queue –> window –> root view –> …… –> lowest view

432423423

iOS中的三大事件

iOS中的事件类型:

  • 触摸事件(手机在屏幕上触摸)
  • 加速计事件(手机摇一摇)
  • 远程遥控事件(遥控器控制)

423423423

UIKit继承图

423423423
通过继承图我们能知道,我们平时在使用的UI大多数都是继承自UIResponder的,只有继承自UIResponder的对象才能接收并处理事件,我们把这类对象称为“响应者”。就像UIApplicationUIViewControllerUIView都继承自UIResponder,因此他们都可以接收处理事件,并且UIResponder中提供了三种处理事件的方法(触摸事件、加速计事件、远程控制事件),所以我们才能在UI中实现各种点击事件:

// 触摸事件
// 开始接触屏幕,就会调用一次
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
// 手指开始移动就会调用(这个方法会频繁的调用,其实一接触屏幕就会多次调用)
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
// 手指离开屏幕时,调用一次
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
// 触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,或者view上面添加手势时,系统会自动调用view的下面方法
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;

// 加速计事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event;
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event;

// 远程控制事件
- (void)remoteControlReceivedWithEvent:(UIEvent *)event;

通过这三种处理事件的方法就可以知道事件的整个过程,我们平时经常使用的就是触摸事件,其中有两个参数,UITouchUIEvent

除了以上方法,现在还有一个类,UIGestureRecognizer,它是一个抽象类,使用它的子类能帮助我们轻松识别view上的各种手势。

UITapGestureRecognizer // 敲击
UIPinchGestureRecognizer // 捏合,用于缩放
UIPanGestureRecognizer // 拖拽
UISwipeGestureRecognizer // 轻扫
UIRotationGestureRecognizer // 旋转
UILongPressGestureRecognizer // 长按

UITouch

当你用一根手指触摸屏幕时,会创建一个与之关联的UITouch对象,一个UITouch对象对应一根手指。在事件中可以根据NSSet中UITouch对象的数量得出此次触摸事件是单指触摸还是双指多指等等。

UITouch几个重要属性
// 触摸产生时所处的窗口
@property(nonatomic, readonly, retain) UIWindow *window;
// 触摸产生时所处的视图
@property(nonatomic, readonly, retain) UIView *view;
// 短时间内点按屏幕的次数,可以根据tapCount判断单击、双击或更多的点击
@property(nonatomic, readonly) NSUInteger tapCount;
// 记录了触摸事件产生或变化时的时间,单位是秒
@property(nonatomic, readonly) NSTimeInterval timestamp;
// 当前触摸事件所处的状态
@property(nonatomic, readonly) UITouchPhase phase;
UITouch的两个方法(可用于view的拖拽)
- (CGPoint)locationInView:(UIView *)view;
/*
  返回值表示触摸在view上的位置
  这里返回的位置是针对传入的view的坐标系(以view的左上角为原点(0, 0))
  调用时传入的view参数为nil的话,返回的是触摸点在UIWindow的位置
*/

// 该方法记录了前一个触摸点的位置
- (CGPoint)previousLocationInView:(UIView *)view;

UIEvent

每产生一个事件,就会产生一个UIEvent事件,UIEvent称为事件对象,记录事件产生的时刻和类型等等。

UIEvent几个重要属性
// 事件类型
@property(nonatomic, readonly) UIEventType type;
@property(nonatomic, readonly) UIEventSubtype subtype;
// 事件产生的时间
@property(nonatomic, readonly) NSTimeInterval timestamp;

事件的产生与传递

传递链

UIApplication传递事件到当前Window是明确的(即一定会的),接下来就是从Window开始找最佳响应视图,此过程有两个重要的方法:

- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event; // 递归调用的事件,从最底层开始,一直往上找,直到找到一个最上层的能响应的视图就返回该视图
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event; // 判断点击区域是否在该视图的范围中,不在该视图范围中就结束递归,返回nil
传递过程
  • 1.发生触摸事件后,压力转为电信号,系统将产生UIEvent事件,记录事件产生的时间和类型。
  • 2.系统会将该事件添加到UIApplication管理的事件队列中。
  • 3.UIApplication将事件队列中的第一个事件分发给UIWindow,这时就会调用UIWindow的hitTest:withEvent:方法。
  • 4.当前 window/视图 调用hitTest:withEvent:方法,hitTest:withEvent:方法内部会通过以下条件判断 window/视图 能否能响应事件,以下判断条件都是不能响应事件的:
    • 不允许交互:userInteractionEnabled=NO
    • 隐藏:hidden = YES
    • 透明度:alpha < 0.01,alpha小于0.01为全透明
  • 5.如果能响应,该函数又会调用pointInside方法判断当前触摸点是不是在视图范围内,不在视图范围内也是不会响应的。
  • 6.如果在 window/视图 范围内,开始反向遍历 window/视图 的子视图列表subviews,遍历的同时会调用subviews中每个子视图的hitTest:withEvent:方法,判断逻辑和上面的一样,直到找到离用户最近的、能响应事件的视图。
  • 4.5.6过程会递归判断,直到找到最外层合适的view,最后返回的view就是最佳响应视图。
hitTest:withEvent:方法的可能实现
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 1.判断当前控件能否接收事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha) return nil;     
    // 2. 判断点在不在当前控件
    if ([self pointInside:point withEvent:event] == NO) return nil;
    // 3.从后往前遍历自己的子控件
    NSInteger count = self.subviews.count;
    for (NSInteger i = count - 1; i >= 0; i--) {
        UIView *childView = self.subviews[i];
        // 把当前控件上的坐标系转换成子控件上的坐标系
        CGPoint childP = [self convertPoint:point toView:childView];
        UIView *fitView = [childView hitTest:childP withEvent:event];
        if (fitView) { // 直到寻找到最合适的view
            return fitView;
        }
    }
    // 循环结束,表示没有比自己更合适的view
    return self;
}
注意
  • 查找结束,返回最终的view,UIApplication会调用UIWindow的sendEvent,从而触发对应的响应方法,如果我们在UIWindow中重写sendEvent而不调用super的实现,所有的点击事件都不会触发,因为事件是从最底层传递上来的,你切断了最底层的传递,肯定就无法响应了
  • 实际调用hitTest的过程,系统为了找到精准的触摸点会多次调用hitTest方法
  • 如果重写hitTest返回self,传递过程就会终止,那么当前view就是最合适的view;返回nil,传递也会终止,父视图superView就是最合适的view
  • 如果遍历subviews的过程都没找到合适的view,那么subviews中的子view的hitTest方法都会被调用一次
  • hitTest方法会调用pointInside判断当前视图是否在点击区域,所以超出父视图边界的控件无法响应事件
  • 同一个view上的两个子视图有重叠部分,后加入的视图会被加入到事件传递链
  • 在打印视图层级结构中部分视图执行hitTestpointInSide方法中可以看到,viewController并没有执行这两个方法。所以传递链中没有viewController,因为viewController本身不具有大小的概念。而响应链中有viewController,因为viewController继承UIResponder。

响应链

当找到最合适的响应者之后,便会调用控件相应的touch方法来作具体处理,然而这些方法默认都是不做处理的,但是我们要是想让该响应者响应该事件就可以重写一开始说的那几个响应事件方法,并且我们也可以在重写touch方法中加入[super touch],使多个响应者同时响应同一事件。如果我们对响应事件的方法不做处理那么将该事件随着响应者链条往回传递,交给上一个响应者来处理(即调用super的touch方法),直到找到一个能响应该事件的响应者。

响应过程
  • 1.通过hitTest返回的view为当前事件的第一响应者,nextResponder为上一个响应者
  • 2.如果当前view默认不去重写响应事件方法,或者重写调用了父类的响应事件方法,响应就会沿着响应者链向上传递(上一个响应者一般是superView,可以通过nextResponder属性获取上一个响应者)
  • 3.如果上一个响应者是viewController,由viewController的view处理,如果view本身没处理,则传递给viewController本身
  • 4.重复上述过程,直到传递到window,window如果也不能处理,则传递到UIApplication,如果UIApplication的delegate继承自UIResponder,则交给delegate处理,如果delegate也不处理最后丢弃

UIControl的Target-Action设计模式

在 UIControl 及其子类(UIButton等控件)的设计上,iOS Api 采用了Target-Action的设计模式。宏观上来看,这并不属于响应者链的一部分,它只是事件处理的一个末端机制。

[button addTarget:self action:@selector(buttonClicked:) forControlEvents:UIControlEventTouchUpInside];

这种代码我们几乎天天都在写,这就是典型的Target-Action的设计模式

  • 第一个参数,Target,UIEvent的新的作用对象(响应者)
  • 第二个参数,Action,是对该UIEvent做出响应的具体动作
  • 第三个参数,是对UIEvent的抽象映射

UIControl 通过这种Target-Action的方式对 UIEvent 进行了转发,从而可以把 UIEvent 事件转发给任意对象处理(原本只有 UIReponder 对象和手势识别器对象才能处理)。

Target-Action设计模式的具体实现

UIControl 实现的具体做法其实是,重写touchesBegan相关方法,通过改变响应者链来实现事件转发的:

  • 首先在touchesBegan方法中调用sendAction:to:forEvent:把消息先转发给UIApplication,让其统一处理。
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        [self sendAction:@selector(buttonClicked:) to:target forEvent:event];
    }
    
  • 然后UIApplication调用sendAction:to:from:forEvent:把消息交给具体的Target(对象)处理。
    - (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
        [[UIApplication sharedApplication] sendAction:action to:target from:self forEvent:event];
    }
    

4234234

简要概括

简单的来说,Target-Action设计模式其实就是重写了UIControl类的touch响应事件,通过重写的类的touch响应事件将事件转发给了UIApplication(子类没有重写touch方法默认就会调用父类的),然后UIApplication通过响应事件传递过来的新响应者,将这个事件的具体调用方法(即我们自定义的方法)传递给了这个新响应者,这样新响应者就实现了方法的调用,执行了我们自定义的方法。

扩大点击范围

扩大点击范围,用到了两个主要方法:

// 返回矩形是否包含指定的点
// rect 要检查的矩形
// point 检查的点
CG_EXTERN bool CGRectContainsPoint(CGRect rect, CGPoint point)
    CG_AVAILABLE_STARTING(10.0, 2.0);


// 返回一个比源矩形小或大且具有相同中心点的矩形
// rect 原CGRect结构
// dx 用于调整源矩形的x坐标值。若要缩小原矩形,请指定一个正值。若要扩大原矩形,请指定负值。
// dy 用于调整源矩形的y坐标值。若要缩小原矩形,请指定一个正值。若要扩大原矩形,请指定负值。
CG_EXTERN CGRect CGRectInset(CGRect rect, CGFloat dx, CGFloat dy) __attribute__ ((warn_unused_result))
    CG_AVAILABLE_STARTING(10.0, 2.0);

举例说明

先创建一个UIButton的子类,并重写其pointInside方法:

// 该方法返回YES,就会触发其响应事件
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    // 将该button的原bounds大小扩大,x扩大20,y也扩大20
    CGRect bounds = CGRectInset(self.bounds, -20, -20);
    // 判断点击的点是否在更改后的bouns中
    return CGRectContainsPoint(bounds, point);
}

或者我们重写hitTest方法,使其成为最佳响应者:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
	// 将该button的原bounds大小扩大,x扩大20,y也扩大20
    CGRect bounds = CGRectInset(self.bounds, -20, -20);
    // 判断点击的点是否在更改后的bouns中
    if (CGRectContainsPoint(bounds, point)) {
        return self;
    } else {
        return nil;
    }
}

这里我们扩大其响应范围,创建的button大小是不会变化的,变化的只是我们看不到的其可以响应的范围。

点击穿透事件

点击穿透事件就比较麻烦了,它要在重写的UIButton中再传入一个你想要执行事件的button,通过它来响应点击事件。

举例说明

如图,视图1与视图2有重合部分3,当点击3时,我们希望视图1来响应这个点击事件

4234234
这时应该重写绿色部分的hitTest的方法,同时还需要给绿色部分传入紫色部分的对象:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
	// 将紫色视图的响应范围转换出来,给point1
    CGPoint point1 = [self convertPoint:point toView:_purpleView];
    // 判断点击范围是否在紫色视图范围中,如果是就通过紫色视图来执行事件
    if ([_purpleView pointInside:point1 withEvent:event]) {
        return _purpleView;
    } else { // 否则就执行父类的方法
        return [super hitTest:point withEvent:event];
    }
}

iOS事件传递及响应者链条
参考
iOS触摸事件全家桶

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值