淺談事件的分發與響應

在 iOS 開發中,當用戶用手指點擊了一下屏幕,會發生什麼呢?系統是怎麼判斷用戶點擊的位置呢?咱們開發者又如何作出「沒有bug」的交互呢?帶着這些疑問,咱們一塊兒談談事件的分發與響應。git

鋪墊

事件

顧名思義,事件就是發生的一件事,對於APP來講,就是發生的一個操做。具體的就是用戶點擊一下屏幕就會出現一個事件(體現爲一個UIEvent),即一個觸摸事件。其實,對於 iOS 設備的用戶來講,他們操做設備的方式主要有四種方式:觸摸屏幕、晃動設備、經過遙控設施控制設備、按壓屏幕。 對應的事件類型UIEventType有如下三種:github

  1. 觸屏事件(Touch Event)
  2. 運動事件(Motion Event)
  3. 遠端控制事件(Remote-Control Event)
  4. 按壓事件(Presses Event)

咱們的主題是探索用戶用手指點擊屏幕會發生什麼,因此咱們將注意力放在觸摸事件上。數組

響應者對象

上面咱們瞭解到,當咱們點擊了屏幕,就會出現一個事件。既然事件出現了,那麼就須要一個一個響應和處理這個事件的對象,那就是咱們的響應者對象。這些響應者對象都有一個共同的特徵,就是他們都繼承自UIResponder。咱們熟知的響應者對象有UIApplicationUIWindowUIViewController和全部繼承自UIView的 UIKit 類bash

UIResponderide

  • 全部響應對象的基類
  • 定義了處理上述各類事件的接口;

第一響應者

在觸摸屏幕的事件中:測試

  • 指的是當前接受觸摸的響應者對象(一般是一個UIView對象);
  • 即表示當前該對象正在與用戶交互,它是響應者鏈的開端;
  • 整個響應者鏈和事件分發的使命都是找出第一響應者。

響應者鏈條

上面介紹了響應者對象,也知道了UIApplicationUIWindowUIViewControllerUIView這些都是響應者。那麼一個 APP 會存在不少響應者對象。由這一系列的響應者對象就構成了一個層次結構,那就是響應者鏈條ui

響應者鏈條

