iOS 事件處理,看我就夠了

UIResponder

UIResponder是 iOS 中用於處理用戶事件的 API,可以處理觸摸事件、按壓事件(3D touch)、遠程控制事件、硬件運動事件。 可以通過touchesBeganpressesBeganmotionBeganremoteControlReceivedWithEvent等方法,獲取到對應的回調消息。UIResponder不只用來接收事件,還可以處理和傳遞對應的事件,如果當前響應者不能處理,則轉發給其他合適的響應者處理。

應用程序通過響應者來接收和處理事件,響應者可以是繼承自UIResponder的任何子類,例如UIViewUIViewControllerUIApplication等。當事件來到時,系統會將事件傳遞給合適的響應者,並且將其成爲第一響應者。

第一響應者未處理的事件,將會在響應者鏈中進行傳遞,傳遞規則由UIRespondernextResponder決定,可以通過重寫該屬性來決定傳遞規則。當一個事件到來時,第一響應者沒有接收消息,則順着響應者鏈向後傳遞。

查找第一響應者

基礎 API

查找第一響應者時,有兩個非常關鍵的API,查找第一響應者就是通過不斷調用子視圖的這兩個API完成的。

調用方法,獲取到被點擊的視圖,也就是第一響應者。

(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;

hitTest:withEvent:方法內部會通過調用這個方法,來判斷點擊區域是否在視圖上,是則返回YES,不是則返回NO

(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

查找第一響應者

應用程序接收到事件後,將事件交給keyWindow並轉發給根視圖,根視圖按照視圖層級逐級遍歷子視圖,並且遍歷的過程中不斷判斷視圖範圍,並最終找到第一響應者。

keyWindow開始,向前逐級遍歷子視圖,不斷調用UIViewhitTest:withEvent:方法,通過該方法查找在點擊區域中的視圖後,並繼續調用返回視圖的子視圖的hitTest:withEvent:方法,以此類推。如果子視圖不在點擊區域或沒有子視圖,則當前視圖就是第一響應者。

hitTest:withEvent:方法中,會從上到下遍歷子視圖,並調用subViewspointInside:withEvent:方法,來找到點擊區域內且最上面的子視圖。如果找到子視圖則調用其hitTest:withEvent:方法,並繼續執行這個流程,以此類推。如果子視圖不在點擊區域內,則忽略這個視圖及其子視圖,繼續遍歷其他視圖。

可以通過重寫對應的方法,控制這個遍歷過程。通過重寫pointInside:withEvent:方法,來做自己的判斷並返回YESNO,返回點擊區域是否在視圖上。通過重寫hitTest:withEvent:方法,返回被點擊的視圖。

此方法在遍歷視圖時,忽略以下三種情況的視圖,如果視圖具有以下特徵則忽略。但是視圖的背景顏色是clearColor,並不在忽略範圍內。

  1. 視圖的hidden等於 YES。

  2. 視圖的alpha小於等於 0.01。

  3. 視圖的userInteractionEnabled爲 NO。

如果點擊事件是發生在視圖外,但在其子視圖內部,子視圖也不能接收事件併成爲第一響應者。這是因爲在其父視圖進行hitTest:withEvent:的過程中,就會將其忽略掉。

事件傳遞

傳遞過程

  1. UIApplication接收到事件,將事件傳遞給keyWindow

  2. keyWindow遍歷subViewshitTest:withEvent:方法,找到點擊區域內合適的視圖來處理事件。

  3. UIView的子視圖也會遍歷其subViewshitTest:withEvent:方法,以此類推。

  4. 直到找到點擊區域內,且處於最上方的視圖,將視圖逐步返回給UIApplication

  5. 在查找第一響應者的過程中,已經形成了一個響應者鏈。

  6. 應用程序會先調用第一響應者處理事件。

  7. 如果第一響應者不能處理事件,則調用其nextResponder方法,一直找響應者鏈中能處理該事件的對象。

  8. 最後到UIApplication後仍然沒有能處理該事件的對象,則該事件被廢棄。

模擬代碼

(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (self.alpha <= 0.01 || self.userInteractionEnabled == NO || self.hidden) {
        return nil;
    }
    
    BOOL inside = [self pointInside:point withEvent:event];
    if (inside) {
        NSArray *subViews = self.subviews;
        // 對子視圖從上向下找
        for (NSInteger i = subViews.count - 1; i >= 0; i--) {
            UIView *subView = subViews[i];
            CGPoint insidePoint = [self convertPoint:point toView:subView];
            UIView *hitView = [subView hitTest:insidePoint withEvent:event];
            if (hitView) {
                return hitView;
            }
        }
        return self;
    }
    return nil;
}

示例

如上圖所示,響應者鏈如下:

  1. 如果點擊UITextField後其會成爲第一響應者。

  2. 如果textField未處理事件,則會將事件傳遞給下一級響應者鏈,也就是其父視圖。

  3. 父視圖未處理事件則繼續向下傳遞,也就是UIViewControllerView

  4. 如果控制器的View未處理事件,則會交給控制器處理。

  5. 控制器未處理則會交給UIWindow

  6. 然後會交給UIApplication

  7. 最後交給UIApplicationDelegate,如果其未處理則丟棄事件。

事件通過UITouch進行傳遞,在事件到來時,第一響應者會分配對應的UITouchUITouch會一直跟隨着第一響應者,並且根據當前事件的變化UITouch也會變化,當事件結束後則UITouch被釋放。

UIViewController沒有hitTest:withEvent:方法,所以控制器不參與查找響應視圖的過程。但是控制器在響應者鏈中,如果控制器的View不處理事件,會交給控制器來處理。控制器不處理的話,再交給View的下一級響應者處理。

注意

  1. 在執行hitTest:withEvent:方法時,如果該視圖是hidden等於 NO 的那三種被忽略的情況,則改視圖返回nil

  2. 如果當前視圖在響應者鏈中,但其沒有處理事件,則不考慮其兄弟視圖,即使其兄弟視圖和其都在點擊範圍內。

  3. UIImageViewuserInteractionEnabled默認爲 NO,如果想要UIImageView響應交互事件,將屬性設置爲 YES 即可響應事件。

事件控制

事件攔截

有時候想讓指定視圖來響應事件,不再向其子視圖繼續傳遞事件,可以通過重寫hitTest:withEvent:方法。在執行到方法後,直接將該視圖返回,而不再繼續遍歷子視圖,這樣響應者鏈的終端就是當前視圖。

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

事件轉發

在開發過程中,經常會遇到子視圖顯示範圍超出父視圖的情況,這時候可以重寫該視圖的pointInside:withEvent:方法,將點擊區域擴大到能夠覆蓋所有子視圖。

假設有上面的視圖結構,SuperViewSubview超出了其視圖範圍,如果點擊Subview在父視圖外面的部分,則不能響應事件。所以通過重寫pointInside:withEvent:方法,將響應區域擴大爲虛線區域,包含SuperView的所有子視圖,即可讓子視圖響應事件。

事件逐級傳遞

如果想讓響應者鏈中,每一級UIResponder都可以響應事件,可以在每級UIResponder中都實現touches並調用super方法,即可實現響應者鏈事件逐級傳遞。

只不過這並不包含UIControl子類以及UIGestureRecognizer的子類,這兩類會直接打斷響應者鏈。

Gesture Recognizer

如果有事件到來時,視圖有附加的手勢識別器,則手勢識別器優先處理事件。如果手勢識別器沒有處理事件,則將事件交給視圖處理,視圖如果未處理則順着響應者鏈繼續向後傳遞。

當響應者鏈和手勢同時出現時,也就是既實現了touches方法又添加了手勢,會發現touches方法有時會失效,這是因爲手勢的執行優先級是高於響應者鏈的。

事件到來後先會執行hitTestpointInside操作,通過這兩個方法找到第一響應者,這個在上面已經詳細講過了。當找到第一響應者並將其返回給UIApplication後,UIApplication會向第一響應者派發事件,並且遍歷整個響應者鏈。如果響應者鏈中能夠處理當前事件的手勢,則將事件交給手勢處理,並調用touchescancelled方法將響應者鏈取消。

UIApplication向第一響應者派發事件,並且遍歷響應者鏈查找手勢時,會開始執行響應者鏈中的touches系列方法。會先執行touchesBegantouchesMoved方法,如果響應者鏈能夠繼續響應事件,則執行touchesEnded方法表示事件完成,如果將事件交給手勢處理則調用touchesCancelled方法將響應者鏈打斷。

根據蘋果的官方文檔,手勢不參與響應者鏈傳遞事件,但是也通過hitTest的方式查找響應的視圖,手勢和響應者鏈一樣都需要通過hitTest方法來確定響應者鏈的。在UIApplication向響應者鏈派發消息時,只要響應者鏈中存在能夠處理事件的手勢,則手勢響應事件,如果手勢不在響應者鏈中則不能處理事件。

Apple UIGestureRecognizer Documentation

UIControl

根據上面的手勢和響應者鏈的處理規則,我們會發現UIButton或者UISlider等控件,並不符合這個處理規則。UIButton可以在其父視圖已經添加tapGestureRecognizer的情況下,依然正常響應事件,並且tap手勢不響應。

UIButton爲例,UIButton也是通過hitTest的方式查找第一響應者的。區別在於,如果UIButton是第一響應者,則直接由UIApplication派發事件,不通過Responder Chain派發。如果其不能處理事件,則交給手勢處理或響應者鏈傳遞。

不只UIButton是直接由UIApplication派發事件的,所有繼承自UIControl的類,都是由UIApplication直接派發事件的。

Apple UIControl Documentation

事件傳遞優先級

測試

爲了有依據的推斷響應事件的實現和傳遞機制,我們做以下測試。

示例 1

假設RootViewSuperViewButton都實現touches方法,並且Button添加buttonAction:action,點擊button後的調用如下。

RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Button -> hitTest:withEvent:
Button -> pointInside:withEvent:
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:

Button -> touchesBegan:withEvent:
Button -> touchesEnded:withEvent:
Button -> buttonAction:
示例 2

還是上面的視圖結構,我們給RootView加上UITapGestureRecognizer手勢,並且通過tapAction:方法接收回調,點擊上面的SuperView後,方法調用如下。

RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Button -> hitTest:withEvent:
Button -> pointInside:withEvent:
RootView -> hitTest:withEvent:
RootView -> pointInside:withEvent:

RootView -> gestureRecognizer:shouldReceivePress:
RootView -> gestureRecognizer:shouldBeRequiredToFailByGestureRecognizer:
SuperView -> touchesBegan:withEvent:
RootView -> gestureRecognizerShouldBegin:
RootView -> tapAction:
SuperView -> touchesCancelled:
示例 3

上面的視圖中Subview1Subview2Subview3是同級視圖,都是SuperView的子視圖。我們給Subview1加上UITapGestureRecognizer手勢,並且通過subView1Action:方法接收回調,點擊上面的Subview3後,方法調用如下。

SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:
Subview3 -> hitTest:withEvent:
Subview3 -> pointInside:withEvent:
SuperView -> hitTest:withEvent:
SuperView -> pointInside:withEvent:

Subview3 -> touchesBegan:withEvent:
Subview3 -> touchesEnded:withEvent:

通過上面的例子來看,雖然Subview1Subview3的下面,並且添加了手勢,點擊區域是在Subview1Subview3兩個視圖上的。但是由於經過hitTestpointInside之後,響應者鏈中並沒有Subview1,所以Subview1的手勢並沒有被響應。

分析

根據我們上面的測試,推斷 iOS 響應事件的優先級,以及整體的響應邏輯。

當事件到來時,會通過hitTestpointInside兩個方法,從Window開始向上面的視圖查找,找到第一響應者的視圖。找到第一響應者後,系統會判斷其是繼承自UIControl還是UIResponder,如果是繼承自UIControl,則直接通過UIApplication直接向其派發消息,並且不再向響應者鏈派發消息。

如果是繼承自UIResponder的類,則調用第一響應者的touchesBegin,並且不會立即執行touchesEnded,而是調用之後順着響應者鏈向後查找。如果在查找過程中,發現響應者鏈中有的視圖添加了手勢,則進入手勢的代理方法中,如果代理方法返回可以響應這個事件,則將第一響應者的事件取消,並調用其touchesCanceled方法,然後由手勢來響應事件。

如果手勢不能處理事件,則交給第一響應者來處理。如果第一響應者也不能響應事件,則順着響應者鏈繼續向後查找,直到找到能夠處理事件的UIResponder對象。如果找到UIApplication還沒有對象響應事件的話,則將這次事件丟棄。

接收事件深度剖析

UIApplication接收到響應事件之前,還有更復雜的系統級的處理,處理流程大致如下。

  1. 系統通過IOKit.framework來處理硬件操作,其中屏幕處理也通過IOKit完成 (IOKit可能是註冊監聽了屏幕輸出的端口)

    當用戶操作屏幕,IOKit收到屏幕操作,會將這次操作封裝爲IOHIDEvent對象。通過mach port(IPC 進程間通信) 將事件轉發給SpringBoard來處理。

  2. SpringBoard是 iOS 系統的桌面程序。SpringBoard收到mach port發過來的事件,喚醒main runloop來處理。
    main runloop將事件交給source1處理,source1會調用__IOHIDEventSystemClientQueueCallback()函數。

  3. 函數內部會判斷,是否有程序在前臺顯示,如果有則通過mach portIOHIDEvent事件轉發給這個程序。

    如果前臺沒有程序在顯示,則表明SpringBoard的桌面程序在前臺顯示,也就是用戶在桌面進行了操作。
    __IOHIDEventSystemClientQueueCallback()函數會將事件交給source0處理,source0會調用__UIApplicationHandleEventQueue()函數,函數內部會做具體的處理操作。

  4. 例如用戶點擊了某個應用程序的 icon,會將這個程序啓動。

    應用程序接收到SpringBoard傳來的消息,會喚醒main runloop並將這個消息交給source1處理,source1調用__IOHIDEventSystemClientQueueCallback()函數,在函數內部會將事件交給source0處理,並調用source0__UIApplicationHandleEventQueue()函數。在__UIApplicationHandleEventQueue()函數中,會將傳遞過來的IOHIDEvent轉換爲UIEvent對象。

  5. 在函數內部,調用UIApplicationsendEvent:方法,將UIEvent傳遞給第一響應者或UIControl對象處理,在UIEvent內部包含若干個UITouch對象。

Tips

source1runloop用來處理mach port傳來的系統事件的,source0是用來處理用戶事件的。source1收到系統事件後,都會調用source0的函數,所以最終這些事件都是由source0處理的。

小技巧

在開發中,有時會有找到當前View對應的控制器的需求,這時候就可以利用我們上面所學,根據響應者鏈來找到最近的控制器。

UIResponder中提供了nextResponder方法,通過這個方法可以找到當前響應環節的上一級響應對象。可以從當前UIView開始不斷調用nextResponder,查找上一級響應者鏈的對象,就可以找到離自己最近的UIViewController

示例代碼:

(UIViewController *)parentController {
   UIResponder *responder = [self nextResponder];
   while (responder) {
       if ([responder isKindOfClass:[UIViewController class]]) {
           return (UIViewController *)responder;
       }
       responder = [responder nextResponder];
   }
   return nil;
}

轉自:掘金  劉小壯

https://juejin.cn/post/6948318786005139493

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/h851CU6lXg-2Cq9_BWdjoQ