轻希
轻希
Posts List
  1. 问题背景
  2. 第一反应
  3. Hit-Testing in iOS
  4. 解决方案

iOS中的Hit-Testing

问题背景

美团iOS客户端电影频道中,影院列表的界面如下所示:

影院列表Cell

每个影院Cell中的放映场次部分,也就是图中红色矩形框标记的部分,都是用UICollectionView完成的,之前代码将UICollectionView的宽度固定,所以导致点击空白区域的时候,实际上也是点击到了UICollectionView上,继而使得用户点击无响应,影响了用户体验。

所以现在需要对其进行改进,使得用户点击放映场次旁边的空白区域的时候,跟点击影院Cell的响应一样,都进入下级页面,而不是无响应。

第一反应

为了解决上面的问题,自己首先想到的是将UICollectionView的宽度不固定,而是根据内容自动计算宽度,这样UICollectionView就不会遮挡住影院Cell,点击空白区域也就是直接点击影院Cell了,但是这样有个问题,就是虽然右边空白区域的问题解决了,但是放映场次之间的间隙仍然会点击无响应,某种程度上来说,还是有瑕疵。

第二个想法是,利用UICollectionView的backgroundView做文章,我们可以实例化一个backgroundView,然后为它添加一个tap手势的监听,那么当用户点击UICollectionView的空白区域时,就可以捕获到该事件,从而做出相应的响应,代码如下:

1
2
self.collectionView.backgroundView = [[UIView alloc] init];
[self.collectionView addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleBackgroundTap)]];

这样我们就能在handleBackgroundTap中处理点击事件,然而问题来了,这里的collectionView是影院Cell的一个属性,那么如何在handleBackgroundTap中调用- tableView:didSelectRowAtIndexPath:方法呢,想了一会儿,感觉没有特别简洁的方法,所以就放弃了这种思路。

Hit-Testing in iOS

上述两种思路都毙掉后,看了一下Apple关于Hit-Testing的文档,了解了一下iOS中触摸事件的原理和流程,发现可以通过覆写-hitTest:withEvent:方法来达到目的,这里先简单说一下Hit-Testing。

iOS用hit-testing机制来找到用来响应一个触摸事件的view。Hit-testing的主要工作就是检查某个触摸事件发生的位置是否在相关联的某个view边界内。如果是,它就会递归检查该view的subviews。而整个view树中,最底层的一个包含触摸位置的view将会成为hit-test view。在iOS找到来hit-test view后,就将触摸事件交给该view去处理。

Hit-testing returns the subview that was touched

为了解释一下hit-testing机制,以上图为例具体说明一下,假设用户点击了View E区域,那么iOS通过以下顺序来检查subviews以找到hit-test view:

  1. 触摸位置在视图A的边界内,所以它会继续检查自视图B和C。
  2. 触摸位置不在视图B的边界内,但是在视图C的边界内,所以继续检查自视图D和E。
  3. 触摸位置不在视图D的边界内,但是在视图E的边界内。

视图E是视图层级体系中包含触摸位置的最底层视图,所以它就成为了hit-test view。
当然,这里还有一种普遍情况就是,假如视图D和视图E重叠了,而用户恰好点击了重叠区域,那么这个时候,hit-test view将会是subview索引中的较大者,也就是说,如果是先addSubview D,再addSubview E,那么就是E,反之则是D。

下面是hit-testing的流程图:

hit-test-flowchart

这个图清楚的解释了-hitTest:withEvent:的具体实现,相应的代码实现则是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (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是否允许接收触摸事件,一个view允许接收触摸事件的条件是:

  • view为被隐藏:self.hidden == NO
  • view允许交互:self.userInteractionEnabled == YES
  • view透明度大于0.01:self.alpha > 0.01
  • 触摸位置在view边界内:pointInside:withEvent: == YES

如果一个view允许接收触摸事件,这个方法就会通过按逆序顺序向subviews发送hitTest:withEvent:消息的方式遍历,直到返回一个非空值。如果所有subviews返回空或者没有subview,则返回自身。

否则,如果一个视图不允许接收触摸事件,这个方法直接放回nil,并且不再遍历视图层级上的自视图。

解决方案

在了解了iOS的hit-testing机制后,再回到开头提到的问题,可以发现,这里我们可以直接通过重写UICollectionView的hitTest:withEvent:方法,改变它接收触摸事件的默认处理方式即可。也就是说,当hit-test检查到UICollectionView的时候,加以判断,如果点击的是放映场次Cell,那么直接返回该Cell,如果点击的是空白区域,那么我们就不返回UICollectionView,而是直接返回nil,这样,最底层的响应者就应该是影院Cell,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@interface MyUICollectionView : UICollectionView
@end
@implementation MyUICollectionView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
return nil;
}
for (UIView *subview in self.subviews.reverseObjectEnumerator) {
CGPoint subPoint = [subview convertPoint:point fromView:self];
UIView *result = [subview hitTest:subPoint withEvent:event];
if (result) {
return result;
}
}
return nil;
}
@end

这样能完全达到我们的目的,但是看上去有点别扭,因为我们新建了一个MyUICollectionView类,显得非常突兀,那么,换个思路,既然我们能在UICollectionView中重写hitTest:withEvent:方法,那么作为collectionView的superview,我们能不能直接在影院Cell中重写该方法呢,答案是肯定的。在影院Cell中,我们可以取得superclass UIView的hitTest:withEvent:方法的返回结果,进行中途拦截,判断返回结果是否为collectionView,如果是,我们返回影院Cell自身,如果不是,原样返回就行:

1
2
3
4
5
6
7
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *view = [super hitTest:point withEvent:event];
if (view == self.collectionView) {
return self;
}
return view;
}

现在看来,这才是最简单有效的方法。

参考资料: