iOS響應者鏈完全掌握

點我跳轉原文地址html

概述

iOS響應者鏈(Responder Chain)是支撐App界面交互的重要基礎,點擊、滑動、旋轉、搖晃等都離不開其背後的響應者鏈,因此每一個iOS開發人員都應該完全掌握響應者鏈的響應邏輯,本文旨在經過demo測試的方式展示響應者鏈的具體響應過程,幫助讀者完全掌握響應者鏈。ios

Demo

你能夠在這裏(GitHub地址)下載本文測試的Demo源碼,閱讀本文的同時結合Demo程序有助於更加直觀深入的理解。git

探究過程

響應者(Responder)

當咱們觸控手機屏幕時系統便會將這一操做封裝成一個UIEvent放到事件隊列裏面,而後Application從事件隊列取出這個事件,接着須要找到去響應這個事件的最佳視圖也就是Responder, 因此開始的第一步應該是找到Responder, 那麼又是如何找到的呢?那就不得不引出UIView的2個方法:github

  • -(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
    返回視圖層級中能響應觸控點的最深視圖
  • -(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
    返回視圖是否包含指定的某個點

經過在顯示視圖層級中依次對視圖調用這個2個方法來確認該視圖是否是能響應這個點擊的點,首先會調用hitTest,而後hitTest會調用pointInside,最終hitTest返回的那個view就是最終的響應者Responder, 那麼問題來了,在視圖層級中是如何肯定該對哪一個View調用呢?優先級又是什麼?
爲了探尋其中的邏輯,在Demo中咱們構建了一個以下圖所示的多重視圖:app

Responder.png

這是一個簡單的控制器視圖,在Controller的視圖上添加了View1-View4共4個視圖,View1-View4和RootView都繼承自BaseView, BaseView繼承自UIView; 其中 View一、View2是RootView的子視圖,View三、View4是View2的子視圖,他們的繼承關係和父子關係圖下圖:ide

relationship.png

爲了能觀測到UIView的hitTest和pointInside調用過程,咱們寫個分類經過方法交換來打印調用的日誌:函數

@implementation UIView (DandJ)
+ (void)load {
    Method origin = class_getInstanceMethod([UIView class], @selector(hitTest:withEvent:));
    Method custom = class_getInstanceMethod([UIView class], @selector(dandJ_hitTest:withEvent:));
    method_exchangeImplementations(origin, custom);
    
    origin = class_getInstanceMethod([UIView class], @selector(pointInside:withEvent:));
    custom = class_getInstanceMethod([UIView class], @selector(dandJ_pointInside:withEvent:));
    method_exchangeImplementations(origin, custom);
}

- (UIView *)dandJ_hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%@ hitTest", NSStringFromClass([self class]));
    UIView *result = [self dandJ_hitTest:point withEvent:event];
    NSLog(@"%@ hitTest return: %@", NSStringFromClass([self class]), NSStringFromClass([result class]));
    return result;
}

- (BOOL)dandJ_pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%@ pointInside", NSStringFromClass([self class]));
    BOOL result = [self dandJ_pointInside:point withEvent:event];
    NSLog(@"%@ pointInside return: %@", NSStringFromClass([self class]), result ? @"YES":@"NO");
    return result;
}

@end

當咱們點擊視圖中的View3(紫色)時看看日誌輸出:oop

log.png

從日誌中咱們能夠看到,首先是從UIWindow開始調用hitTest, 而後通過一段導航控制器的視圖,由於咱們的控制器是在導航控制的,因此能夠先忽略這一段,而後來到RootView,調用RootView的hitTest和pointInside,由於點擊發生在RootView中因此繼續遍歷它的子視圖,能夠看到是從View2開始的,調用View2的hitTest和pointInside,pointInside返回YES,而後繼續遍歷View2的子視圖,從View4開始,由於點擊不發生在View4因此pointInside返回NO,而View4沒有子視圖了,因此返回了nil也就是打印出來的null,而後繼續在View2的另一個子視圖View3(目標視圖)中調用hitTest和pointInside,由於咱們點擊的就是View3因此pointInside返回YES,且View3沒有子視圖因此hitTest返回了本身View3,接着View2的hitTest也返回View3直到UIWindow返回View3, 自此咱們找到了響應視圖:View3!另外咱們看到對其餘的Window也有調用,只不過返回了nil。測試

  • 結論:
    1. 尋找事件的最佳響應視圖是經過對視圖調用hitTest和pointInside完成的
    2. hitTest的調用順序是從UIWindow開始,對視圖的每一個子視圖依次調用,子視圖的調用順序是從後面往前面,也能夠說是從顯示最上面到最下面
    3. 遍歷直到找到響應視圖,而後逐級返回最終到UIWindow返回此視圖

