iOS事件攔截和事件轉發

weixin_34279579發表於2017-06-17

響應鏈流程

基本流程

大家都知道 iOS 的響應鏈是 UIApplication 收到使用者觸控螢幕的事件以後通過逐層尋找最後得到使用者觸控的 View 也就是第一響應者,然後呼叫 View 的 touchesBegan:withEvent: 方法處理事件任務的流程.大概流程是這樣的:

2026683-67629efe30d1ff5b.png
原圖地址 http://www.cocoachina.com/ios/20160113/14896.html

圖片很清晰的說明了查詢流程 AppDelegate 收到事件逐層查詢.最終找到 UIButton 這個響應者 然後呼叫 UIButton 的touchesBegan:withEvent: 方法處理事件.

如何查詢第一響應者

查詢第一響應者主要涉及以下兩個方法

- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;

pointInside:
通過 point 引數確定觸碰點是否在當前 View 的響應範圍內 是則返回YES 否則返回 NO 實現方法大概是這個樣子的

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event{
    
    return CGRectContainsPoint(self.bounds, point);
}

hitTest方法:

  1. 它首先會通過呼叫自身的 pointInside 方法判斷使用者觸控的點是否在當前物件的響應範圍內,如果 pointInside 方法返回 NO hitTest方法直接返回 nil
  2. 如果 pointInside 方法返回 YES hitTest方法接著會判斷自身是否有子檢視.如果有則呼叫頂層子檢視的 hitTest 方法 直到有子檢視返回 View
  3. 如果所有子檢視都返回 nil hitTest 方法返回自身.

hitTest方法的內部實現虛擬碼

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    // 判斷觸控位置是否在當前檢視內
    if ([self pointInside:point withEvent:event]) {
        NSArray<UIView *> * superViews = self.subviews;
        // 倒序 從最上面的一個檢視開始查詢
        for (NSUInteger i = superViews.count; i > 0; i--) {
            UIView * subview = superViews[i - 1];
            // 轉換座標系 使座標基於子檢視
            CGPoint newPoint = [self convertPoint:point toView:subview];
            // 得到子檢視 hitTest 方法返回的值
            UIView * view = [subview hitTest:newPoint withEvent:event];
            // 如果子檢視返回一個view 就直接返回 不在繼續遍歷
            if (view) {
                return view;
            }
        }
        // 所有子檢視都沒有返回 則返回自身
        return self;
    }
    return nil;
}

事件傳遞

找到第一響應者 application 便會根據 event 呼叫第一響應者響應的
touch 方法:

- (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);

第一響應者在這幾個方法中處理響應的事件,處理完成後根據需要呼叫 nextResponder 的 touch 方法,通常 nextResponder 就是第一響應者的 superView 文章的第一張圖倒著看就是nextResponder 的順序

事件攔截

通常第一響應者都是響應鏈中最末端的響應者,事件攔截就是在響應鏈中截獲事件,停止下發.將事件交由中間的某個響應者執行.比如這樣:

2026683-0d66429470ccceed.png

通常點選紅色 view 事件將交由 紅色 view 處理.如果想讓粉色 View 或者綠色 view 處理事件應該怎麼辦?
有兩種辦法

  1. 在紅色 view 的的 touch 方法中呼叫父類或者 nextResponder 的
    touch 方法
  2. 在需要攔截的 view 中重寫 hitTest 方法改變第一響應者

首先來看第一種

 - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 將事件傳遞給下一響應者
    [self.nextResponder touchesBegan:touches withEvent:event];
    // 呼叫父類的touch方法 和上面的方法效果一樣 這兩句只需要其中一句
    [super touchesBegan:touches withEvent:event];
    
}

這種方法有兩個問題,你需要重寫所有的 touch 方法並且還要重寫要攔截事件的 view 與頂級 view 之間的所有 view 的 touch 方法

第二種方法
重寫攔截事件的 view 的 hitTest 方法 比如要讓綠色的 view 處理事件 就重寫綠色 view 的 hitTest 方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    // 如果在當前 view 中 直接返回 self 這樣自身就成為了第一響應者 subViews 不再能夠接受到響應事件
    if ([self pointInside:point withEvent:event]) {
        return self;
    }
    return nil;
}

這種方法比較簡單粗暴.實現後 所有 subview 將不再能夠接受任何事件 具體使用那種方式看需求.當然還可以通過 event 或者 point 有針對性的攔截

事件轉發

有時候還需要將事件轉發出去.讓本來不能響應事件的 view 響應事件,最常用的場景就是讓子檢視超出父檢視的部分也能響應事件,比如要實現這樣的 tabbar

2026683-33c11956ffa0b039.png
9A58AA4E-0685-4F08-A3AE-06E9C627EA08.png

橙色按鈕有兩個區域 a 區超出父檢視 b 區沒有超出父檢視,如果不作處理,那麼點選 a 區是無法響應事件的,因為 a 區域的座標不在父檢視的範圍內,當執行到父檢視的 pointInside 的時候就會返回 NO
想要讓 a 區響應事件 就需要重寫父檢視的 pointInside 或 hitTest 方法讓 pointInside 返回 YES 或 讓hitTest 直接返回橙色檢視
重寫hitTest

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{
    
    // 觸控點在檢視範圍內 則交由父類處理
    if ([self pointInside:point withEvent:event]) {
        return [super hitTest:point withEvent:event];
    }
    
    // 如果觸控點不在範圍內 而在子檢視範圍內依舊返回子檢視
    NSArray<UIView *> * superViews = self.subviews;
    // 倒序 從最上面的一個檢視開始查詢
    for (NSUInteger i = superViews.count; i > 0; i--) {

        UIView * subview = superViews[i - 1];
        // 轉換座標系 使座標基於子檢視
        CGPoint newPoint = [self convertPoint:point toView:subview];
        // 得到子檢視 hitTest 方法返回的值
        UIView * view = [subview hitTest:newPoint withEvent:event];
        // 如果子檢視返回一個view 就直接返回 不在繼續遍歷
        if (view) {

            return view;
        }
    }
    
    return nil;
}

重寫 pointInside 方法原理相同 重點注意轉換座標系 就算他們不是一條響應鏈上 也可以通過重寫 hitTest 方法轉發事件.原理相同的東西就不再寫了

擴充套件

關於手勢的處理邏輯和這個相同.但是手勢的優先順序更高.如果父檢視有手勢.預設優先處理手勢事件 可以修改手勢的屬性cancelsTouchesInView為 NO 來同時處理手勢和普通觸控事件

相關文章