dailyLearning -- 響應(yīng)者鏈

  • 響應(yīng)者對象介紹
  • 什么是響應(yīng)者鏈
  • 事件響應(yīng)流程(事件的產(chǎn)生和傳遞)
  • 怎么尋找最合適的 view
  • 應(yīng)用

runLoop 的介紹中, 說到了,runLoop 在事件響應(yīng)的應(yīng)用,

蘋果注冊了一個Source1(基于mach port的) 用來接收系統(tǒng)事件, 其回調(diào)函數(shù)為 _IOHIDEventSystenClientQueueCallback();

當一個硬件事件 (觸摸/鎖屏/搖晃等) 發(fā)生后, 首先由IOKit.framework 生成一個IOHIDEvent事件并由SpringBoard接收, 這個過程的詳細情況可以參考這里; SpringBoard 只接收按鍵(鎖屏/靜音等), 觸摸,加速,接近傳感器等幾種 event, 隨后通過 mach port 轉(zhuǎn)發(fā)給需要的 App 進程; 隨后觸發(fā) App 注冊 蘋果注冊的那個 Source1 就會觸發(fā)回調(diào), 并調(diào)用_UIApplicationHandleEventQueu() 進行應(yīng)用內(nèi)部的分發(fā);

_UIApplicationHandleEventQueue()會把IOHIDEvent 處理并包裝成 UIEvent 進行處理或者分發(fā), 其中包括識別UIGesture / 處理屏幕旋轉(zhuǎn) / 發(fā)送給 UIWindow 等; 通常事件比如 UIButton 點擊, touchsBegin / Move / End / Cancel 事件都是在這個回調(diào)中完成的;

所以當我們 觸摸手機屏幕時, 系統(tǒng)會將這一操作封裝成一個UIEvent 對象, 放到 runLoop 的事件隊列里面, UIApplication從事件隊列取出事件, 然后找到該事件的第一響應(yīng)者處理該事件, 這里主要介紹事件的產(chǎn)生和傳遞 — 響應(yīng)者鏈;

一、響應(yīng)者對象介紹

響應(yīng)者對象是什么?
響應(yīng)者對象是一個能夠響應(yīng)和處理事件的對象; UIResponder 是所有響應(yīng)者對象的基類, 繼承自 UIResponder 的對象稱為響應(yīng)者對象; UIApplication, UIWindow, UIViewController 和所有繼承自 UIView 的 UIKit 類都直接或間接繼承自 UIResponder;

UIResponder 一般響應(yīng)一下幾種事件: 觸摸事件(touch handling), 點按事件(press handling), 加速事件 和 遠程事件;

//觸摸事件(touch handling)
- (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);
//點按事件(press handling) NS_AVAILABLE_IOS(9_0)
- (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);

二、什么是響應(yīng)者鏈
響應(yīng)者鏈: 由多個響應(yīng)者組合起來的鏈條, 就叫做響應(yīng)者鏈; 他表示了每個響應(yīng)者之間的聯(lián)系, 并且可以使得一個事件可選擇多個對象處理;

當觸摸了 initial view 時:

  1. 第一響應(yīng)者就是 initial view , 即 initial view 首先響應(yīng) touchesBegan:withEvent: 方法, 接著傳遞給 橘黃色的 view;
  2. 橘黃色 view 開始響應(yīng) touchesBegan:withEvent: 方法, 接著傳遞給藍綠色 view;
  3. 藍綠色 view 響應(yīng) touchesBegan:withEvent: 方法, 接著傳遞給控制器的 view;
  4. 控制器 view 響應(yīng) touchesBegan:withEvent: 方法, 控制器傳遞給窗口 window;
  1. 窗口 window 再傳遞給 UIApplication 處理該事件;

如果上述響應(yīng)者都不處理該事件, 那么事件被丟棄;

三、事件響應(yīng)流程(事件的產(chǎn)生和傳遞)

當一個觸摸事件產(chǎn)生的時候, 程序是如何找到第一響應(yīng)者呢


當點擊屏幕時會產(chǎn)生一個觸摸事件, 消息循環(huán)(runLoop) 會接收到觸摸事件, 將事件包裝成 UIEvent 對象, 放到主循環(huán)的消息隊列里, UIApplication 會從消息隊列里取出事件, 分發(fā)下去;

