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

相關文章
相關標籤/搜索