淺析iOS事件的傳遞及分發

響應者Responders

說到事件,不得不從UIResponder說起;
UIResponder是用於響應和處理事件的抽象接口,UIResponder的實例構成了UIKit的事件處理主幹,許多UIKit類也都是繼承自UIResponder,包括UIApplication, UIViewController以及UIView(包括UIWindow),它們的實例都是響應者:(對用戶交互動作事件進行響應的對象),當事件發生時,UIKit將它們分派到應用的responder對象中進行處理。

UIResponder響應的事件有以下幾種:

  • touch events
- (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);
  • motion events
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
  • remote-control events
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);
  • press events
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);

響應者鏈Responder Chain

由多個響應者組合起來的鏈條,就是響應者鏈。它表示了每個響應者之間的聯繫,並且可以使得一個事件可選擇多個對象處理。UIResponder有nextResponder方法,返回響應鏈的下一個響應者;一般的,響應者的下個響應者是它的父視圖(如果響應者是UIViewController的view,這個響應者的下個響應者是UIViewController)。當然我們也可以重寫nextResponder方法來指定下個響應者。
官方文檔舉了個通俗易懂的例子來描述響應者鏈:

如果text field沒有處理事件,UIKit會將事件發送給text field的父視圖UIView對象,如果UIView對象沒有處理事件,事件會被髮送給UIViewController的根視圖;之後事件會依次傳遞到根視圖下個響應者即視圖所屬的視圖控制器,然後是UIWindow對象,然後是UIApplication對象,如果該對象是UIResponder的一個實例並且不是響應者鏈的一部分,可能會傳遞給AppDelegate。

使用touch events來驗證下:

viewController和redView都加上touch event

// viewController
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%s",__func__);
}

// redView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%s",__func__);
}

當點擊紅色view時,很顯然只有紅色view響應事件。註釋redView中touch方法後,點擊紅色view時viewController的touchesBegan事件觸發了。如何讓viewController和redView都能響應這個touch事件呢,可以在redView中主動傳遞給下個響應者:

// redView
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.nextResponder touchesBegan:touches withEvent:event];
    // 或者  [super touchesBegan:touches withEvent:event];
    NSLog(@"%s",__func__);
}

Hit-Test 機制

當一個touch events產生的時候,系統是如何找到第一響應者(即最適合處理這個事件的對象)的呢?這裏就是使用了Hit-Test 機制:
Hit-Test有關的兩個方法:

- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;   // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;   // default returns YES if point is in bounds
  • 當產生一個touch事件,Runloop會接收到事件並把其加入UIApplication事件隊列裏;
  • UIApplication從事件隊列中取出最新的事件進行分發傳遞給UIWindow進行處理;
  • UIWindow會調用hitTest:withEvent:方法在視圖層次結構中找到一個最合適的UIView來處理這個事件;分發的順序和響應鏈基本相反:UIApplication -> UIWindow -> Root View -> ··· -> subview:
  • hitTest:withEvent:方法會調用當前視圖的pointInside:withEvent:方法判斷觸摸點是否在當前視圖內;
  • 若pointInside:withEvent:方法返回NO,說明觸摸點不在當前視圖內,則當前視圖的hitTest:withEvent:返回nil;
  • 若pointInside:withEvent:方法返回YES,說明觸摸點在當前視圖內,則遍歷當前視圖的所有子視圖(subviews),調用子視圖的hitTest:withEvent:方法(子視圖重複同樣的步驟),子視圖的遍歷順序是棧的形式,即從最後面添加的子視圖至最早添加的子視圖,直到有子視圖的hitTest:withEvent:方法返回非空對象或者全部子視圖遍歷完畢;
  • 若有子視圖的hitTest:withEvent:方法返回非空對象(第一響應對象爲子視圖),則當前視圖的hitTest:withEvent:方法就返回此對象,處理結束;
  • 若所有子視圖的hitTest:withEvent:方法都返回nil(觸摸點不在子視圖上),則當前視圖的hitTest:withEvent:方法返回當前視圖self;

同樣,還是來驗證下:

在之前的redView上添加了2個subviews:blueView和yellowView;這三個view分別重寫hitTest:withEvent:和pointInside:withEvent:方法

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    NSLog(@"%@ start hit",[self class]);
    UIView *view = [super hitTest:point withEvent:event];
    NSLog(@"%@ hitView:%@",[self class],[view class]);
    return view;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    BOOL inside = [super pointInside:point withEvent:event];
    NSLog(@"%@ pointInside:%@",[self class],inside ? @"YES" : @"NO");
    return inside;
}

首先,點擊yellowView,輸出log如下:

RedView start hit
RedView pointInside:YES
YellowView start hit
YellowView pointInside:YES
YellowView hitView:Third

結果不重要,重要的是過程,我們來分析下這個過程:

  1. redView是父視圖,首先會調用redView的hitTest:withEvent:方法,在獲取hitView的時候會調用pointInside:withEvent:方法判斷點擊的point是否在當前視圖frame內;
  2. pointInside:withEvent:返回YES,則依次分發給redView的子視圖;由於yellowView是後面添加的,會先分發給yellowView調用hitTest:withEvent:。
  3. 和之前redView同樣的流程,yellowView判斷後point在當前視圖frame內,由於yellowView沒有子視圖分發結束;hitTest:withEvent:返回yellowView對象;然後父視圖redView的hitTest:withEvent:也返回yellowView對象;

綜上,事件流程其實可以用一張圖表示:

實際應用

以上知識,在實際開發中有什麼用途呢?

  • 通過nextResponser方法實現解耦:
    需求:在自定義UIView中獲取ViewController對象;一般的做法是在自定義View中聲明並引用ViewController對象,但這樣做耦合性高了。可以使用nextResponser獲取:
@implementation UIView (Tool)

- (UIViewController *)hh_viewController {
    UIResponder *rp = [self nextResponder];
    while (rp) {
        if ([rp isKindOfClass:[UIViewController class]]) {
            return (UIViewController *)rp;
        }
        rp = [rp nextResponder];
    }
    return nil;
}
             
@end
  • 重寫hitTest:withEvent:或pointInside:withEvent:方法,解決某些事件不能響應的情況:
    控件不能響應事件,一般有如下情況:

1.userInteractionEnabled = NO(這也是UIImageView控件及其子視圖不能響應事件的原因)
2.hidden = YES
3.alpha小於等於0.01
4.子視圖超出了父視圖frame

項目中一個常見的需求就是讓超出父視圖範圍的子視圖也能響應事件,比如TableBar中突出的按鈕;現在簡單模擬一下這種情況:

blueView是redView的subview,並且有一半已超出父視圖範圍;這時點擊blueView的上半部分,肯定沒有任何反應;這是因爲父視圖的pointInside:withEvent:方法返回了NO,就不會遍歷子視圖了。可以重寫redView的pointInside:withEvent:方法解決此問題。

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
    BOOL inside = [super pointInside:point withEvent:event];
    if (!inside) {
        UIView *subview = self.subviews[0];
        CGRect subRect = subview.frame;
        if (CGRectContainsPoint(subRect, point)) {
            inside = YES;
        }
    }
    return inside;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章