只为此心无垠

A blog for Jekyll

凤凰花开开两季,之余青春止于青春


Download the theme

Hit-testing in iOS

Hit-testing是一个确定point(如触摸点)是否在绘制在屏幕上的既定图形对象(如UIView)的过程。IOS使用Hit-testing,以确定哪些UIView是用户的手指下最顶层的视图,即应该接收触摸事件。通过使用前序深度优先遍历算法搜索视图层次,来实现Hit-testing

在解释Hit-testing的工作原理之前,重要的是要了解何时执行Hit-testing。下图说明了单次触摸的流程,从手指触摸屏幕直到手指离开屏幕为止:

触摸事件流

如上图所示,每当手指触摸屏幕时都会执行Hit-testing。而且,是在任何视图或手势识别器接收到了代表该触摸所属事件的UIEvent对象之前。

注意:由于未知的原因,Hit-testing连续执行多次。然而,确定的Hit-testing视图保持相同。

Hit-testing完成和触摸点在最顶层的视图被确定后,在触摸时间序列的所有阶段(即:开始,移动,结束,或者取消)中,hit-test view都与UITouch对象关联。除了hit-test view,该视图或其根视图关联的手势识别也与UITouch对象关联。然后,hit-test view开始接收触摸事件的序列。

重要的是,即使手指移动到hit-test view界限之外,到了另一视图,hit-test view仍然继续接收所有触摸,直到触摸事件序列的结束:

“触摸对象是一直与hit-test view相关联,即使之后触摸移动视图外。”  Event Handling Guide for iOS, iOS Developer Library

如前所述,Hit-testing使用前序深度优先遍历(首先访问根节点,然后从较高到较低的索引遍历其子树)。这种遍历允许减少遍历迭代的次数,并且一旦找到包含触摸点的第一最深后代视图,停止搜索处理。这是可能的,因为子视图总是呈现在其超级视图之前,并且同级视图总是呈现在其同级视图的前面,并且在子视图数组中具有较低的索引。这样,当多个重叠视图包含特定点时,最右侧子树中的最深视图将是最前面的视图。

“在视觉上,子视图的内容掩盖了其父视图的全部或部分内容。每个超级视图将其子视图存储在有序数组中,该数组中的顺序也会影响每个子视图的可见性。如果两个兄弟子视图相互重叠,那么最后添加的子视图出现在另一个的顶部 视图编程指南适用于iOS,iOS开发库

下图显示了在屏幕上绘制的视图层次结构树及其匹配UI的示例。从左到右的树枝排列反映了subviews数组的顺序。

查看层次结构树

可以看出,“View A”和“View B”以及它们的子视图“View A.2”和“View B.1”是重叠的。但由于“View B”的子视图索引高于“View A”,“View B”及其子视图呈现在“View A”及其子视图上方。因此,当用户的手指在与“View A.2”重叠的区域中触摸“View B.1”时,应当通过Hit-testing返回“View B.1”。

通过采用前序深度优先遍历,允许在找到包含触摸点的最深视图时,停止遍历:

查看层次结构深度优先遍历

遍历算法开始通过发送hitTest:withEvent:消息到UIWindow,这是视图层级的根视图。此方法返回的值。是包含触摸点的最前面的视图。

下面的流程图说明了Hit-testing逻辑。

下面的代码展示原生hitTest:withEvent:方法的可能实现方式:

