iOS 事件處理 | Hit-Testing

一個觸摸點(如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 = NO

  • view開啟用戶交互
    self.userInteractionEnable = YES

  • view的alpha值大于0.01
    self.alpha > 0.01

  • viwe包含該點
    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)用.

這里只是一個小例子,希望大家看資料時注意甄別.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 好奇觸摸事件是如何從屏幕轉(zhuǎn)移到APP內(nèi)的?困惑于Cell怎么突然不能點擊了?糾結(jié)于如何實現(xiàn)這個奇葩響應(yīng)需求?亦或是...
    Lotheve閱讀 59,425評論 51 604
  • 在iOS開發(fā)中經(jīng)常會涉及到觸摸事件。本想自己總結(jié)一下,但是遇到了這篇文章,感覺總結(jié)的已經(jīng)很到位,特此轉(zhuǎn)載。作者:L...
    WQ_UESTC閱讀 6,236評論 4 26
  • 用戶以多種方式操縱他們的iOS設(shè)備,例如觸摸屏幕或搖動設(shè)備。 iOS會解釋用戶何時以及如何操作硬件并將此信息傳遞到...
    坤坤同學(xué)閱讀 4,116評論 7 19
  • Hit-testing翻譯為中文是"命中測試",是確定touch-point是否在一個View內(nèi)的過程,最終命中的...
    iOneWay閱讀 1,363評論 1 8
  • -- iOS事件全面解析 概覽 iPhone的成功很大一部分得益于它多點觸摸的強大功能,喬布斯讓人們認識到手機其實...
    翹楚iOS9閱讀 3,196評論 0 13

友情鏈接更多精彩內(nèi)容