本文將簡單介紹 iOS 的點擊事件( TouchEvents )分發機制和一些使用場景。詳解請看參考部分。
從以下兩個方面介紹:
1. 尋找 hit-TestView 的過程(事件的傳遞過程)
2. 響應鏈(事件的響應過程)
一些應用場景:
- 一個內容是圓形的按鈕(指定只允許視圖的 frame 內某個區域可以響應事件)
- tabBar 上中間凸起的按鈕(讓超出父視圖邊界的子視圖區域也能響應事件)
開始
尋找 hit-TestView 的過程的總結
在 iOS 中,當產生一個 touch 事件之后(點擊屏幕),通過 hit-Testing 找到觸摸點所在的 View( hit-TestView )。尋找過程總結如下(默認情況下):
尋找順序如下:
1. 從視圖層級最底層的 window 開始遍歷它的子 View。
2. 默認的遍歷順序是按照 UIView 中 Subviews 的逆順序。
3. 找到 hit-TestView 之后,尋找過程就結束了。
確定一個 View 是不是 hit-TestView 的過程如下:
1. 如果 View 的 userInteractionEnabled = NO,enabled = NO( UIControl ),或者 alpha <= 0.01, hidden = YES 等情況的時候,直接返回 nil(不再往下判斷)。
2. 如果觸摸點不在 view 中,直接返回 nil。
3. 如果觸摸點在 view 中,逆序遍歷它的子 View ,重復上面的過程。
4. 如果 view 的 子view 都返回 nil(都不是 hit-TestVeiw ),那么返回自身(自身是 hit-TestView )。
UIView 提供兩個方法來來確定 hit-TestView:
// 返回一個 hit-TestView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event; // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
// 判斷觸摸點是否在 view 中
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event; // default returns YES if point is in bounds
hitTest:withEvent: 方法的具體實現可以寫成這樣:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
//1
if (self.alpha <= 0.01 || !self.userInteractionEnabled || self.hidden) {
return nil;
}
//2
if (![self pointInside:point withEvent:event]) {
return nil;
}
//3
NSEnumerator *enumerator = [self.subviews reverseObjectEnumerator];
for (UIView *subview in enumerator) {
UIView *hitTestView = [subview hitTest:point withEvent:event];
if (hitTestView) {
return hitTestView;
}
}
//4
return self;
}
看完了理論,再結合實際,這樣就好理解了。
以下講解基于這樣的視圖層級結構
+-UIWindow
+-MainView
+-RedView
| +-UIButton
| +-UIButtonLabel
+-YellowView
+-UILabel
下面是測試過程中的一些日志(請結合上面的總結來分析):
ps:在實際項目中點擊一次視圖會打印兩次下面的信息中間插入一次 UIStatusBarWindow 的信息,目前也不知道什么原因,如果有知道的請分享出來,非常感謝!
點擊紅色 View 時:
UIWindow:[hitTest:withEvent:]
----MainView:[hitTest:withEvent:]
--------YellowView:[hitTest:withEvent:]
--------RedView:[hitTest:withEvent:]
------------UIButton:[hitTest:withEvent:]
hit-TestView is RedView !
分析:
1. 先使用了 YellowView 的 [hitTest:withEvent:] 方法可以看出:默認的遍歷順序是按照 UIView(MainView) 中 Subviews 的逆順序。
2. 當判斷 YellowView 是不是 hit-TestView 的時候,判斷觸摸點不在 YellowView 上就不會再遍歷它的子 View(UILabel) 了。
3. 觸摸點在 RedView 上,所以會繼續遍歷它的子 View( UIButton ),觸摸點不在 UIButton 上,所以返回 nil ( UIButton 不是 hit-TestView ),所以返回它本身( 是 hit-TestView )。
點擊灰色 button 時:
UIWindow:[hitTest:withEvent:]
----MainView:[hitTest:withEvent:]
--------YellowView:[hitTest:withEvent:]
--------RedView:[hitTest:withEvent:]
------------UIButton:[hitTest:withEvent:]
----------------UIButtonLabel:[hitTest:withEvent:]
hit-TestView is UIButton !
根據上面的分析,觸摸點在 RedView 上,所以會繼續遍歷它的子 View( UIButton ),觸摸點在 UIButton 上,所以返回它本身( 是 hit-TestView )。
點擊黃色 View 時:
UIWindow:[hitTest:withEvent:]
----MainView:[hitTest:withEvent:]
--------YellowView:[hitTest:withEvent:]
------------UILabel:[hitTest:withEvent:]
hit-TestView is YellowView !
分析:
觸摸點在 YellowView 上,遍歷它的子 View( UILabel ),觸摸點不在 UILabel 上,所以返回 nil,所以 YellowView 是 hit-TestView。找到 hit-TestView 后,就不再檢查 RedView 了。
點擊 label 時:
UIWindow:[hitTest:withEvent:]
UIWindow pointInside:1
----MainView:[hitTest:withEvent:]
MainView pointInside:1
--------YellowView:[hitTest:withEvent:]
YellowView pointInside:1
------------UILabel:[hitTest:withEvent:]
hit-TestView is YellowView !
分析:
觸摸點在 YellowView 上,所以遍歷它的子 View( UILabel ),但是 UILabel 的 userInteractionEnabled = NO,所以返回 nil,這個時候其實還沒有判斷觸摸點是不是在 UILabel上。
響應鏈
找到 hit-TestView 之后,事件就交給它來處理,hit-TestView 就是 firstResponder(第一響應者),如果它無法響應事件(不處理事件),則把事件交給它的 nextResponder(下一個響應者),直到有處理事件的響應者或者結束(傳遞到 AppDelegate 為止)。這一系列的響應者和事件的傳遞方向就是響應鏈(很形象)。在響應鏈中,所有響應者的基類都是 UIResponder,也就是說所有可以響應事件的類都是 UIResponder 的子類,UIApplication/UIView/UIViewController 都是 UIResponder 的子類。
ps: View 處理事件的方式有手勢或者重寫 touchesEvent 方法或者利用系統封裝好的組件( UIControls )。
只要知道 nextResponder 是什么,就可以確定響應鏈了。
nextResponder 查找過程如下:
1. UIView 的 nextResponder 是直接管理它的 UIViewController (也就是 VC.view.nextResponder = VC ),如果當前 View 不是 ViewController 直接管理的 View,則 nextResponder 是它的 superView( view.nextResponder = view.superView )。
2. UIViewController 的 nextResponder 是它直接管理的 View 的 superView( VC.nextResponder = VC.view.superView )。
3. UIWindow 的 nextResponder 是 UIApplication 。
4. UIApplication 的 nextResponder 是 AppDelegate。
下面是測試過程中的一些日志:
點擊紅色 View 時:
------------------The Responder Chain------------------
RedView
|
MainView
|
ViewController
|
UIWindow
|
UIApplication
|
AppDelegate
------------------The Responder Chain------------------
分析:
1. RedView 不是 UIViewController 管理的 View,所以它的 nextResponder 是它的 superView( MainView )。
2. MainView 是 UIViewController 管理的 View,所以它的 nextResponder 是管理它的 ViewController。
3. ViewController 的 nextResponder 是它管理的 MainView 的superView( UIWindow )。
4. UIWindow 的 nextResponder 是 UIApplication。
5. UIApplication 的 nextResponder 是 AppDelegate。
一般來說,某個 UIResponder 的子類想要自己處理一些事件,就需要重寫它的這些方法:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
響應鏈上的某個對象處理事件之后可以選擇讓事件傳遞繼續下去或者終止,如果需要讓事件繼續傳遞下去則需要在 touchesBegan 方法里面,調用父類對應的方法:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
// Responding to Touch Events
[super touchesBegan:touches withEvent:event];
}
下面分享一個實際開發中的應用場景
場景:自定義一個這樣的 tabBar,中間有個凸起一丟丟的 item。
UI的實現:自定義一個大小和 tabBar 一樣的 View 覆蓋在 tabBar 上,然后然后中間的 item 超出自定義 View 的邊界,讓自定義的 View 的 clipsToBounds 為 NO,把超出邊界的部分也顯示出來。
分析:
根據尋找 hit-TestView 過程的原理可以知道,如果點擊超出邊界的部分(凸起的那一丟丟)是不能響應事件的。
解決過程:
1. 打印view的層級
+-UIWindow
+-UILayoutContainerView
+-UITransitionView
| +-UIViewControllerWrapperView
| +-UILayoutContainerView
| +-UINavigationTransitionView
| | +-UIViewControllerWrapperView
| | +-UIView
| +-UINavigationBar
| +-_UINavigationBarBackground
| | +-_UIBackdropView
| | | +-_UIBackdropEffectView
| | | +-UIView
| | +-UIImageView
| +-UINavigationItemView
| | +-UILabel
| +-_UINavigationBarBackIndicatorView
+-MSCustomTabBar
+-_UITabBarBackgroundView
| +-_UIBackdropView
| +-_UIBackdropEffectView
| +-UIView
+-UITabBarButton
+-UITabBarButton
+-UITabBarButton
+-UITabBarButton
+-UIImageView
+-MSTabBarView
+-UIButton
| +-UIImageView
+-MSVerticalCenterButton
| +-UIImageView
| +-UIButtonLabel
+-MSVerticalCenterButton
| +-UIImageView
| +-UIButtonLabel
+-MSVerticalCenterButton
| +-UIImageView
| +-UIButtonLabel
+-MSVerticalCenterButton
+-UIImageView
+-UIButtonLabel
分析:(有點長,不過只要看 MSCustomTabBar 那部分就可以了)
- MSTabBarView 就是自定義覆蓋在 MSCustomTabBar 上面的 View,它的子 ViewUIButton 就是中間凸起一丟丟的 item。
- 如果我們點擊了 tabBar 的內部,尋找 hit-TestView 的時候是會查詢自定義的 MSTabBarView 的,從而它的子 View 也會被查詢,所以只要觸摸點在 view 的范圍內就可以響應事件了,所以沒有任何問題。
- 如果我們點擊了凸起的那一丟丟部分,尋找 hit-TestView 的時候,查詢到 MSCustomTabBar 之后,由于觸摸點不在它的內部,所以不會查詢它的子 View( MSTabBarView ),所以凸起的那一丟丟是響應不了事件的。所以我們需要重寫 MSCustomTabBar 的 [hitTest:withEvent:] 方法。
分析 view 的層級主要是為了確定在哪里重寫 [hitTest:withEvent:] 方法。
2. 重寫 [hitTest:withEvent:] 方法,讓超出 tabBar 的那部分也能響應事件
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// 先使用默認的方法來尋找 hit-TestView
UIView *result = [super hitTest:point withEvent:event];
// 如果 result 不為 nil,說明觸摸事件發生在 tabbar 里面,直接返回就可以了
if (result) {
return result;
}
// 到這里說明觸摸事件不發生在 tabBar 里面
// 這里遍歷那些超出的部分就可以了,不過這么寫比較通用。
for (UIView *subview in self.tabBarView.subviews) {
// 把這個坐標從tabbar的坐標系轉為subview的坐標系
CGPoint subPoint = [subview convertPoint:point fromView:self];
result = [subview hitTest:subPoint withEvent:event];
// 如果事件發生在subView里就返回
if (result) {
return result;
}
}
return nil;
}
分析:
如果觸摸點在 tabBar 里面的時候,使用默認方法就可以找到 hit-TestView 了,所以先使用 [super hitTest:point withEvent:event] (因為我們是重寫方法,所以使用 super 就是使用原始的方法)來尋找,如果找不到,說明觸摸點不在 tabBar 里面,這個時候就需要我們手動的判斷觸摸點在不在超出的那一丟丟里面了。(其實只要判斷凸起的 View 就可以了,不過遍歷所有 子View 比較通用,如果有多個凸起的 view 也可以這么寫),先把坐標轉換為 子View 的坐標(這樣才能使用默認的 [pointInside:withEvent:] 方法來判斷觸摸點是否在 view 里面),然后遍歷 子View 調用默認的 [hitTest:withEvent:] 方法,如果觸摸點在 view 的內部,就能找到 hit-TestView,如果遍歷完所有 子View 都沒有找到 hit-TestView 說明觸摸點也不在凸起的那一丟丟里面,然后返回 nil 就可以了。
分享一個demo
非矩形區域的點擊:比如一個圓角為寬度一半的Button,只有點擊圓形區域才會響應事件。
分析:
因為觸摸點在 View 內,想要限制 view 內的點擊區域,所以重寫 button 的 [pointInside:withEvent:] 這個方法。如下:
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
// 圓形區域的半徑
CGFloat maxRadius = CGRectGetWidth(self.frame)/2;
// 觸摸點相對圓心的坐標
CGFloat xOffset = point.x - maxRadius;
CGFloat yOffset = point.y - maxRadius;
// 觸摸點的半徑
CGFloat radius = sqrt(xOffset * xOffset + yOffset * yOffset);
return radius <= maxRadius;
}
demo 比較簡單,稍微動手一下就可以掌握了。