PS:
1.關於最後一個能響應的子視圖demo中是由於沒有子視圖而肯定的,這不是惟一肯定的條件,由於有些狀況下視圖可能會被忽略,不會調用hitTest,這與userInteractionEnabled, alpha, frame等有關,在下個demo會演示。
2.與加速度器、陀螺儀、磁力儀相關的運動事件不遵循此響應鏈,他們是由Core Motion 直接派發的ui

處理者

在上面咱們已經找到了點擊事件的響應者View3,可是咱們並未給View3添加相應的點擊處理邏輯(UITapGestureRecognizer),因此View3並不會處理事件,那麼View3不處理由會交給誰處理呢?若是View3處理了又是怎麼樣的呢?
可以處理UI事件都是繼承UIResponder的子類對象,UIResponder主要有如下4個方法來處理事件:

// 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;

分別是對應從觸摸事件的開始、移動、結束、取消,若是你想自定義響應事件能夠重寫這幾個方法來實現。若是某個Responder沒處理事件,事件會被傳遞,UIResponder都有一個nextResponder屬性,此屬性會返回在Responder Chain中的下一個事件處理者,若是每一個Responder都不處理事件,那麼事件將會被丟棄。因此繼承自UIResponder的子類便會構成一條響應者鏈,因此咱們能夠打印下以View3爲開始的響應者鏈是什麼樣的:

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    UIResponder *nextResponder = self.view3.nextResponder;
    NSMutableString *pre = [NSMutableString stringWithString:@"--"];
    NSLog(@"View3");
    while (nextResponder) {
        NSLog(@"%@%@", pre, NSStringFromClass([nextResponder class]));
        [pre appendString:@"--"];
        nextResponder = nextResponder.nextResponder;
    }
}

View3ReponderChain.png

能夠看到響應者鏈一直延伸到AppDelegate, View3的下一個是View2也就是View3的父視圖,View2下一個是RootView也是父視圖,而RootView的下一個則是Controller, 因此下一個響應者的規則是若是有父視圖則nextResponder指向父視圖,若是是控制器根視圖則指向控制器,控制器若是在導航控制器中則指向導航控制器的相關顯示視圖最後指向導航控制器,若是是根控制器則指向UIWindow,UIWindow的nexResponder指向UIApplication最後指向AppDelegate,而他們實現這一套指向都是靠重寫nextReponder實現的。

爲了驗證點擊上面的事件的處理順序,咱們繼續上面那個demo,爲RootView和View1-View4的基類BaseView重寫這幾個方法:

@implementation BaseView

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

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

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

@end

一樣也爲控制器(FindResponderController)添加相關touches方法,日誌打印看調用順序:

chainOrder.png

能夠看到先是由UIWindow經過hitTest返回所找到的最合適的響應者View3, 接着執行了View3的touchesBegan,而後是經過nextResponder依次是View二、RootView、FindResponderController,能夠看到徹底是按照nextResponder鏈條的調用順序,touchesEnded也是一樣的順序。

PS:感興趣的能夠繼續重寫AppDelegate的相關touches方法,驗證最終是否是會被順序調用。

上面是View3不處理點擊事件的狀況,接下來咱們爲View3添加一個點擊事件處理,看看又會是什麼樣的調用過程:

@implementation View3
- (void)awakeFromNib {
    [super awakeFromNib];
    [self addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAction:)]];
}