從上圖中能夠看到,響應者鏈條有如下特色spa

  1. 響應者鏈頭部一般是由視圖(UIView)構成的;
  2. 若是該視圖是屬於視圖控制器(UIViewController)的,那麼下一個響應者是該視圖控制器,而後再將事件響應到它的父視圖(Super View)中;
  3. 若是該視圖沒有視圖控制器(UIViewController),那麼下一個響應者就直接是它的父視圖(Super View);
  4. 一直響應直至其對象是單例的窗口(UIWindow
  5. 再下一個響應者就是單例的應用(UIApplication),也是響應者鏈條的終點
  6. 下一個響應者指向 nil ,結束整個循環

事件分發

回到開篇的狀況,當用戶點擊了一下屏幕。系統檢測到用戶的觸摸事件,就會將其打包成一個事件(即UIEvent對象),並將這個UIEvent對象放入 Application 的事件隊列中。這時系統只是知道有這麼一個事件發生,雖然響應者鏈條中有不少有處理事件能力的響應者,可是它不知道誰纔是響應這個事件的最佳人選。 所以,系統會從UIApplication開始,順着響應者鏈條向上尋找那個最佳的人選。這個尋找的過程就是事件的分發過程code

傳遞過程

  • 第一步UIApplication將這個事件從事件隊列中拿出來,從頂部開始詢問誰纔是最佳人選;
  • 第二步UIWindow會最早獲取到事件,並開始使用hitTest:withEvent:來判斷下面他的子控件中誰纔是最佳人選;
  • \ldots\ldots\ldots
  • 第 N - 1 步:當前UIView繼續詢問他的子視圖是否是最佳人選;
  • 第 N 步:當前UIView不是被點擊的的視圖,orz,上一個UIView就是最佳人選了。

從用戶視角來看,系統經過hitTest:withEvent:方法,從視圖的底部一直向表面尋找最佳人選。由於是一直查找,只有在全部的查找都完成了,判斷出當前視圖沒有子視圖或者他的子視圖都不適合了,那麼當前視圖就是最佳人選了。(因此你只是點了一個你一眼就看中的視圖,其實系統是從底部開始,一頓連續操做才找到你想要的東西[汗顏])cdn

hitTest:withEvent:

上面的事件分發過程當中,大量使用了hitTest:withEvent:這個方法,它的處理流程以下:

  • 首先調用當前視圖的pointInside:withEvent:方法,判斷觸摸點是否在當前視圖內
    • 若返回NO,則hitTest:withEvent:返回nil
    • 若返回YES,則向當前視圖的全部子視圖發送hitTest:withEvent:消息,全部子視圖的遍歷順序是從最頂層視圖一直到到最底層視圖,即從subviews數組的末尾向前遍歷。
  • 如有子視圖返回非空對象,則hitTest:withEvent:方法返回此對象,處理結束;
  • 若全部子視圖都返回nil,則hitTest:withEvent:方法返回自身,即self,處理結束。

下面咱們用一個圖解來理解一下這個hitTest:withEvent:

Demo

假如用戶點擊了View D,結合上圖詳細介紹一下hitTest:withEvent:過程: (hitTest:withEvent:簡稱hitTestpointInside:withEvent:簡稱pointInsideView X簡稱X

  1. A 是 UIWindow 的根視圖,所以,UIWindow 對象會首先對 A 進行hitTest
  2. 顯然用戶點擊的範圍是在 A 的範圍內,這時會繼續檢查 A 的子視圖;
  3. 這時候會有 B 和 C 兩個分支,因爲 C 是後添加的子視圖,所以先對 C 進行hitTest
    • 顯然點擊的範圍在 C 內;
  4. 這時候有 D 和 E 兩個分支,按順序先檢查 E
    • 顯然點擊的範圍不在 E 內,對應的hitTest:withEvent:返回 nil;
    • 顯然點擊的範圍在 D 內,因爲 D 沒有子視圖(也能夠理解成對 D 的子視圖進行hitTest時返回了 nil);
  5. 所以,D 的hitTest會將 D 返回,再往回回溯,就是 C 的hitTest返回 D,A 的hitTest返回 D。

至此,本次點擊事件的第一響應者就經過響應者鏈的事件分發邏輯成功找到了

除了使用pointInside:withEvent:判斷是不是響應者,還有下面三種狀況會使hitTest:withEvent:返回 nil:

  • 隱藏hidden=YES的視圖;
  • 禁止用戶操做userInteractionEnabled=YES的視圖;
  • 透明度小於0.01alpha<0.01的視圖。

所以hitTest:withEvent:的實現多是:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}
複製代碼

事件響應

前面說了一大堆事件的分發,其實就是爲了找到響應事件的最佳人選,這個最佳人選就是在介紹響應者鏈條的時候,最底下的那個View。從這個 View 開始咱們沿着響應者鏈條的方向進行響應。

開篇咱們的說的是用戶點擊屏幕的場景,所以,響應者會按照當前UITouch的所處階段使用下面的方法進行響應:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event;
複製代碼
  • 在響應方法內部,咱們也能夠調用調用[super touches...]將這個觸摸事件繼續分發給父控件的對應方法處理。而後父控件還能夠將該事件繼續向上傳遞,直到傳遞給UIApplication對象。這一系列的響應者對象就構成了一個響應者鏈條
  • 若是不調用[super toucher...]事件不會繼續沿着響應者鏈條進行響應

小結

事件的分發響應都是在響應者鏈條上進行的,只不過是二者傳遞的方向不一樣。

傳遞方向
上面的圖片中省略了 UIViewController,這裏說明一下他的位置:

  • 事件分發過程當中沒有ViewController的事
  • 事件響應的過程當中,傳遞的方向以下:

UIController狀況

至此,咱們已經大概瞭解了當用戶用手指點擊了一下屏幕,會發生什麼。

經過對這些的瞭解,咱們能夠經過使用下面兩種方式來實現一些特殊需求:

  • 重寫 UIView 中的hitTest:withEvent:來影響事件分發
  • 重寫 UIResponder 中的touches系列方法來影響事件響應

問題

我在測試hitTest:withEvent:的過程當中,經過運行時給每一個hitTest:withEvent:都添加了打印方法,在點擊綠色的B View的時候出現了下面的重複尋找的狀況(不單隻點擊B View時候有出現)

這個現象我不太會解釋...但願有人能夠解答一下。

問題
Demo地址
相關文章
相關標籤/搜索