响应链的分析与应用

UI相关的开发和调试中,经常会涉及到点击、触摸、手势等情况的调试,最开始的开发时,也许会用用touchBegan等方法或者网上找找相关解决方案,并没有系统的了解过相关知识。今天主要是讲一下点击、触摸、手势调试的根源——响应链,顺带讲下手势与其关系,深入理解这些,对我们以后调试这些问题都能思路更清晰。

先从我们日常开发中可能会遇到的问题,我们可以自测下对于响应链的理解是否达标,如果这三个问题都能清晰地知道解决方案,可以忽略下文了:

  • 隐藏键盘可以怎样实现?
  • resignFirstResponder是什么意思?
  • 一个视图A遮挡了视图B,如果让B来响应,而A不响应?

通过本文,对以上三个问题不仅知道解决方案,而且“知其所以然”。

触摸事件UIEvent

首先,我们平常在手机上最简单的一个动作开始:


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

UITouch与UIEvent

一次触摸将产生一个UITouch:一个手指离开屏幕前的一系列动作,包含时间戳、所在视图、 力度等信息。
UIEvent:多个UITouch组成,也就是多个触摸组成。 一个event指的是第一个手指开始触摸到最后一个手指离开屏幕这段时间所有UITouch的总和。
那么这个UIEvent是如何在系统解析出来后,传递下去呢?哪些对象可以传递UIEvent呢?

响应者Responder

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


上图的四个类都继承自UIResponder:

  • @interface AppDelegate : UIResponder
  • @interface UIWindow : UIView
  • @interface UIView : UIResponder
  • @interface UIViewController : UIResponder

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通过hittest遍历的过程链。 责任传递就是处于应该响应顺序上的上一个对象有责任处理这个UIEvent,如果他不处理,就轮到响应链上的下一个来处理。也就是说刚才UIEvent的传递过程和响应链是两个互逆的顺序,但是这个具体哪个能作为响应者,该如何确定呢?

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

@interface UIView(UIViewGeometry)

// animatable. do not use frame if view is transformed since it will not correctly reflect the actual location of the view. use bounds + center instead.
@property(nonatomic) CGRect            frame;

// use bounds/center and not frame if non-identity transform. if bounds dimension is odd, center may be have fractional part
@property(nonatomic) CGRect            bounds;      // default bounds is zero origin, frame size. animatable
@property(nonatomic) CGPoint           center;      // center is center of frame. animatable
@property(nonatomic) CGAffineTransform transform;   // default is CGAffineTransformIdentity. animatable
@property(nonatomic) CGFloat           contentScaleFactor NS_AVAILABLE_IOS(4_0);

@property(nonatomic,getter=isMultipleTouchEnabled) BOOL multipleTouchEnabled __TVOS_PROHIBITED;   // default is NO
@property(nonatomic,getter=isExclusiveTouch) BOOL       exclusiveTouch __TVOS_PROHIBITED;         // default is NO

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

- (CGPoint)convertPoint:(CGPoint)point toView:(nullable UIView *)view;
- (CGPoint)convertPoint:(CGPoint)point fromView:(nullable UIView *)view;
- (CGRect)convertRect:(CGRect)rect toView:(nullable UIView *)view;
- (CGRect)convertRect:(CGRect)rect fromView:(nullable UIView *)view;

@property(nonatomic) BOOL               autoresizesSubviews; // default is YES. if set, subviews are adjusted according to their autoresizingMask if self.bounds changes
@property(nonatomic) UIViewAutoresizing autoresizingMask;    // simple resize. default is UIViewAutoresizingNone

- (CGSize)sizeThatFits:(CGSize)size;     // return 'best' size to fit given size. does not actually resize view. Default is return existing view size
- (void)sizeToFit;                       // calls sizeThatFits: with current view bounds and changes bounds size.

@end  

——————————hittest————————

理解hittest就是理解响应链的核心点:

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

通过hittest的这个特点,我们便可以通过代码来验证一下,在我们所知的最顶层的view上重写hittest方法,通过断点获取到断点:
我们验证的视图层次如下, vc上放着一个tableview,table上面一个yellowview:

Table *table = [[Table alloc]initWithFrame:self.view.bounds];
 table.backgroundColor = [UIColor lightGrayColor];
table.dataSource = self;
table.delegate = self;
[self.view addSubview: table];
    
YellowView *view = [[YellowView alloc]initWithFrame:CGRectMake(20, 20, 200, 200)];
view.backgroundColor = [UIColor yellowColor];
[table addSubview:view];

我们便可以在yellowview的实现中重写hittest

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    return self;
}

得到堆栈如下:

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

  • 注意:以下三种情况下,view将不会执行hittest:
    hidden=YES
    userInteractionEnabled=NO
    alpha<0.01

响应链

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

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    [super touchesBegan:touches withEvent:event];
    
    NSLog(@"------------------");
    NSLog(@"      v ");
    NSLog(@"    %@",  [self class]);

    UIResponder * next = [self nextResponder];
    
    while (next != nil)
    {
        NSLog(@"      v ");
        NSLog(@"    %@",  [next class]);
        next = [next nextResponder];
    }
    NSLog(@"------------------");
}

打印log结果如下图:


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



UIView的nextResponder可能是UIView或UIViewViewController两种情况:

  • 当UIView的父类还是UIView时,nextResponder就是这个view父类;
  • 当UIView的父类还是UIViewViewController时,nextResponder就是这个vc父类;
    UIViewViewController的nextResponder是UIWindow,
    UIWindow的nextResponder是UIApplication的delegate(appDelegate)。

手势与响应链关系

这里不会讲述手势相关的知识, 只简单减少下手势相关问题调试时可能碰到的问题:当某个view添加了手势后,通过touchesBegan判断当前view是否是响应者时,并没有执行(实际这个view就是响应者),原因如下:


当window接收到UIEvent后,并不会直接将UIEvent传递给链条上的view,因为其添加了手势,这时手势识别器会将处理UIEvent,如果识别出来是手势,会立刻给view调用touchCancel;如果识别不出来是手势,才会调用touchesBegan,让view来处理这个UIEvent。

应用

通过响应链的基本知识,我们可以考虑下一些应用:

1. 找到view所属vc

@implementation UIView (ParentController)
-(UIViewController*)parentController
{
    UIResponder *responder = [self nextResponder];
    while (responder) 
    {
      if ([responder isKindOfClass:[UIViewController class]]) 
          {
        return (UIViewController*)responder;
      }
    responder = [responder nextResponder];
  }
    return nil;
}
@end

可以看出这个方法正是利用了,view的nextResponder可能是view,如果是view就继续找nextResponder,直到是vc了,就是它所属的vc。如下左图。
当情况更复杂一点,如下面右边图时:

  • 注意: 这时,vc上面还有childVC,这时childVC的nextResponder不是UIWindow,而是其父类vc,一直到vc已经是UIWindow的rootVC,其nextResponder才是UIWindow。

2. 视图被遮挡时的响应

当图A被图B遮挡时,通常B会将点击事件阻断,A收不到。这时如果想要让A能收到,可以有两种方式:

  • 利用hittest三种情况不调用的知识,可以设置A的userInteractionEnabled=NO;
  • 重写A的hittest方法,使其返回nil;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容