Hacking Hit Tests

做者:Soroush Khanlou,原文连接,原文日期:2018-09-07 译者:Nemocdz;校对:Yousanflicspmst;定稿:Forelaxios

回想 Crusty 教咱们使用面向协议编程以前的日子,咱们大多使用继承来共享代码的实现。一般在 UIKit 编程中,你可能会用 UIView 的子类去添加一些子视图,重写 -layoutSubviews,而后重复这些工做。也许你还会重写 -drawRect。但当你须要作一些特别的事情时,就须要看看 UIView 中其余能够被重写的方法。git

UIKit 有个十分古怪的地方,那是它的触摸事件处理系统。它主要包括两个方法,-pointInstide:withEvent:-hitTest:withEvent:github

-pointInside: 会告诉调用者给定点是否包含在指定的视图区域中。而 -hitTest:pointInside: 这个方法来告诉调用者哪一个子视图(若是有的话)是当前触摸在给定点的接收者。如今我比较感兴趣的是后面这个方法。算法

苹果的文档勉强可以让你理解怎么从新实现这个方法。在你学会怎么从新实现方法以前,你都不能改变它的功能。接下来让咱们看一遍 文档,并尝试重写这个函数。编程

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
	// ...
}
复制代码

首先,让咱们从文档的第二段开始吧:swift

这个方法会忽略那些隐藏的视图,禁用用户交互视图和 alpha 等级小于 0.01 的视图。数组

让咱们经过一些 gurad 语句来快速预处理这些前提条件。app

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

	guard isUserInteractionEnabled else { return nil }
	
	guard !isHidden else { return nil }
	
	guard alpha >= 0.01 else { return nil }
			
	// ...
复制代码

至关简单吧。那接下来是?iview

这个方法调用 pointInside:withEvent: 方法来遍历接收视图层级中每个子视图,来决定哪一个子视图来接收该触摸事件。ide

逐字阅读文档后,感受 -pointInside: 会在每个子视图里被调用(用一个 for 循环),但这并非彻底正确的。

感谢这个 读者。经过他在 -hitTest:-pointInside: 中放置了断点的试验,咱们知道 -pointInside: 会在 self 中调用(在有上面那些 guard 的状况下),而不是在每个子视图中。 因此应该添加另外的 guard 语句,像下面这行代码同样:

guard self.point(inside: point, with: event) else { return nil }
复制代码

-pointInside:UIView 另外一个须要重写的方法。它的默认实现会检查传入的某个点是否包含在视图的 bounds 中。若是调用 -pointInside 返回 true,那么意味着触摸事件发生在它的 bounds 中。

理解完这个小小的差异后,咱们能够继续阅读文档了:

若是 -pointInside:withEvnet: 返回 YES,那么子视图的层级也会进行相似的遍历直到找到包含指定点的最前面的视图。

因此,从这里知道咱们须要遍历视图树。这意味着循环遍历全部的视图,并调用 -hitTest: 在它们每个上去找到合适的子视图。在这种状况下,这个方法是递归的。

为了遍历视图层级,咱们须要一个循环。然而,这个方法其中一个更反人类的是须要反向遍历视图。子视图数组中尾部的视图反而会处在 Z 轴中更高的位置,因此它们应该被最早检验。(若是没有这篇 文章,我可记不起这个点。)

// ...
for subview in subviews.reversed() {

}
// ...
复制代码

传入的坐标点会转换到当前视图的坐标系中,而非咱们关心子视图中。幸运的是,UIKit 给了一个处理函数,去转换坐标点的参考系到其余任何的视图的 frame 的参考系中。

// ...
for subview in subviews.reversed() {
	let convertedPoint = subview.convert(point, from: self)
	// ...
}
// ...
复制代码

一旦有了转换后的坐标点,咱们就能够很简单地询问每个子视图该点的目标视图。须要注意的是,若是点处于该视图外部(也就是说,-pointInside: 返回 false),-hitTest 会返回 nil。这时就应该检查层级里的下一个子视图。

// ...
let convertedPoint = subview.convert(point, from: self)
if let candidate = subview.hitTest(convertedPoint, with: event) {
	return candidate
}
//...
复制代码

一旦咱们有了合适的循环语句,最后一件须要作的事是 return self。若是视图是可被点击(被咱们的 guard 语句断言过的状况),但却没有子视图想要处理这个触摸的话,意味着当前视图,也就是 self,是这个触摸正确的目标。

这是完整的算法:

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
	
	guard isUserInteractionEnabled else { return nil }
	
	guard !isHidden else { return nil }
	
	guard alpha >= 0.01 else { return nil }
	
	guard self.point(inside: point, with: event) else { return nil }	
	
	for subview in subviews.reversed() {
		let convertedPoint = subview.convert(point, from: self)
		if let candidate = subview.hitTest(convertedPoint, with: event) {
			return candidate
		}
	}
	return self
}
复制代码

如今咱们有了一个参考的实现,能够开始修改它来实现具体的行为。

在以前的这篇播客《Changing the size of a paging scroll view》中,我就已经讨论过其中一种行为。我谈到一种“落后并该被废弃”的方法来产生这种效果。本质上,你必须:

  1. 关掉 clipsToBounds
  2. 在滑动区域中放一个非隐藏视图
  3. 在非隐藏视图上重写 -hitTest: 来传递全部触摸到 scrollview 中

-hitTest: 方法是这种技术的基石。由于在 UIKit 中,hitTest 方法会代理给每个视图去实现,决定触摸事件传递给哪一个视图接收。这可让你去重写默认的实现(指望和普通的实现)并替换它为你想作的,甚至返回一个不是原始视图的子视图。多么疯狂。

让咱们看一下另外一个例子。若是你已经用过 Beacon 今年的版本,你会注意到滑动删除事件行为的物理效果感受上和其余用原生系统实现的效果有点不同。这是由于用系统的途径不能彻底得到咱们想要的表现,因此须要本身从新实现这个功能。

如你所想,重写滑动和反弹物理效果不须要那么复杂,因此咱们用一个 UIScrollView 和将 pagingEnabled 设为 true 来得到尽量自由的反弹力。用和这篇旧博客里说的相似的技术,将滑动的视图的 bounds 设置得更小一些并将 panGestureRecognizer 移到事件的 cell 顶层的一个覆盖视图中,来设置一个自定义页面大小。

然而,当覆盖视图正确的传递触摸事件到 scroll view 时,那里会有覆盖视图不能正确拦截的其余事件。cell 包含着按钮,像 “join event” 按钮和 “delete event” 按钮,都须要接收触摸。有几种自定义实如今 -hitTest: 中能够处理这种状况,其中一种实现就是直接检查这两个按钮的子视图:

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

	guard isUserInteractionEnabled else { return nil }
	
	guard !isHidden else { return nil }
	
	guard alpha >= 0.01 else { return nil }

	guard self.point(inside: point, with: event) else { return nil }

	if joinButton.point(inside: convert(point, to: joinButton), with: event) {
		return joinButton
	}
	
	if isDeleteButtonOpen && deleteButton.point(inside: convert(point, to: deleteButton), with: event) {
		return deleteButton
	}
	return super.hitTest(point, with: event)
}
复制代码

这种方法会正确地传递正确的点击事件到正确的的按钮中,并且不用打断显示删除按钮的滑动表现。(你能够尝试只忽略 deletionOverlay,不过它不会正确的传递滑动事件。)

-hitTest: 是视图中一个不多重写的地方,可是在须要时,能够提供其余工具很难作到的行为。理解如何本身实现有助于随意替换它。你能够用这个技术去扩大点击的目标区域,去除触摸处理中的某些子视图,而不用把它们从可见的层级中去掉,又或是用一个视图做为另外一个将响应触摸的视图的兜底。全部东西都是可能的。

本文由 SwiftGG 翻译组翻译,已经得到做者翻译受权,最新文章请访问 swift.gg

相关文章
相关标签/搜索