1. 程式人生 > >iOS-使用hitTest控制點選事件的響應物件

iOS-使用hitTest控制點選事件的響應物件

之前在文章《iOS-實現映客首頁TabBar和滑動隱藏NavBar和TabBar》中,提到了hitTest方法,但是沒有詳細說明,導致有童鞋不理解為什麼要這麼做,這幾天把hitTest的資料整理了一下,在這裡介紹一些,解開疑惑。

這篇文章,最終的目的就是解釋如何讓中間按鈕超出TabBar部分響應點選事件。效果圖如下:

中間按鈕超出了`TabBar`的區域效果圖

 

這篇文章將圍繞一下幾個問題來講:

  1. hitTest是什麼
  2. hitTest的呼叫順序是怎麼樣的
  3. hitTest和事件傳遞有什麼關係
  4. hitTest是如何解決子檢視超出其檢視範圍還是能響應觸控事件的

下面我們一個一個來看。

1. hitTest是什麼

hitTest:withEvent:UIView的一個方法,該方法會被系統呼叫,是用於在檢視(UIView)層次結構中找到一個最合適的UIView來響應觸控事件。

2. hitTest的呼叫順序是怎麼樣的

一個觸控事件事件傳遞順序大致如下:

touch->UIApplication->UIWindow->UIViewController.view->subViews->...->view

1) 觸控事件傳遞順序

  1. 當用戶點選螢幕時,會產生一個觸控事件,系統會將該事件加入到由UIApplication
    管理的事件佇列中
  2. UIApplication會從事件佇列中取出最早的事件進行分發處理,先發送事件給應用程式的主視窗UIWindow
  3. 主視窗會呼叫其hitTest:withEvent:方法在檢視(UIView)層次結構中找到一個最合適的UIView來處理觸控事件

2) hitTest呼叫順序

以下pointInside:withEvent:簡稱為pointInsidehitTest:withEvent:簡稱為hitTest

hitTest的程式碼邏輯大致如下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    //系統預設會忽略isUserInteractionEnabled設定為NO、隱藏、alpha小於等於0.01的檢視
    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;
}

執行順序如下:

  1. 首先在當前檢視的hitTest方法中呼叫pointInside方法判斷觸控點是否在當前檢視內
  2. pointInside方法返回NO,說明觸控點不在當前檢視內,則當前檢視的hitTest返回nil,該檢視不處理該事件
  3. pointInside方法返回YES,說明觸控點在當前檢視內,則從最上層的子檢視開始(即從subviews陣列的末尾向前遍歷),遍歷當前檢視的所有子檢視,呼叫子檢視的hitTest方法重複步驟1-3
  4. 直到有子檢視的hitTest方法返回非空物件或者全部子檢視遍歷完畢
  5. 若第一次有子檢視的hitTest方法返回非空物件,則當前檢視的hitTest方法就返回此物件,處理結束
  6. 若所有子檢視的hitTest方法都返回nil,則當前檢視的hitTest方法返回當前檢視本身,最終由該物件處理觸控事件

上面的流程,看著可能有點繞,我們來看下面一個例子

 

例子

上圖中有5個View,紅點為手指點選區域,ViewA為父檢視,ViewBViewCViewA的子檢視,ViewDViewEViewC的子檢視。
(這裡假設所有View都可以響應點選事件,而且ViewBViewC上層,ViewDViewE上層,即ViewBaddSubView:執行在ViewC之後,ViewDaddSubView:執行在ViewE之後)

當點選ViewE時,hitTest執行順序如下:
先看看點選大致走向圖如下,其中,✅部分為執行pointInsideYES部分,X部分執行pointInsideNO部分,最終hitTest返回ViewE

hitTest走向圖

 

  1. 首先呼叫ViewAhitTest方法,由於觸控點在其範圍內,pointInside返回YES,遍歷其子檢視,依次呼叫ViewBViewChitTest方法
  2. 執行ViewBhitTest方法,由於觸控點是不在ViewB內,其pointInside方法返回NOhitTest返回nil
  3. 執行ViewChitTest方法,由於觸控點是在ViewC內,其pointInside方法返回YES,遍歷其子檢視,依次呼叫ViewDViewEhitTest方法
  4. 執行ViewDhitTest方法,由於觸控點是不在ViewD內,其pointInside方法返回NO,所以其hitTest返回nil
  5. 執行ViewEhitTest方法,由於觸控點是在 ViewE內,其pointInside方法返回YES,由於其沒有子檢視了,其hitTest返回其本身
  6. 最終,由ViewE來響應該點選事件

3. hitTest和事件傳遞有什麼關係

事件傳遞的的順序和hitTestpointInside返回為YES的檢視的執行順序是相反的。事件傳遞是從最上層的檢視開始傳遞的,直到UIApplication

拿我們上面的例子來說,hitTest執行的結果是ViewE來響應事件,但是如果ViewE並不處理該事件,則其需要把該事件進行傳遞給下一個響應者,這個時候,它會將事件拋給ViewC,如果ViewC也不處理事件,則其會將事件傳遞給ViewA,如果ViewA也不處理,則該事件就不響應了。

以下由蘋果官方文件提供的事件傳遞圖

 

蘋果官方提供的事件傳遞圖

上圖事件的傳遞流程如下:

  1. 首先,由initial view嘗試來處理事件,如果它處理不了,則會將事件傳遞給他的父檢視View
  2. View嘗試處理該事件,如果其也處理不了,再傳遞給它的父檢視UIViewController.view
  3. UIViewController.view嘗試來處理該事件,如果處理不了,將把該事件傳遞給UIViewController
  4. UIViewController嘗試處理該事件,如果處理不了,將把該事件傳遞給主視窗Window
  5. 主視窗Window嘗試來處理該事件,如果處理不了,將傳遞給應用單例Application
  6. 如果應用單例Application也處理不了,則該事件將會被丟棄

4. hitTest是如何解決子檢視超出其檢視範圍還是能響應觸控事件的

我們來看看下面的圖,下圖中中間按鈕超出了TabBar的區域

中間按鈕超出了`TabBar`的區域效果圖


我們通過Xcode中下圖紅框按鈕來檢視該頁面的層級關係

Xcode中層級檢視按鈕

 

我來看下這個圖的層級關係

層級結構圖

從以上圖可以看出,TabBarUITableView,共同的父類為UILayoutContainerView,而TabBar的層級,相對於UITableView高些,它和UITransitionView是同級的。

當我們點選中間按鈕超出TabBar部分(“中間按鈕超出了TabBar
的區域效果圖”紅框部分),系統是如何處理的呢?我們跳過UIWindow,直接從UILayoutContainerView開始呼叫hitTest

先看看大致走向圖,其中,✅部分為執行pointInsideYES部分,X部分執行pointInsideNO部分

hitTest走向圖

  1. 呼叫UILayoutContainerViewhitTest方法,由於是在其區域內,pointInside返回YES,再遍歷其子檢視,呼叫hitTest
  2. 先呼叫TabBarhitTest方法,由於點選區域是在TabBar之外的,所以pointInside返回NOhitTest返回nilTabBar並不響應該事件
  3. 再呼叫UITransitionViewhitTest方法,在其區域內,遞迴呼叫子檢視hitTest方法,直到呼叫UITableView在突出按鈕後的UITableViewCellhitTest返回,返回該Cell,最終由Cell響應該事件

所以,系統預設的處理方式,超出TabBar區域,中間按鈕是不響應該事件的,而是由其後檢視響應。

想要超出父檢視區域響應點選事件,必須將走向圖該為如下所示(其中,✅部分為執行pointInsideYES部分,X部分執行pointInsideNO部分):

修改後的hitTest走向圖

 

要讓中間按鈕響應點選超出TabBar按鈕部分的點選事件,則需要重寫TabBarhitTest方法了,在執行hitTest方法時,判斷點選區域在中間按鈕的區域,則返回中間按鈕,響應該事件,程式碼如下:

//重寫hitTest方法,去監聽中間按鈕的點選,目的是為了讓凸出的部分點選也有反應
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    
    //判斷當前手指是否點選到中間按鈕上,如果是,則響應按鈕點選,其他則系統處理
    //首先判斷當前View是否被隱藏了,隱藏了就不需要處理了
    if (self.isHidden == NO) {
        
        //將當前tabbar的觸控點轉換座標系,轉換到中間按鈕的身上,生成一個新的點
        CGPoint newP = [self convertPoint:point toView:self.centerBtn];
        
        //判斷如果這個新的點是在中間按鈕身上,那麼處理點選事件最合適的view就是中間按鈕
        if ( [self.centerBtn pointInside:newP withEvent:event]) {
            return self.centerBtn;
        }
    }
    
    return [super hitTest:point withEvent:event];
}

童鞋的疑問

這裡,之前童鞋有一個疑問:
問:直接在中間按鈕中事件hitTest直接來響應點選事件,行不行呢?
答:答案當然是不行的,如果你看懂了這篇文章,那就知道答案了。如果不在TabBar中重寫hitTest方法,系統是先呼叫TabBarhitTest方法的,在呼叫該hitTest方法時,判斷點選超出TabBar部分,不在其區域內,pointInside就返回NO了,hitTest直接返回nilTabBar不能響應該事件,其子檢視(中間按鈕)也就沒機會執行hitTest方法了。所以是不行的。

參考文章



作者:HK_Hank
連結:https://www.jianshu.com/p/ca3cd5306668
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。