Hacking Hit Tests

SwiftGG翻譯組發表於2019-02-27

作者:Soroush Khanlou,原文連結,原文日期:2018-09-07
譯者:Nemocdz;校對:Yousanflicspmst;定稿:Forelax

回想 Crusty 教我們使用面向協議程式設計之前的日子,我們大多使用繼承來共享程式碼的實現。通常在 UIKit 程式設計中,你可能會用 UIView 的子類去新增一些子檢視,重寫 -layoutSubviews,然後重複這些工作。也許你還會重寫 -drawRect。但當你需要做一些特別的事情時,就需要看看 UIView 中其他可以被重寫的方法。

UIKit 有個十分古怪的地方,那是它的觸控事件處理系統。它主要包括兩個方法,-pointInstide:withEvent:-hitTest:withEvent:

-pointInside: 會告訴呼叫者給定點是否包含在指定的檢視區域中。而 -hitTest:pointInside: 這個方法來告訴呼叫者哪個子檢視(如果有的話)是當前觸控在給定點的接收者。現在我比較感興趣的是後面這個方法。

蘋果的文件勉強能夠讓你理解怎麼重新實現這個方法。在你學會怎麼重新實現方法之前,你都不能改變它的功能。接下來讓我們看一遍 文件,並嘗試重寫這個函式。

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
	// ...
}
複製程式碼

首先,讓我們從文件的第二段開始吧:

這個方法會忽略那些隱藏的檢視,禁用使用者互動檢視和 alpha 等級小於 0.01 的檢視。

讓我們通過一些 gurad 語句來快速預處理這些前提條件。

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 }
			
	// ...
複製程式碼

相當簡單吧。那接下來是?

這個方法呼叫 pointInside:withEvent: 方法來遍歷接收檢視層級中每一個子檢視,來決定哪個子檢視來接收該觸控事件。

逐字閱讀文件後,感覺 -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

相關文章