最近又看了遍蘋果的官方文檔《Event Handling Guide for iOS》,對事件響應鏈中的hit-test view 又多了些理解,個人覺的官方文檔對這塊講的非常簡單,很多東西都是點到為止,hit-test view的知識在項目的任何地方都用到了,但自己反而感知不到,接下來我會給大家講hit-test view的原理,以及項目中能解決痛點的三個應用 。
什么叫 hit-test view?文檔說:The lowest view in the view hierarchy that contains the touch point becomes the hit-test view,我的理解是:當你點擊了屏幕上的某個view,這個動作由硬件層傳導到操作系統,然后又從底層封裝成一個事件(Event)順著view的層級往上傳導,一直要找到含有這個點擊點且層級最高(文檔說是最低,我理解是視圖樹的根節點,或者最靠近你的手指的view)的view來響應事件,這個view就是hit-test view。
文檔中說,決定誰hit-test view是通過不斷遞歸調用view中的 - (UIView *)hitTest: withEvent: 方法和 -(BOOL)pointInside: withEvent: 方法來實現的,文段中的這段話太好理解,于是我仿照官方文檔中這張圖做了個Demo -> Github地址
重載圖中view的方法添加相應的log便于觀察:
//in every view .m overide those methods
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
NSLog(@"進入A_View---hitTest withEvent ---");
UIView * view = [super hitTest:point withEvent:event];
NSLog(@"離開A_View--- hitTest withEvent ---hitTestView:%@",view);
return view;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event {
NSLog(@"A_view--- pointInside withEvent ---");
BOOL isInside = [super pointInside:point withEvent:event];
NSLog(@"A_view--- pointInside withEvent --- isInside:%d",isInside);
return isInside;
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
NSLog(@"A_touchesBegan");
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
NSLog(@"A_touchesMoved");
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event {
NSLog(@"A_touchesEnded");
}
點擊圖中View_D,看下會發生什么
一是發現touchesBegan、touchesMoved、touchesEnded這些方法都是發生在找到hit-test view之后,因為touch事件是針對能響應事件的確定的某個view,比如你手指劃出了scrollview的范圍,只要你不松手繼續滑動,scrollview依然會響應滑動事件繼續滾動;二是尋找hit-test view的事件鏈傳導了兩遍,而且兩次的調用堆棧是不同的,這點我有點搞不懂,為啥需要兩遍,查閱了很多資料也不知道原因,發現真機和模擬器以及不同的系統版本之間還會有些區別(此為真機iOS9),大家可以下載我的Demo進行測試與研究。
把這個尋找的邏輯換成代碼如下:
- (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;
}
如果有某個view的兩個子view位置重疊,根據View Programming Guide for iOS文檔中說的 Visually, the content of a subview obscures all or part of the content of its parent view. If the subview is totally opaque, then the area occupied by the subview completely obscures the corresponding area of the parent. If the subview is partially transparent, the content from the two views is blended together prior to being displayed on the screen. Each superview stores its subviews in an ordered array and the order in that array also affects the visibility of each subview. If two sibling subviews overlap each other, the one that was added last (or was moved to the end of the subview array) appears on top of the other. 那最高層(邏輯最靠近手指的)view是view subviews數組的最后一個元素,只要尋找是從數組的第一個元素開始遍歷,hit-test view的邏輯依然是有效的。
找到hit-test view后,它會有最高的優先權去響應逐級傳遞上來的Event,如它不能響應就會傳遞給它的superview,依此類推,一直傳遞到UIApplication都無響應者,這個Event就會被系統丟棄了。
Hit-test view的應用舉例:
1、擴大UIButton的響應熱區
相信大家都遇到小圖button點擊熱區太小問題,之前我是用UIButton的setImage方法來設置圖片解決,但是調起坐標就坑了,得各種計算不說,寫出的代碼還很難看不便于維護,如果我們用用hit-test view的知識你就能輕松地解決這個問題。
重載UIButton的-(BOOL)pointInside: withEvent:方法,讓Point即使落在Button的Frame外圍也返回YES。
//in custom button .m
//overide this method
- (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event {
return CGRectContainsPoint(HitTestingBounds(self.bounds, self.minimumHitTestWidth, self.minimumHitTestHeight), point);
}
CGRect HitTestingBounds(CGRect bounds, CGFloat minimumHitTestWidth, CGFloat minimumHitTestHeight) {
CGRect hitTestingBounds = bounds;
if (minimumHitTestWidth > bounds.size.width) {
hitTestingBounds.size.width = minimumHitTestWidth;
hitTestingBounds.origin.x -= (hitTestingBounds.size.width - bounds.size.width)/2;
}
if (minimumHitTestHeight > bounds.size.height) {
hitTestingBounds.size.height = minimumHitTestHeight;
hitTestingBounds.origin.y -= (hitTestingBounds.size.height - bounds.size.height)/2;
}
return hitTestingBounds;
}
2、子view超出了父view的bounds響應事件
項目中常常遇到button已經超出了父view的范圍但仍需可點擊的情況,比如自定義Tabbar中間的大按鈕,如下圖閑魚的app,點擊超出Tabbar bounds的區域也需要響應,此時重載父view的-(UIView *)hitTest: withEvent:方法,去掉點擊必須在父view內的判斷,然后子view就能成為 hit-test view用于響應事件了。
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
/**
* 此注釋掉的方法用來判斷點擊是否在父View Bounds內,
* 如果不在父view內,就會直接不會去其子View中尋找HitTestView,return 返回
*/
// 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;
}
3、ScrollView page滑動
這是app store 應用的app封面預覽功能
上圖的交互常常見于很多海報、封面展示的app,實現這個交互的方法有很多,但選擇用scrollView來橫向滑動來做是最簡單的,讓scrollview.pageEnabel = YES,就有了翻頁的感覺,但這樣scoreView的實際可滑動區域就只有一張照片那么寬,如果想讓邊側留出的距離(藍色框部分)響應滑動事件的話應該怎么辦呢?這個時候又可以用到hit-test view的知識了,在scrollview的父view中把藍色部分的事件都傳遞給scrollView就可以了,具體看下面代碼:
//in scrollView.superView .m
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitTestView = [super hitTest:point withEvent:event];
if (hitTestView) {
hitTestView = self.scrollView;
}
return hitTestView;
}
總結
事件響應鏈是UI層一個非常重要的概念,想做出非常棒的交互和動畫,必須對其有一個深入的理解。我列舉的只是我在開發中遇到的一些問題,如果有其他的對事件響應鏈的應用希望大家和我一起交流探討。