談談響應鏈

本文地址html

當用戶的手指在屏幕上的某一點按下時,屏幕接收到點擊信號將點擊位置轉換成具體座標,而後本次點擊被包裝成一個點擊事件UIEvent。最終會存在某個視圖響應本次事件進行處理,而爲UIEvent查找響應視圖的過程被稱爲響應鏈查找,在整個過程當中有兩個相當重要的類:UIResponderUIViewgit

響應者

響應者是能夠處理事件的具體對象,一個響應者應當是UIResponder或其子類的實例對象。從設計上來看,UIResponder主要提供了三類接口:github

  • 向上查詢響應者的接口,體如今nextResponder這個惟一的接口
  • 用戶操做的處理接口,包括touchpressremote三類事件的處理
  • 是否具有處理action的能力,以及爲其找到target的能力

整體來講UIResponder爲整個事件查找過程提供了處理能力api

視圖

視圖是展現在界面上的可視元素,包括不限於文本按鈕圖片等可見樣式。雖然UIResponder提供了讓對象響應事件的處理能力,但擁有處理事件能力的responder是沒法被用戶觀察到的,換句話說,用戶也沒法點擊這些有處理能力的對象,所以UIView提供了一個可視化的載體,從接口上來看UIView提供了三方面的能力:dom

  • 視圖樹結構。雖然responder也存在相同的樹狀結構,但其必須依託於可視載體進行表達
  • 可視化內容。經過frame等屬性決定視圖在屏幕上的可視範圍,提供了點擊座標和響應可視對象的關聯能力
  • 內容佈局重繪。視圖渲染到屏幕上雖然很複雜,但按照不一樣的layout方式提供了不一樣階段的重繪調起接口,使得子類具備很強的定製性

視圖樹的結構以下,因爲UIViewUIResponder的子類,能夠經過nextResponder訪問到父級視圖,但因爲responder並不全是具有可視載體的對象,經過nextResponder向上查找的方式可能會致使沒法經過位置計算的方式查找響應者 ide

查找響應者

講了這麼多,也該聊聊查找響應者的過程了。前面說了,responder決定了對象具備響應處理的能力,而UIView纔是提供了可視載體和點擊座標關聯的能力。換句話說,查找響應者其實是查找點擊座標落點位置在其可視範圍內且其具有處理事件能力的對象,按照官方話來講就是既要responde又要view的對象。由於須要先找到響應者,纔能有進一步的處理,因此直接從後者的接口找起,兩個api佈局

/// 檢測座標點是否落在當前視圖範圍內
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;
/// 查找響應處理事件的最終視圖
- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;
複製代碼

經過exchange掉第一個方法的實現,很輕易的就能得出響應者查找的順序:動畫

- (BOOL)sl_pointInside: (CGPoint)point withEvent: (UIEvent *)event {
    BOOL res = [self sl_pointInside: point withEvent: event];
    if (res) {
        NSLog(@"[%@ can answer]", self.class);
    } else {
        NSLog(@"non answer in %@", self.class);
    }
    return res;
}
複製代碼

如圖建立相同的佈局結構,而後點擊BView,獲得的日誌:spa

[UIStatusBarWindow can answer]
non answer in UIStatusBar
non answer in UIStatusBarForegroundView
non answer in UIStatusBarServiceItemView
non answer in UIStatusBarDataNetworkItemView
non answer in UIStatusBarBatteryItemView
non answer in UIStatusBarTimeItemView
[UIWindow can answer]
[UIView can answer]
non answer in CView
[AView can answer]
[BView can answer]
複製代碼

經過日誌輸出能夠看出查找順序優先級有兩條:設計

  1. 優先級更高的window優先匹配
  2. 同一個window中從父視圖向子視圖尋找

經過pointInside:肯定了點擊座標落在哪些視圖的範圍後,會繼續調用另外一個方法來找尋真正的響應者。一樣hook掉這個方法,從日誌來看父級視圖會調用子視圖,最終以遞歸的格式輸出:

- (UIView *)sl_hitTest: (CGPoint)point withEvent: (UIEvent *)event {
    UIView *res = [self sl_hitTest: point withEvent: event];
    NSLog(@"hit view is: %@ and self is: %@", res.class, self.class);
    return res;
}

/// 輸出日誌
hit view is: (null) and self is: CView
hit view is: BView and self is: BView
hit view is: BView and self is: AView
hit view is: BView and self is: UIView
hit view is: BView and self is: UIWindow
複製代碼

當肯定了是CView是最後一個落點視圖時,會以CView爲起始點,向上尋找事件的響應者,這個查找過程由一個個responder連接起來,這也是響應鏈名字的來由。另外,基於樹結構的視圖層級,只須要咱們持有根節點,就能遍歷整棵樹,這也是爲何搜索過程是從window開始的

響應者處理

通過pointInside:hitTest:兩個方法會肯定點擊位置最上方的可視響應者,但並不表明了這個responder會處理本次事件。基於上面的界面demo,在AView中實現touches方法:

@implementation AView

- (void)touchesBegan: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event {
    NSLog(@"A began");
}

- (void)touchesCancelled: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event {
    NSLog(@"A canceled");
}

- (void)touchesEnded: (NSSet<UITouch *> *)touches withEvent: (UIEvent *)event {
    NSLog(@"A ended");
}

@end
複製代碼

前面說過UIResponder提供了用戶操做的處理接口,但很明顯touches系列的接口默認是未實現的,所以BView即使成爲了響應鏈上的最上層節點,依舊沒法處理點擊事件,而是沿着響應鏈查找響應者:

void handleTouch(UIResponder *responder, NSSet<UITouch *> *touches UIEvent *event) {
    if (!responder) {
        return;
    }
    if ([responder respondsToSelector: @selector(touchesBegan:withEvent:)]) {
        [responder touchesBegan: touches withEvent: event];
    } else {
        handleTouch(responder.nextResponder, touches, event);
    }
}
複製代碼

另一個有趣的地方是手勢攔截,除了實現touches系列方法讓view提供響應能力以外,咱們還能夠主動的在視圖上添加手勢進行回調:

- (void)viewDidLoad {
    [super viewDidLoad];
    [_a addGestureRecognizer: [[UITapGestureRecognizer alloc] initWithTarget: self action: @selector(clickedA:)]];
}

- (void)clickedA: (UITapGestureRecognizer *)tap {
    NSLog(@"gesture clicked A");
}

/// 日誌輸出
A began
gesture clicked A
A canceled
複製代碼

從日誌能夠看到手勢的處理是在touchesBegan以後,而且執行以後會中斷原有的touches調用鏈,所以能夠肯定即使是手勢動做,最終依舊由UIResponder進行事件分發處理

小結

觸屏事件的處理被分紅兩個階段:查找響應者響應者處理,分別由UIViewUIResponder提供了功能上的支持。另外因爲pointInside:hitTest:這兩個關鍵接口是對外暴露的,所以經過hook或者inherit的方式來修改這兩個方法,可使得視圖的可響應範圍大於顯示範圍

應用

最近有個需求須要在tabbar的位置上方彈出氣泡,且容許用戶點擊氣泡發生交互事件。從視圖層來分析,tabbar被嵌套在多層尺寸等同於菜單欄的view當中:

若是要實現從item彈起氣泡而且可交互,有兩個可行的方案:

  1. 將氣泡添加到ViewController的視圖上
  2. 修改tabbar的響應鏈查找接口,實現顯示範圍外的可點擊處理

考慮到若是項目中存在相同的彈出需求可能會致使寫了一堆重複代碼,因此將彈出動做觸屏判斷給封裝起來,經過hook的方式來實現彈出氣泡自動化觸屏檢測功能

接口設計

依照最少接口原則,只暴露兩個彈出接口:

/*!
 *  @enum   SLViewDirection
 *  彈窗視圖所在的方向(dismiss和pop應當保持一致)
 */
typedef NS_ENUM(NSInteger, SLViewDirection)
{
    SLViewDirectionCenter,
    SLViewDirectionTop,
    SLViewDirectionLeft,
    SLViewDirectionBottom,
    SLViewDirectionRight
};

/*!
 *  @category   UIView+SLFreedomPop
 *  自由彈窗擴展
 */
@interface UIView (SLFreedomPop)

/*!
 *  @method sl_popView:
 *  居中彈出view
 *  @param  view    要彈出的view
 */
- (void)sl_popView: (UIView *)view;

/*!
 *  @method sl_popView:WithDirection:
 *  控制彈窗方向
 *  @param  view        要彈出的view
 *  @param  direction   彈出方向
 */
- (void)sl_popView: (UIView *)view withDirection: (SLViewDirection)direction;

@end
複製代碼

落點檢測

考慮到兩個問題:

  1. 視圖可能存在多個超出顯示範圍的子視圖
  2. 視圖存在超出父級視圖顯示範圍的子視圖

針對這兩個問題,解決方案分別是:

  1. 用視圖做key,存儲一個extraRect的列表
  2. 當彈出視圖超出自身範圍時,向父級視圖調用,確保父級視圖能處理響應

最終檢測代碼以下:

#define SLRectOverflow(subrect, rect) \
    subrect.origin.x < 0 || \
    subrect.origin.y < 0 || \
    CGRectGetMaxX(subrect) > CGRectGetWidth(rect) ||   \
    CGRectGetMaxY(subrect) > CGRectGetHeight(rect)

#pragma mark - Private
- (BOOL)_sl_pointInsideExtraRects: (CGPoint)point {
    NSArray *extraRects = [self extraHitRects].allValues;
    if (extraRects.count == 0) {
        return NO;
    }
    
    for (NSSet *rects in extraRects) {
        for (NSString *rectStr in rects) {
            if (CGRectContainsPoint(CGRectFromString(rectStr), point)) {
                return YES;
            }
        }
    }
    return NO;
}

#pragma mark - Rects
- (void)_sl_addExtraRect: (CGRect)extraRect inSubview: (UIView *)subview {
    CGRect curRect = [subview convertRect: extraRect toView: self];
    if (SLRectOverflow(curRect, self.frame)) {
        [self _sl_expandExtraRects: curRect forKey: [NSValue valueWithBytes: &subview objCType: @encode(typeof(subview))]];
        [self.superview _sl_addExtraRect: curRect inSubview: self];
    }
}

#pragma mark - Hook
- (BOOL)sl_pointInside: (CGPoint)point withEvent: (UIEvent *)event {
    BOOL res = [self sl_pointInside: point withEvent: event];
    if (!res) {
        return [self _sl_pointInsideExtraRects: point];
    }
    return res;
}

- (UIView *)sl_hitTest: (CGPoint)point withEvent: (UIEvent *)event {
    UIView *res = [self sl_hitTest: point withEvent: event];
    if (!res) {
        if ([self _sl_pointInsideExtraRects: point]) {
            return self;
        }
    }
    return res;
}
複製代碼

運行效果

紅色視圖彈出綠色視圖,超出自身和父視圖的顯示範圍,點擊有效:

定製改進

因爲核心功能在於event handle,目前代碼只提供了簡單的彈出接口,須要進一步擴充彈出接口能力的能夠考慮兩點改進:

  • 增長configuration來定製彈窗樣式
  • 提供animation來完成自定義的動畫

文章的源碼地址

關注個人公衆號獲取更新信息
相關文章
相關標籤/搜索