一個觸摸點(如touch-point)是否和一個繪制在屏幕上的圖像對象(如UIView)相交(intersects),決定這一結(jié)果的過程就是hit-testing.找出用戶手指下能接收觸摸事件的那個最前端的UIView,在iOS中就是用hit-testing實現(xiàn)的. 它的算法實現(xiàn)是深度優(yōu)先逆先序遍歷算法(reverse pre-order depth-first traversal algorithm).
在解釋hit-testing工作原理前,理解它是怎么執(zhí)行的很有必要.下圖展示了單點觸摸開始到結(jié)束的高層次的流向(high-level flow).

每次一個手指觸摸屏幕,hit-testing就會像上面那樣執(zhí)行.在此之前,view或gesture會接收到觸摸對象(touch)所屬的事件(event).
不知什么原因,hit-testing會連續(xù)執(zhí)行多次.不過,多次確定的hit-testing view一樣.
hit-testing完成后,觸摸點下最前端的view也確定了.確定的hit-testing view被關(guān)聯(lián)上各個階段的觸摸事件序列(例如:開始,移動,結(jié)束,取消).另外,hit-testing view和添加在該view上的手勢以及其祖先都會被關(guān)聯(lián)touch對象.
需要注意的是即使手指移出hit-testing view,它依然會接收touch對象,直到touch event序列結(jié)束.
touch對象在hit-test view整個生命周期內(nèi)都被關(guān)聯(lián)著,即使移到了該view外.
參看:iOS事件處理指南,iOS開發(fā)者庫
如先前所說,hit-testing使用的是反先序深度優(yōu)先遍歷算法(先遍歷根節(jié)點,然后按索引由高到低遍歷子樹).這種遍歷方式可以減少迭代,一旦遍歷到包含觸摸點的最深層后代view,既可以停止搜索.這種情況是可能的,因為子視圖總是渲染在父視圖上,兄弟視圖總是渲染在subview數(shù)組中索引值低的兄弟視圖上.如此,當多個重疊視圖包含具體點時,右子樹中最深的view將會是最前端的view.
表面上,subview會遮蓋parent view的部分或全部.每個superview以數(shù)組的形式有序地存儲在subview,subview在數(shù)組中的順序影響著該view的顯示.兩個兄弟subview相遮蓋時,最后加入數(shù)組的(或被移到subview數(shù)組后面的)會顯示在另一個的上面.
參看:iOS開發(fā)指南,iOS開發(fā)者庫
下圖是一個屏幕上的用戶界面和它的視圖層次樹的例子.這棵樹的分支從左到右的分布反應(yīng)了subview數(shù)組的順序.

正如所看到的,"View A"和"View B"是子view,"View B.1"和"View A.2"相遮蓋."View B"比"View A"的索引值大,"View B"和其子view會渲染在"View A"和其子view之上.當用戶手指觸摸屏幕觸發(fā)hit-testing時,"View B"就是hit-testing view.
使用反先序的深度優(yōu)先算法,一旦發(fā)現(xiàn)最深層后代view包含touch-point,就可以停止遍歷.

該深度優(yōu)先算法開始先給view的root view(UIWindow)發(fā)送hitTest:withEvent消息.這個方法的返回值是包含touch-point的最前端的view.
下面流程圖可以說明hit-test邏輯.