首先傳給 UIWindow, UIWindow 通過 hitTest:withEvent: 方法找到此次觸摸事件初始點所在的視圖,找到這個視圖之后,就會調(diào)用該視圖的 touchesBegan:withEvent:方法來處理此事件;

iOS系統(tǒng)檢測到手指觸摸(Touch)操作時會將其放入當前活動Application的事件隊列,UIApplication會從事件隊列中取出觸摸事件并傳遞給key window(當前接收用戶事件的窗口)處理,window對象首先會使用hitTest:withEvent:方法尋找此次Touch操作初始點所在的視圖(View),即需要將觸摸事件傳遞給其處理的視圖,稱之為hit-test view

hitTest:withEvent: 查找響應(yīng)者過程

圖片中view等級

    [ViewA addSubview:ViewB];
    [ViewA addSubview:ViewC];
    [ViewB addSubview:ViewD];
    [ViewB addSubview:ViewE];

點擊 ViewE:

  1. A 是 UIWindow 的根視圖, 首先對 A 進行hitTest:withEvent:
  2. pointInside:withEvent:方法判斷用戶點擊是否在 A 的范圍;
  3. 遍歷 A 的子視圖 B 和 C, 從后向前遍歷;
  • 因此, 先查看 C , 調(diào)用 C 的 hitTest:withEvent:方法, pointInside:withEvent:判斷用戶點擊是否在 C 的范圍內(nèi), 不在返回 NO, C 對應(yīng)的hitTest:withEvent: 返回 nil;
  • 再查看 B, 調(diào)用 B 的 hitTest:withEvent: 方法, pointInside:withEvent: 判斷用戶點擊是否在 B 的范圍內(nèi), 在返回 YES;
  • 再遍歷 B 的子視圖 D 和 E, 從后向前遍歷;
  • 先查看 E, 調(diào)用 E 的 hitTest:withEvent: 方法,pointInside:withEvent: 判斷用戶點擊是否在 E 的范圍內(nèi), 在返回 YES; E 沒有子視圖, 因此 E 對應(yīng)的 hitTest:withEvent: 方法返回 E, 再往前回溯, 就是 B 的 hitTest:withEvent: 返回 E, A 的 hitTest:withEvent: 返回 E;

至此, 點擊事件的第一響應(yīng)者找到了;

如果 hitTest:withEvent: 找到了第一響應(yīng)者, 但 view 沒有處理該事件, 那么事件會沿著響應(yīng)者鏈向上傳遞 -> 父視圖 -> 視圖控制器 -> UIWindow -> UIApplication , 如果傳遞到響應(yīng)鏈最頂級還沒有處理事件, 就丟棄該事件;

注意: 控件不能響應(yīng)的情況,

  1. userInteractionEnabled = NO;
  2. hidden = YES;
  3. 視圖透明度 alpha <= 0.01;
  4. 子視圖超出父視圖區(qū)域;

子視圖超出父視圖, 不響應(yīng)的原因:
因為父視圖的pointInside:withEvent: 方法返回 NO, 就不會遍歷子視圖了, 可以重寫pointInside:withEvent: 方法解決此問題;

四、怎么尋找最合適的 view

hitTest:withEvent:

// 此方法返回的View是本次點擊事件需要的最佳View
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event

事件傳遞給 window 窗口或者控件后, 就調(diào)用hitTest:withEvent: 方法尋找更合適的 view, 如果控件是合適的 view, 則在子控件再調(diào)用 hitTest:withEvent:查看子控件是不是合適的 view, 一直遍歷,直到找到合適的 view, 或者廢棄事件;

// 因為所有的視圖類都是繼承BaseView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    // 1.判斷當前控件能否接收事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) 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];
        
        // 把當前控件上的坐標系轉(zhuǎn)換成子控件上的坐標系
        CGPoint childP = [self convertPoint:point toView:childView];
        UIView *fitView = [childView hitTest:childP withEvent:event];
        
        if (fitView) { // 尋找到最合適的view
            
            return fitView;
        }
    }
    // 循環(huán)結(jié)束,表示沒有比自己更合適的view
    return self;
}

pointInside:withEvent:

// 判斷一個點是否落在范圍內(nèi)
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event

這個函數(shù)的用處是判斷當前的點擊或者觸摸事件的點是否在當前的view中。

