iOS事件處理,看我就夠了~

該文章屬於<簡書 — 劉小壯>原創,轉載請註明:

<簡書 — 劉小壯> https://www.jianshu.com/p/b0884faae603app


很久沒寫博客了,先後算起來恰好有一年了。這期間博客也不是一直沒變化,細心的同窗應該能發現,我一直在回覆評論區和私信的問題,還更新了好幾篇以前的博客。ide

去年是有意義的一年,從各個方面我也學到了很多的東西,也不侷限於技術方面。不少人都寫年終總結,我比較懶就不寫了,心裏作自我總結吧,哈哈。函數

迴歸正題,在項目中常常會遇到各類手勢或者點擊事件處理之類的,這些都屬於響應事件處理。可是不少人對iOS中的響應事件處理並不清楚,常常會遇到手勢衝突、事件不響應之類的問題,因此就去查博客。 可是如今不少博客寫的並非很完整,或者說質量並不高,我這兩天抽時間把我所學習和理解的iOS事件處理寫出來,供各位參考。oop


博客配圖

UIResponder

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

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

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

查找第一響應者

基礎API

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

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

- (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手勢不響應。

UIControl

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

不僅UIButton是直接由UIApplication派發事件的,全部繼承自UIControl的類,都是由UIApplication直接派發事件的。

Apple UIControl Documentation

事件傳遞優先級

測試

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

示例1

示例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

示例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;
}
相關文章
相關標籤/搜索