Cocoa

众里寻她千百度

起因

最近项目中遇到一个需求:如何将事件由一个A view传递到下面的B view上。常规的做法当然是设置A view的isUserInteractionEnabled的属性,但这样做会有一个缺点,即A view本身的子view也无法接受事件了,毕竟很多情况下A view上是会有一些button等需要接受点击事件的。

尝试

遇到问题首先想到肯定是 google,毕竟前人踩过的坑,总会在google上留下些痕迹。经过goole搜索后,发现UIView本身的这两个API非常符合条件。

1
2
open func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? // recursively calls -pointInside:withEvent:. point is in the receiver's coordinate system
open func point(inside point: CGPoint, with event: UIEvent?) -> Bool // default returns YES if point is in bounds

我对于点击事件的理解是

  • 当用户点击屏幕时,会产生一个触摸事件,系统会将该事件加入到一个由UIApplication管理的事件队列中
  • UIApplication会从事件队列中取出最前面的事件进行分发以便处理,通常,先发送事件给应用程序的主窗口(UIWindow)
  • 主窗口会调用hitTest:withEvent:方法在视图(UIView)层次结构中找到一个最合适的UIView来处理触摸事件

我理解的hitTest:withEvent:方法正常的处理流程大致是这样的:

  • 首先调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内:
  • 若pointInside:withEvent:方法返回NO,说明触摸点不在当前视图内,则当前视图的hitTest:withEvent:返回nil
  • 若pointInside:withEvent:方法返回YES,说明触摸点在当前视图内,则遍历当前视图的所有子视图(subviews),调用子视图的hitTest:withEvent:方法重复前面的步骤,子视图的遍历顺序是从top到bottom,即从subviews数组的末尾向前遍历,直到有子视图的hitTest:withEvent:方法返回非空对象或者全部子视图遍历完毕:
  • 若第一次有子视图的hitTest:withEvent:方法返回非空对象,则当前视图的hitTest:withEvent:方法就返回此对象,处理结束
  • 若所有子视图的hitTest:withEvent:方法都返回nil,则当前视图的hitTest:withEvent:方法返回当前视图自身(self)
  • 最终,这个触摸事件交给主窗口的hitTest:withEvent:方法返回的视图对象去处理

正常流程是没办法满足我们的特殊的需求的那么非正常流程会是什么样的呢?既然hitTest最终返回的对象会响应该事件,我们是否可以手动的更改响应链来满足我们之前的需求呢?基于这些问题,做了如下的一个demo。demo的结构如图所示:
Structure.png

yellobutton是greenview的subview,greenview和maskbutton同级都是hitTestView的subview,而backgroundButton和hittestView同级都在viewController.view上。每个button被点击时都会打出对应的log。

1
2
3
4
5
6
7
8
9
@IBAction func maskButtonClick(_ sender: Any) {
print("Mask Button Click")
}
@IBAction func yellowButtonClick(_ sender: Any) {
print("Yellow Button Click")
}
@IBAction func backgroundButtonClick(_ sender: Any) {
print("Background Button Click")
}

现在我在hitTestView中重写hitTest的方法

1
2
3
4
5
6
7
8
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == greenView {
return nil
}
return view
}

这个时候我们点击greenview会得到什么样的log呢?答案是

1
Background Button Click //只有这一个log

由此我们可以得到一个结论,每一个view的hitTestView被点击时只会触发一次,假如我们的触摸点在某一个view上,而这个view恰好被我们过滤掉,那么触摸事件会直接传递到与其同级的view上。假如我们现在有一个需求需要我们的触摸事件会响应在maskbutton上,而不响应greenview上我们又该怎么做呢?肯定不能如上面的hitTest判断是否是greenView,而返回nil,这样也会屏蔽掉maskbutton的响应。当然有多种实现方法,我这里来提供一种我自己的实现方法

1
2
3
4
5
6
7
8
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == greenView {
return maskButton
}
return view
}

依旧是重写hitTest方法,判断view是否是greenview,如果是则返回maskbutton