下面代碼是原生hitTest:withEvent:方法的可能實現(xiàn).
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
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;
}
hitTest:withEvent:首先檢查view是否可以接收touch,一個view如果可以接收touch,需:
view沒有隱藏
self.hidden = NOview開啟用戶交互
self.userInteractionEnable = YESview的alpha值大于0.01
self.alpha > 0.01viwe包含該點
pointInside:withEvent: == YES
滿足上述,view允許接收touch,按照從后向前的順序向該view的每一個subview發(fā)送hitTest:withEvent:來遍歷,當返回非nil時停止.touch-point下,在subview中進行hit-testing,最先返回非nil值是前端的view返回的,同時也是也是當前接收接收者的返回值.如果接收者的subview都返回nil或者沒有subview返回接收者.
相反,view不允許接收touch,這個方法不會遍歷其子樹,直接返回nil.由此可知,hit-test過程可能不會遍歷視圖層次中的每個view.
重寫hitTest:withEvent:通用的使用案例
在各階段touch事件序列中的touch事件想要由一個view重定向到另一個view處理時,可以重寫 hitTest:withEvent: 方法.
重寫
hitTest:withEvent:方法來重定向touch事件,將會重定向touch事件序列中的所有touch事件.hit-test執(zhí)行必須在第一個touch事件發(fā)送到接收者之前(UITouchPhaseBegan階段touch).
擴大view觸摸區(qū)(touch area)
一個使用場景,view的觸摸區(qū)需要大于它的bounds時,可以重寫hitTest:withEvent:方法.例如,下圖展示了一個20x20的UIView,處理touches附近的區(qū)域,這個尺寸可能太小了.因此,通過重寫hitTest:withEvent:,觸摸區(qū)可以在每個方向上擴大10坐標點.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
CGRect touchRect = CGRectInset(self.bounds, -10, -10);
if (CGRectContainsPoint(touchRect, point)) {
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;
}
注意:為了hit-test正常,父視圖的bounds應(yīng)該包含目標子視圖的觸摸區(qū),或者重寫
hitTest:withEvent:來包含目標觸摸區(qū).
傳事件到下面的視圖
有時,一個視圖忽略touch事件,把該事件傳給它下面的視圖是很有必要的.舉個例子,一個透明的遮蓋視圖(transparent overlay)放置在app其他的所有視圖上.該遮蓋視圖有一些control和button類型的子視圖,它們應(yīng)該正常響應(yīng)觸摸事件.但觸摸遮蓋視圖的其它位置應(yīng)該把事件傳給它下面的視圖.為了實現(xiàn)這一個行為,遮蓋層的hitTest:withEvent:方法可以被重寫,它的子視圖包含touch-point時返回子視圖,其它情況返回nil,這里的其它情況也包括遮蓋視圖包含touch-point.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitTestView = [super hitTest:point withEvent:event];
if (hitTestView == self) {
hitTestView = nil;
}
return hitTestView;
}
譯者注:
1.這個案例我在翻譯時反復(fù)讀了原文很多遍,原文中的views below指的是視圖書中更靠近根節(jié)點的那些視圖,而蘋果文檔中的lowest view指的是最靠近葉子節(jié)點的視圖.
2.這里的透明遮蓋視圖本身是可以響應(yīng)的touch的.
3.作者說遮蓋視圖是透明的,想構(gòu)造一種使用場景,如果子控件可以響應(yīng),就讓子控件響應(yīng),如果觸摸不在子控件上,遮蓋視圖就把交由它下面的視圖響應(yīng).
傳遞事件給子視圖
一個不同的使用案例是父視圖把事件傳給它的子視圖.在子視圖展示在父視圖的一個區(qū)域,但應(yīng)該響應(yīng)出現(xiàn)在父視圖上的所有touch時,可能需要這種使用方式.一個例子,由一個父視圖和UIScrollview來創(chuàng)建圖片的旋轉(zhuǎn)木馬效果,UIScrollview的pagingEnabled屬性設(shè)置為YES,clipsToBounds屬性設(shè)置為NO.

為了讓UIScrollview不僅能響應(yīng)出現(xiàn)在自己bounds內(nèi)的touch,也能響應(yīng)父視圖bounds內(nèi)的touch,父視圖的hitTest:withEvent:方法可以像下面這種方式重寫:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitTestView = [super hitTest:point withEvent:event];
if (hitTestView) {
hitTestView = self.scrollView;
}
return hitTestView;
}
至此,文章翻譯結(jié)束.點擊看原文
譯者的話
在查閱資料時,不要只是看看,最好動手嘗試一下.否則,你看過的東西即便是錯的,也會形成你的認知.
不信你可以看看網(wǎng)上關(guān)于下面這類方法的博客,看看他們是如何解釋super一下的作用.
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[super touchesBegan:touches withEvent:event];
}
很多解釋都會扯到父視圖,至少從表面實驗結(jié)果這是對的,注釋掉[super touchesBegan:touches withEvent:event];,父視圖的該方法確實不會執(zhí)行,加上就會執(zhí)行.這里的super和父視圖完全沒關(guān)系,而是為了調(diào)用父類的方法.
為啥注釋掉這個方法,就不調(diào)用父視圖的該方法呢?很簡單,如果hit-test view能處理touch事件就不會繼續(xù)沿著響應(yīng)者鏈繼續(xù)傳下去了.我們在hit-test view中實現(xiàn)了- (void)touchesBegan:(NSSet<UITouch *> *)touches,就意味著hit-test view可以處理touch事件,所以事件不會繼續(xù)傳了.如果調(diào)用了[super touchesBegan:touches withEvent:event];呢?如果該hit-test view的父類沒有實現(xiàn)該方法,就會傳給響應(yīng)者鏈中的下一個響應(yīng)者,這樣父視圖中的該方法就會被調(diào)用.
這里只是一個小例子,希望大家看資料時注意甄別.