超出父视图bouds的控件响应事件

2,423 阅读5分钟

最近遇到一个问题,一个可以移动的视图上展示一个气泡,气泡超出了视图的边界,为了能响应事件,我将气泡添加到了移动的视图的父视图上,以保证气泡可以响应事件。这样的话,视图移动之后需要重新定位气泡,因为,气泡不会随视图移动,他是添加在移动的视图的父视图上的,这样的话,每次视图移动之后,需要重新计算气泡的位置,以保持移动的视图与气泡的相对位置不变。初步解决了这个问题,但是,有技术负债,视图移动的时候,气泡需要先移除,再重新添加,显示效果达不到产品的要求。

如此,终于,还是需要解决这个技术负债,以保持视图移动时气泡不会消失,气泡随视图同步移动。我将代码修改为,将气泡添加到视图上,这样,视图移动时,子视图气泡会随父视图一起移动,但是,新的问题来了:超出父视图边界的视图bounds的气泡,不响应点击。

查阅资料后,明白了问题的关键。问题在于,UITouchEvent的事件派发时,事件不会派发给超出bounds的子视图。那么,为什么呢?当UIKit在派发UITouchEvent的时候,首先会调视图的-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法,判定事件是否需要派发给该视图,如果接受该消息的视图判定失败,则事件不会派发给该视图。在这个方法中,首先会判定视图本身是否能够接受事件,然后再查找其子视图,判定的标准是,调用-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event方法,就是看UITouchEvent的点,是否在视图内!这就解释了,为什么超出视图bounds的子视图无法接受事件派发,因为,当hitTest在父视图调用时,已经判定失败,所以,也就不会调用子视图hitTest,事件是无法派发到超出父视图bounds的子视图的。

了解到以上原因之后,我选择了,复写移动视图的-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event方法,在调用超类实现返回nil的时候,遍历视图的子视图,超出视图bounds的子视图,使其能够响应事件。代码如下所示:

-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    UIView* view = [super hitTest:point withEvent:event];
    if (!view) {
        for (UIView *subView in self.subviews) {
            CGPoint tp = [subView convertPoint:point fromView:self];
            if (CGRectContainsPoint(subView.bounds, tp)) {
                view = subView;
                break;
            }
        }
    }
    return view;
}

但是,事情并不像我想象的顺利,气泡仍然无法点击。经过调试,我发现返回的subView是气泡最外层的视图!事件直接派发给了气泡视图,但是,气泡的子视图并没有返回,此处重点:hitTest必须递归调用,以找到最终获得事件的视图。如果,最终视图不响应(不处理事件),再由响应链将touch 事件向父视图传递!所以,我们不能直接返回subView,我需要在subView上调用hitTest,以找到最终接受派发的视图!将代码修改为如下所示:

-(UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    UIView* view = [super hitTest:point withEvent:event];
    if (!view) {
        for (UIView *subView in self.subviews) {
            CGPoint tp = [subView convertPoint:point fromView:self];
            if (CGRectContainsPoint(subView.bounds, tp)) {
                view = [subView hitTest:tp withEvent:event];
                break;
            }
        }
    }
    return view;
}

这样,超出父视图边界的气泡就能正常的工作了。问题解决了。

其实,我将问题复杂化了,我其实并不用去修改视图的hitTest方法,因为,导致问题的原因在于,hitTest在调用-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event的时候,返回了否,如点不在父视图内,那么,也就不会去hitTest其子视图,如果此方法能返回YES,视图就会自动去调用子视图的hitTest方法,就能一切正常,于是,我尝试另一种方式:重写-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event方法,在默认实现返回NO的时候,去测试点是否在可能的子视图范围内!代码如下所示:

-(BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
    BOOL result = CGRectContainsPoint(self.bounds, point);
    if (result) {
        return result;
    }
    for (UIView* v in self.subviews) {
        CGPoint localPoint = [v convertPoint:point fromView:self];
        result = CGRectContainsPoint(v.bounds, localPoint);
        if (result) {
            return result;
        }
    }
    return NO;
}

如预期所想,能够实现想要的效果。

综上两种方式,说下后者,因为复写pointInside方法,相当于扩大了视图本身的范围,超出视图的子视图bounds也相当于归为父视图的范围,所有调用此方法来判断点的存在性都会受到影响。这也就是为什么改写pointInside,会让事件派发恢复,就是因为hitTest会调用pointInside方法来判定,点是否在父视图,让本来会被阻断的hitTest调用,到达了超出父视图边界的气泡视图的原因。

然后,关于hitTest方法需要注意的一些细节,仅以我知道的列出,以后会完善:

  1. hitTest会调用pointInside,默认会判定点是否在视图的bounds边界内,如果不在,会直接返回空,对子视图的查找被阻断;
  2. hitTest查找视图时,从subviews数组的最后一个元素(也就是最上层的子视图)开始,一旦找到,向其它兄弟子视图的查找就会停止。(所以,这种有超出父视图bounds边界子视图的视图,应该添加在最上层,让hitTest查找能够传递到其父视图,如果在其父视图的兄弟视图找到了接受事件的视图,那么事件也无法派发过来,不过,如果先找到的是其父视图的兄弟视图,那么,超出边界的子视图也一定是被父视图的兄弟视图覆盖的,所以,一般不会出现这种错误,但要注意透明视图的时候。总之一点,让其父视图要被hitTest到达);
  3. hitTest查找的视图如果没有子视图,并且点在视图bounds范围内,那么会返回自己;
  4. hitTest的point参数,一定要转到hitTest视图的本地坐标系。(这个很重要,如果在复写hitTest方法,对其子视图调用hitTest方法时,点没有转换到被测试子视图的本地坐标系,将发生错误,别问我咋知道的);
  5. hitTest调用是递归的,会一直递归查找到接收事件的视图。