本文地址html
當用戶的手指在屏幕上的某一點按下時,屏幕接收到點擊信號將點擊位置轉換成具體座標,而後本次點擊被包裝成一個點擊事件UIEvent
。最終會存在某個視圖響應本次事件進行處理,而爲UIEvent
查找響應視圖的過程被稱爲響應鏈查找
,在整個過程當中有兩個相當重要的類:UIResponder
和UIView
git
響應者是能夠處理事件的具體對象,一個響應者應當是UIResponder
或其子類的實例對象。從設計上來看,UIResponder
主要提供了三類接口:github
nextResponder
這個惟一的接口touch
、press
和remote
三類事件的處理action
的能力,以及爲其找到target
的能力整體來講UIResponder
爲整個事件查找過程提供了處理能力api
視圖是展現在界面上的可視元素,包括不限於文本
、按鈕
、圖片
等可見樣式。雖然UIResponder
提供了讓對象響應事件的處理能力,但擁有處理事件能力的responder
是沒法被用戶觀察到的,換句話說,用戶也沒法點擊這些有處理能力的對象,所以UIView
提供了一個可視化的載體,從接口上來看UIView
提供了三方面的能力:dom
responder
也存在相同的樹狀結構,但其必須依託於可視載體進行表達frame
等屬性決定視圖在屏幕上的可視範圍,提供了點擊座標和響應可視對象的關聯能力layout
方式提供了不一樣階段的重繪調起接口,使得子類具備很強的定製性視圖樹的結構以下,因爲UIView
是UIResponder
的子類,能夠經過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]
複製代碼
經過日誌輸出能夠看出查找順序優先級有兩條:設計
window
優先匹配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
進行事件分發處理
觸屏事件的處理被分紅兩個階段:查找響應者
和響應者處理
,分別由UIView
和 UIResponder
提供了功能上的支持。另外因爲pointInside:
和hitTest:
這兩個關鍵接口是對外暴露的,所以經過hook
或者inherit
的方式來修改這兩個方法,可使得視圖的可響應範圍大於顯示範圍
最近有個需求須要在tabbar
的位置上方彈出氣泡,且容許用戶點擊氣泡發生交互事件。從視圖層來分析,tabbar
被嵌套在多層尺寸等同於菜單欄的view
當中:
若是要實現從item
彈起氣泡而且可交互,有兩個可行的方案:
ViewController
的視圖上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
複製代碼
考慮到兩個問題:
針對這兩個問題,解決方案分別是:
key
,存儲一個extraRect
的列表最終檢測代碼以下:
#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
來完成自定義的動畫文章的源碼地址