- (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:方法首先检查视图允许接收的触摸。以下是允许视图接收触摸的情况:

  • 视图未隐藏: self.hidden == NO
  • 该视图已启用用户交互: self.userInteractionEnabled == YES
  • 视图的alpha级别大于0.01: self.alpha > 0.01
  • 视图包含point: pointInside:withEvent: == YES

然后,如果该视图允许接收触摸,这种方法向每个子视图从最后一个到第一个发送hitTest:withEvent:消息,来遍历接收者的子数,直至它们中一个返回非nil值。由其中一个子视图返回的第一个非零值是接触点下的最前面的视图,并由接收器返回。如果所有接收器子视图返回nil或接收器没有子视图,接收器返回自身。

另外,如果视图不允许接收触摸,此方法返回nil,而不会遍历子树。因此,Hit-testing过程可能不会访问视图层次结构中的所有视图。

重写 hitTest:withEvent:的常见应用场景

hitTest:withEvent:可以被重写,当想要触摸处理被重定向另一个视图,在触摸时间序列的所有阶段时。

因为执行Hit-testing,只有在触摸事件序列的第一触摸事件被发送到它的接收器(与触摸UITouchPhaseBegan阶段)之前,并重写hitTest:withEvent:,可以将重定向事件将重定向序列的所有触摸事件。

增加视图的触摸区域

这可以证明重写一个用例hitTest:withEvent:的方法是,一个视图的触摸面积应大于它的边界大。例如,下图显示了一个UIView20×20的有大小。此大小可能太小,无法处理附近的触摸。因此,它的触摸区域可在每个方向通过重写hitTest:withEvent:的方法增加10点:

增加触摸面积

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    CGRect touchRect = CGRectInset(self.bounds, -10, -10);
    if (CGRectContainsPoint(touchRect, point)) {
        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进行hit-testing,父视图的边界应包含其子视图所需的触摸区域,或hitTest:withEvent:方法应该是被重写,包括所需的触摸区域。

将触摸事件传递到下面的视图

有时,视图需要忽略触摸事件,并将其传递到下面的视图。例如,假设在应用程序视图上方,放置透明覆盖视图。叠加层有一些控件和按钮,作为子视图,它们应该正常响应触摸。但是触摸叠加层放入其他地方,应该将触摸事件传递到叠加层下方的视图。为了实现这一行为,重写hitTest:withEvent:方法,以返回其包含的接触点一个子视图,并在所有其他情况下返回nil,包括当覆盖层包含接触点情况下:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView == self) {
        hitTestView = nil;
    }
    return hitTestView;
}

将触摸事件传递到子视图

不同的场景,可能需要父视图将所有触摸事件重定向到其唯一的子视图。当子视图只占据其父视图一部分,但是应当响应于其父视图中发生的所有触摸时,可能需要这种行为。例如,假设由父视图和UIScrollView组成的图像轮播,其中pagingEnabled设置为YESclipsToBounds设置为NO创建创建效果:

将触摸事件传递到子视图

为了使UIScrollView响应发生内部自身的边界和它的父视图的边界内的触摸,hitTest:withEvent:方法需要被重写:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView) {
        hitTestView = self.scrollView;
    }
    return hitTestView;
}
最近的文章

Event Delivery The Responder Chain

当你设计应用时,你可能希望动态地响应事件。例如,触摸可能发生在屏幕上的很多不同对象上,你必须决定你希望哪一个对象来响应给定的事件,理解对象是如何接收事件的。当用户产生的事件发生时,UIKit创建一个封装了所有需要处理该事件信息的事件对象。然后将该事件对象放在 active app’s (Application object)事件队列中。对于触摸事件来说,该对象是封装在 UIEvent 对象中的一组touch。对于运动事件来说,事件对象不一样,取决于你使用哪种框架,以及哪种类型的运动事件。一...…

Event Handling Guide for iOS(三)继续阅读
更早的文章

View Controller Catalog for iOS(四)-Split View Controllers

分屏控制器UISplitViewController类是管理两个窗格信息的容器视图控制器。第一个窗格具有320点的固定宽度,以及与visible window相等的高度。第二个窗格填充剩余的空间。 图4-1显示了一个分屏控制器界面。图4-1   分屏视图界面split view界面的窗格包含视图控制器管理的内容。由于该窗格包含应用程序的具体内容,所以由你来管理这两个视图控制器之间的相互作用。然而,旋转和其它系统相关的行为由split view controller本身管理。split vi...…

View Controller Catalog for iOS继续阅读