它被hitTest:withEvent:調(diào)用,通過對每個子視圖調(diào)用pointInside:withEvent:決定最終哪個視圖來響應(yīng)此事件。如果pointInside:withEvent:返回YES,然后子視圖的繼承樹就會被遍歷(遍歷順序中最先響應(yīng)的為:與用戶最接近的那個視圖。 it starts from the top-level subview),即子視圖的子視圖繼續(xù)調(diào)用遞歸這個函數(shù),直到找到可以響應(yīng)的子視圖(這個子視圖的 hitTest:withEvent: 會返回self,而不是nil);否則,視圖的繼承樹就會被忽略。

當我們需要重寫某個UIView的繼承類UIViewInherit的時候,如果需要重寫 hitTest:withEvent:方法,就會出現(xiàn)是否調(diào)用[super hitTest:withEvent:]方法的疑問?究竟是否需要都是看具體需求,這里只是說明調(diào)與不調(diào)的效果。

如果不調(diào)用,那么重寫的方法 hitTest:withEvent: 只會調(diào)用重寫后的代碼,根據(jù)所重寫的代碼返回self或nil,如果返回self那么你的這個UIViewInherit類會接受你的按鍵,然后調(diào)用touches系列方法;否則返回nil那么傳遞給 UIViewInherit 類的按鍵到此為止,它不接受它的父view給它的按鍵,即不會調(diào)用touches系列方法。這時,pointInside:withEvent: 幾乎沒有作用。

如果調(diào)用,那么[super hitTest:withEvent:]方法首先是根據(jù)pointInside:withEvent:的返回值決定是否遞歸調(diào)用所有子View的hitTest:withEvent:方法。對于子 View 的 hitTest:withEvent:方法調(diào)用也是一樣的過程,這樣一直遞歸下去,直到最先找到的某個遞歸層次上的子 View 的 hitTest:withEvent: 方法返回非 nil,這時候,調(diào)用即結(jié)束,最終會調(diào)用這個子 View 的 touches 系列方法。

如果我們不想讓某個視圖響應(yīng)事件,只需要重載 pointInside:withEvent:方法,讓此方法返回NO就行了。不過從這里,還是不能了解到hitTest:WithEvent的方法的用途

五、應(yīng)用

在實際開發(fā)中, 可能會遇到自定義 tabBar, 中間有凸起按鈕的情況, 如圖:

如何做到點擊按鈕而不會觸發(fā)頁面?
這里就需要用到 hitTest:withEvent:來處理;

一般我們在子類化的 UITabBar 中, 重寫 hitTest:withEvent: 方法,

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    if (self.clipsToBounds || self.hidden || (self.alpha == 0.f)) {
        return nil;
    }
    
    UIView *result = [super hitTest:point withEvent:event];

    //如果發(fā)生在 tabBar 里面直接返回
    if (result) {
        return result;
    }
    //這里遍歷哪些超出部分, 通用寫法
    for (UIView *subview in self.subviews) {
        
        //把這個坐標從 tabBar 的坐標系轉(zhuǎn)為 subView 的坐標系
        CGPoint subPoint = [subview convertPoint:point fromView:self];
        result = [subview hitTest:subPoint withEvent:event];
        
        //如果事件發(fā)生在 subview 里, 就返回
        if (result) {
            return result;
        }
    }
    return nil;
}

或者

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    //判斷當前手指是否點擊到中間按鈕上,如果是,則響應(yīng)按鈕點擊,其他則系統(tǒng)處理
    //首先判斷當前View是否被隱藏了,隱藏了就不需要處理了
    if (self.isHidden == NO) {
        
        //將當前tabbar的觸摸點轉(zhuǎn)換坐標系,轉(zhuǎn)換到中間按鈕的身上,生成一個新的點
        CGPoint newP = [self convertPoint:point toView:self.centerBtn];
        
        //判斷如果這個新的點是在中間按鈕身上,那么處理點擊事件最合適的view就是中間按鈕
        //self.centerBtn 為大按鈕
        if ([self.centerBtn pointInside:newP withEvent:event]) {
            return self.centerBtn;
        }
    }
    
    return [super hitTest:point withEvent:event];
}

以上 hitTest:withEvent: 的兩種實現(xiàn)都可以達到點擊 tabBar 外部觸發(fā)按鈕事件的目的;

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容