- (void)tapAction:(UITapGestureRecognizer *)recognizer {
    NSLog(@"View3 taped");
}

@end

運行程序,點擊View3看看日誌打印:

View3Tap.png

能夠看到touchesBegan順着nextResponder鏈條調用了,可是View3處理了事件,去執行了相關是事件處理方法,而touchesEnded並無獲得調用。

  • 總結
    1.找到最適合的響應視圖後事件會今後視圖開始沿着響應鏈nextResponder傳遞,直到找處處理事件的視圖,若是沒有處理的事件會被丟棄。
    2.若是視圖有父視圖則nextResponder指向父視圖,若是是根視圖則指向控制器,最終指向AppDelegate, 他們都是經過重寫nextResponder來實現。

沒法響應的狀況

在[響應者]章節咱們已經提到尋找最佳響應者是經過hitTest函數調用完成的,那麼存在哪些狀況下視圖會被忽視,而不被調用hiTest呢?
下面我麼也經過第2個demo來演示,在什麼狀況下hitTest不會被調用或者返回nil,在demo中從上到下咱們分別模擬了Alpha=0、子視圖超出父視圖的狀況、userInteractionEnabled=NO、hidden=YES這4中狀況:

clipboard.png

  • 結論
    1.Alpha=0、子視圖超出父視圖的狀況、userInteractionEnabled=NO、hidden=YES視圖會被忽略,不會調用hitTest
    2.父視圖被忽略後其全部子視圖也會被忽略,因此View3上的button不會有點擊反應
    3.出現視圖沒法響應的狀況,能夠考慮上訴狀況來排查問題

應用示例

  • 點擊透傳
    RootView有2個重疊在一塊兒的子視圖View1和View2, View2覆蓋在View1上面,如何作到點擊View1觸發View2的處理邏輯?
    很簡單,設置View2的userInteractionEnabled=NO便可。
  • 限定點擊區域
    給定一個顯示爲圓形的視圖,實現只有在點擊區域在圓形裏面才視爲有效。
    咱們能夠重寫View的pointInside方法來判斷點擊的點是否在圓內,也就是判斷點擊的點到圓心的距離是否小於等於半徑就能夠。
@implementation CircleView
- (void)awakeFromNib {
    [super awakeFromNib];
    self.layer.cornerRadius = self.frame.size.width / 2.0f;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    const CGFloat radius = self.frame.size.width / 2.0f;
    CGFloat xOffset = point.x - radius;
    CGFloat yOffset = point.y - radius;
    CGFloat distance = sqrt(xOffset * xOffset + yOffset * yOffset);
    return distance <= radius;
}
@end

One More Thing

在[響應者]一節中咱們提到了,觸摸事件是由系統封裝成UIEvent放到事件隊列裏面,而後Application從事件隊列取出事件接着是後面的尋找響應,那麼在UIEvent又是如何封裝的呢?放到事件隊列裏面又經歷了什麼呢?
這就不得不提RunLoop了,RunLoop是App運行的基礎機制,它一直處於接受消息->等待->處理 的循環中,當沒有事件處理時會處於休眠狀態,等待着下一個事件到來的喚醒,被還手去處理事件,好比我麼這裏的觸摸事件。
當一個觸摸事件發生後首先是由IOKit.framework 生成一個 IOHIDEvent 事件並由 SpringBoard 接收,而後SpringBoard會經過match port將事件轉發給咱們是App進程,而後觸發App註冊在RunLoop中的Source1來處理事件,Source1會觸發__IOHIDEventSystemClientQueueCallback回調,回調後又會觸發Source0,再後面就是UIApplication從事件隊列取出事件派發,咱們能夠打個斷點觀察:

source0.png

而要看到最初的Source1,則須要在__IOHIDEventSystemClientQueueCallback下符號斷點才能看到:

source1.png

想了解更多關於RunLoop機制詳情的能夠閱讀這篇文章更適合,本文不作詳情介紹。

以上demo都可在GitHub下載:GitHub地址, Objective-C, Xcode9.3

參考文章:
文章一
文章二
文章三

相關文章
相關標籤/搜索