Skip to content

事件分发传递和响应链

FFur edited this page Aug 14, 2018 · 1 revision

事件分发传递和响应链

我们在手机上的一个触摸或点击,在手机系统中是怎么样一个过程: 其实首先就是手机屏幕和底层相关软硬件对这个触摸一个解析过程,将这个触摸解析成event,然后iOS系统将这个event事件传递到相应的界面上,由界面来响应我们的操作,给出对应的反馈,这样一个交互过程。这其中传递的过程就是我们今天的要了解的主角。从图上可以看到,从window—view之间,具体是怎么样一个规律呢? 先从这个触摸或点击开始了解一下:

UITouch与UIEvent

  • 一次触摸将产生一个UITouch:一个手指离开屏幕前的一系列动作,包含时间戳、所在视图、 力度等信息。
  • UIEvent:多个UITouch组成,也就是多个触摸组成。 一个event指的是第一个手指开始触摸到最后一个手指离开屏幕这段时间所有UITouch的总和。

那么这个UIEvent是如何在系统解析出来后,传递下去呢?哪些对象可以传递UIEvent呢?

事件传递

响应者Responder

响应者就是可以接收到UIEvent的对象,也是可以最终响应用户的操作的对象。iOS开发中主要与四种对象可以作为responder:

UIResponder作为响应者的基类,主要是定义了一些关于响应的属性和方法,用于子类判断或者执行关于响应的操作:

@interface UIResponder : NSObject <UIResponderStandardEditActions>

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
#else
- (nullable UIResponder*)nextResponder;
#endif

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canBecomeFirstResponder;    // default is NO
#else
- (BOOL)canBecomeFirstResponder;    // default is NO
#endif
- (BOOL)becomeFirstResponder;

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canResignFirstResponder;    // default is YES
#else
- (BOOL)canResignFirstResponder;    // default is YES
#endif
- (BOOL)resignFirstResponder;

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL isFirstResponder;
#else
- (BOOL)isFirstResponder;
#endif

// Generally, all responders which do custom touch handling should override all four of these methods.
// Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
// touch it is handling (those touches it received in touchesBegan:withEvent:).
// *** You must handle cancelled touches to ensure correct behavior in your application.  Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (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);

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

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

- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);

- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender NS_AVAILABLE_IOS(3_0);
// Allows an action to be forwarded to another target. By default checks -canPerformAction:withSender: to either return self, or go up the responder chain.
- (nullable id)targetForAction:(SEL)action withSender:(nullable id)sender NS_AVAILABLE_IOS(7_0);

@property(nullable, nonatomic,readonly) NSUndoManager *undoManager NS_AVAILABLE_IOS(3_0);

这些responder在接受上一个responder传递而来的响应UIEvent, 这样依次传递响应的顺序就是一个响应链 如果将响应链顺序反向:就是UIEvent通过hit-test遍历的过程链。 责任传递就是处于应该响应顺序上的上一个对象有责任处理这个UIEvent,如果他不处理,就轮到响应链上的下一个来处理 也就是说刚才UIEvent的传递过程和响应链是两个互逆的顺序

其实系统是通过hit-test方法来决定哪个view作为最终响应用户操作的载体。hit-test是UIView的一个扩展方法,与其搭配的就是pointInside方法, 二者功能可以确定点击或者触摸的点是否在view的范围内,通过hit-test的返回值告诉系统当前view是否可以作为响应者。

hit-test

理解hit-test就是理解响应链的核心点:

iOS系统在判断哪个view来响应用户时 ,就是通过hit-test在所有view的“树”上遍历:当前内存中所有view的子类都会执行hit-test方法,逐个遍历每个叶子节点,如果叶子节点view上还有叶子,就进一步遍历下去。再啰嗦一句:即使某个view不会作为响应者,并且也不在点击范围内,只要它是当前显示的vc或者view的子视图,就会被httest遍历到,从而获取到是否可以作为响应者的信息。

序号25的栈帧上,主线程的runloop接收到UIEvent,然后经由分发器,首先分发到window上,然后传递给UIView,然后16--13正式遍历这个UIView的所有叶子的过程。在遍历过程中,如果子节点的任何一个view在执行hit-test时返回了self(当前子view),就表示找到了响应的视图,这时父类也会通过返回值将这个子view向上返回,一直返回到window,返回到application,也就让系统知道了应该让谁来处理这个触摸。

注意:以下三种情况下,view将不会执行hit-test:

  • hidden=YES
  • userInteractionEnabled=NO
  • alpha<0.01
  • 视图超出父视图的区域

响应链

通过上面的hit-test遍历后,已经找到了响应者responder,由于其必然继承自UIResponder,我们可以重写UIResponder的touchBegan方法,touchBegan方法中再通过nextResponder属性来遍历地查看响应者链上的每个响应者:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event

我们以下图来具体描述下响应者链:

点击穿透

点击区域1和3,自然是不用说了。

那么,点击区域2,是响应哪个事件呢?

通过上面的hit-test遍历规则分析,我们该知道蓝色3区域是最后一个subview,后进先出,那么他应该是first reponder,所以点击2区域是响应蓝色按钮事件。

那么,既然我们的主题是事件穿透,那么肯定是希望红色2区域响应事件的。怎么做呢,肯定是要重写hit-test方法的,基本思路就是在点击2区域返回的是红色button,那么等于改写了hit-test规则,first responder就可以变成红色button。

那么,每个view都有hit-test,我们是在哪个view重写hit-test呢?

因为hit-test在传递事件遍历的时候是从父view到subviews,而subviews是从蓝色到红色。

然而在点击2区域的时候,遍历到蓝色的时候就已经返回了,根本不会调用红色button的hit-test。

所以,我们可以在蓝色view里重写,也可以在父view里重写hit-test。下面是在蓝色view里重写的hit-test

- (UIView *)hit-test:(CGPoint)point withEvent:(UIEvent *)event {
    CGPoint redBtnPoint = [self convertPoint:point toView:_redButton];
    if ([_redButton pointInside:redBtnPoint withEvent:event]) {
        return _redButton;
    }
    //如果希望严谨一点,可以将上面if语句及里面代码替换成如下代码
    //UIView *view = [_redButton hit-test: redBtnPoint withEvent: event];
    //if (view) return view;
    return [super hit-test:point withEvent:event];
}

如此,点击2区域,就会响应红色按钮就会响应事件。 这种透传有个前提:你要能获取到被挡住的view对象。

如果是私有的view对象,甚至通过runtime都获取不到该view对象。比如说appstore的下载按钮以及其他subviews,那就不能用这种方案。

总结

从上面可以看出,事件的传递方向是(hit-test就是事件的传递):

UIApplication -> UIWindow ->ViewController-> UIView -> initial view

而Responder传递方向是(还记得nextResponder吗):

Initial View -> Parent View -> ViewController -> Window -> Application

参考

  1. 响应链的分析与应用 - 简书
  2. ios事件传递和响应机制 - CSDN博客
  3. iOS事件响应链中Hit-Test View的应用 - 简书