问题背景
美团iOS客户端电影频道中,影院列表的界面如下所示:
每个影院Cell中的放映场次部分,也就是图中红色矩形框标记的部分,都是用UICollectionView完成的,之前代码将UICollectionView的宽度固定,所以导致点击空白区域的时候,实际上也是点击到了UICollectionView上,继而使得用户点击无响应,影响了用户体验。
所以现在需要对其进行改进,使得用户点击放映场次旁边的空白区域的时候,跟点击影院Cell的响应一样,都进入下级页面,而不是无响应。
第一反应
为了解决上面的问题,自己首先想到的是将UICollectionView的宽度不固定,而是根据内容自动计算宽度,这样UICollectionView就不会遮挡住影院Cell,点击空白区域也就是直接点击影院Cell了,但是这样有个问题,就是虽然右边空白区域的问题解决了,但是放映场次之间的间隙仍然会点击无响应,某种程度上来说,还是有瑕疵。
第二个想法是,利用UICollectionView的backgroundView做文章,我们可以实例化一个backgroundView,然后为它添加一个tap手势的监听,那么当用户点击UICollectionView的空白区域时,就可以捕获到该事件,从而做出相应的响应,代码如下:
|
|
这样我们就能在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机制,以上图为例具体说明一下,假设用户点击了View E区域,那么iOS通过以下顺序来检查subviews以找到hit-test view:
- 触摸位置在视图A的边界内,所以它会继续检查自视图B和C。
- 触摸位置不在视图B的边界内,但是在视图C的边界内,所以继续检查自视图D和E。
- 触摸位置不在视图D的边界内,但是在视图E的边界内。
视图E是视图层级体系中包含触摸位置的最底层视图,所以它就成为了hit-test view。
当然,这里还有一种普遍情况就是,假如视图D和视图E重叠了,而用户恰好点击了重叠区域,那么这个时候,hit-test view将会是subview索引中的较大者,也就是说,如果是先addSubview D,再addSubview E,那么就是E,反之则是D。
下面是hit-testing的流程图:
这个图清楚的解释了-hitTest:withEvent:
的具体实现,相应的代码实现则是:
|
|
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,代码如下:
|
|
这样能完全达到我们的目的,但是看上去有点别扭,因为我们新建了一个MyUICollectionView
类,显得非常突兀,那么,换个思路,既然我们能在UICollectionView中重写hitTest:withEvent:
方法,那么作为collectionView的superview,我们能不能直接在影院Cell中重写该方法呢,答案是肯定的。在影院Cell中,我们可以取得superclass UIView的hitTest:withEvent:
方法的返回结果,进行中途拦截,判断返回结果是否为collectionView,如果是,我们返回影院Cell自身,如果不是,原样返回就行:
|
|
现在看来,这才是最简单有效的方法。
参考资料: