在SwiftUI ScrollView中使用復雜的手勢很復雜,因為它們以導致滾動停止工作的方式阻止?jié)L動視圖手勢。我研究了這一點,并找到了一種使用按鈕樣式以不阻止?jié)L動的方式處理手勢的方法。
發(fā)布更新
**2022-11-20 **我添加了一個“繼續(xù)”部分,描述了如何添加對拖動手勢的支持,還添加了一個組件的鏈接,該組件支持檢測和處理按壓、釋放(內(nèi)部和外部)、長按、雙擊、重復(按?。?、拖動手勢以及手勢何時結(jié)束。
問題
我解決這個問題的原因是,我在一個項目中遇到了問題,我需要在嵌套在ScrollView視圖上進行復雜的手勢。手勢與滾動視圖手勢沖突,導致滾動停止工作,手勢無法檢測到。
為了解釋,假設(shè)你有一個帶有LazyHStack的ScrollView,你在其中添加了一堆視圖:
struct MyView: View {
var body: some View {
VStack {
Text("\(tapCount) taps")
ScrollView(.horizontal) {
LazyHStack {
ForEach(0...100, id: \.self) { _ in
listItem
}
}
}
}
}
var listItem: some View {
Color.red
.frame(width: 100, height: 100)
}
}
如果運行此代碼,您將獲得一個帶有紅色方塊的水平列表,該列表可以滑動來滾動瀏覽項目。
讓我們更新上面的代碼,為每個列表項添加一個onTapGesture修飾符:
listItem
.onTapGesture { print("Tap") }
再次運行代碼,您將看到事情仍然有效。您可以點擊項目來觸發(fā)操作,同時仍然像以前一樣滾動瀏覽項目。
現(xiàn)在讓我們更新代碼以使用onLongPressGesture修飾符而不是onTapGesture:
listItem
.onLongPressGesture { print("Long press") }
如果您運行此代碼,您現(xiàn)在可以長按項目來觸發(fā)長按操作。但是,如果您嘗試在列表中滾動,您會注意到滾動不再有效。
如果您使用帶有LongPressGesture的gesture,則沒關(guān)系,滾動仍然損壞:
listItem
.gesture(
LongPressGesture()
.onChanged { _ in print("Long press changed") }
.onEnded { _ in print("Long press ended") }
)
使用此代碼,onChange和onEnded函數(shù)將按預期觸發(fā),但列表不會滾動。如果您使用DragGesture而不是LongPressGesture也會發(fā)生同樣的情況。
為什么會發(fā)生這種情況?
滾動停止工作,因為長按和拖動手勢修飾符從滾動視圖中竊取手勢,而當您應(yīng)用點擊手勢修飾符時不會發(fā)生這種情況。
我不知道為什么點擊手勢可以工作,而長按和拖動卻不起作用,但我想這與點擊有關(guān),只需檢測按壓和釋放,而其他手勢需要隨著時間的推移檢測手勢,其方式可能與滾動手勢相沖突。
一些不工作的解決方案
如果您在線搜索此問題,您會發(fā)現(xiàn)建議,您可以在長按和拖動手勢之前添加emptionTapGesture來解決這個問題:
listItem
.onTapGesture { print("Tap") }
.gesture(
LongPressGesture()
.onEnded { _ in print("Long press") }
)
這實際上會起作用。長按手勢將觸發(fā),您仍然可以滾動瀏覽列表。但是,如果您用simultaneousGesture替換gesture,滾動將再次停止工作:
listItem
.onTapGesture { print("Tap") }
.simultaneousGesture(
LongPressGesture()
.onEnded { _ in print("Long press") }
)
gesture起作用而simultaneousGesture不起作用,原因是gesture在任何先前的手勢之后都會自行安排,而simultaneousGesture則會與它們一起安排自己。
換句話說,gesture在onTapGesture之后觸發(fā),這意味著它不會干擾滾動。這就是為什么滾動仍然有效。然而,由于simultaneousGesture會立即觸發(fā),它干擾了滾動。這就是滾動停止工作的原因。
這意味著onTapGesture方法需要延遲。如果我們想使用即時手勢,例如檢測拖動和按壓,這種方法是不可行的。
您可能會發(fā)現(xiàn)其他基于延遲的解決方案,其中一些非常復雜。由于這些也基于延遲,如果我們想立即檢測到手勢,它們將不起作用。
尋找變通辦法
雖然UIKit具有非常精細的手勢檢測,但SwiftUI更有限。我們?nèi)匀豢梢宰龊芏嗤瑯拥氖虑?,但工具更少。例如,您可以使用距離為0的DragGesture來檢測press手勢。為了檢測releases,我們可以監(jiān)聽拖動手勢結(jié)束。
然而,由于長按和拖動手勢在ScrollView中不起作用,面臨的挑戰(zhàn)是找到一種方法,以一種不會擾亂滾動的方式檢測其中一些手勢。
經(jīng)過一段時間的思考并嘗試了許多不工作的解決方案,我和康斯坦丁·齊里亞諾夫確實意識到我們有一種方法可以檢測到正在按下視圖-使用ButtonStyle。
對于那些不熟悉SwiftUI按鈕樣式的人,它們允許您根據(jù)按鈕role和isPressed狀態(tài)更改按鈕的樣式。例如,此樣式會更改其label的不透明度:
struct MyButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.5 : 1)
}
}
正如大多數(shù)使用過SwiftUI的人可能知道的那樣,按鈕樣式不會干擾滾動。這將使整個樣式方法無法使用(就像無法在滾動視圖中使用手勢一樣)。也許這就是我們一直在尋找的黑客?
也許我們可以使用按鈕樣式來繞過滾動視圖限制,并用它來檢測按下和釋放,而無需使用拖動手勢?讓我們來了解一下!
構(gòu)建滾動視圖手勢按鈕
在創(chuàng)建這種基于按鈕樣式的方法時,我希望它能夠檢測以下手勢:
- 壓力機
- 釋放(內(nèi)部和外部)
- 長壓
- 按住按壓
- 雙水龍頭
- 手勢結(jié)束
大多數(shù)將由樣式處理,而一些必須由按鈕處理。讓我們從風格開始。
定義按鈕樣式
讓我們創(chuàng)建一個ScrollViewGestureButtonStyle,并定義它將幫助我們處理的功能:
struct ScrollViewGestureButtonStyle: ButtonStyle {
init(
pressAction: @escaping () -> Void,
longPressTime: TimeInterval,
longPressAction: @escaping () -> Void,
doubleTapTimeout: TimeInterval,
doubleTapAction: @escaping () -> Void,
endAction: @escaping () -> Void
) {
self.pressAction = pressAction
self.longPressTime = longPressTime
self.longPressAction = longPressAction
self.doubleTapTimeout = doubleTapTimeout
self.doubleTapAction = doubleTapAction
self.endAction = endAction
}
private var doubleTapTimeout: TimeInterval
private var longPressTime: TimeInterval
private var pressAction: () -> Void
private var longPressAction: () -> Void
private var doubleTapAction: () -> Void
private var endAction: () -> Void
func makeBody(configuration: Configuration) -> some View {
// Insert magic here
}
}
除了手勢操作外,我們還添加了配置,讓我們定義兩個點擊之間的最大時間,以算作雙擊,以及長按所需的時間。
有了這個基礎(chǔ),我們可以開始處理makeBody中的按下狀態(tài),我們使用configuration.isPressed值來檢測:
func makeBody(configuration: Configuration) -> some View {
configuration.label
.onChange(of: configuration.isPressed) { isPressed in
if isPressed {
pressAction()
} else {
endAction()
}
}
}
在上面的代碼中,我們訂閱了按下狀態(tài),并在每次狀態(tài)更改時觸發(fā)一個函數(shù)。如果按下配置,我們將觸發(fā)pressAction,如果不按下,則觸發(fā)endAction。
如果你想知道為什么endAction不叫releaseAction,讓我破壞未來的發(fā)現(xiàn)。如果我們將此樣式應(yīng)用于滾動視圖中的按鈕,然后在按下按鈕時開始滾動,即使我們?nèi)匀话聪掳粹o,也會在取消手勢時觸發(fā)endAction。換句話說,這不是釋放動作。我們必須以另一種方式處理釋放。
如何處理雙水龍頭
要處理雙擊,我們只需要檢測兩個按壓事件的觸發(fā)速度。要為我們的按鈕樣式實現(xiàn)這一點,首先將此狀態(tài)添加到樣式中:
@State
var doubleTapDate = Date()
然后添加以下功能:
private extension ScrollViewGestureButtonStyle {
func tryTriggerDoubleTap() -> Bool {
let interval = Date().timeIntervalSince(doubleTapDate)
guard interval < doubleTapTimeout else { return false }
doubleTapAction()
return true
}
}
最后將以下內(nèi)容添加到isPressed處理中:
if isPressed {
pressAction()
doubleTapDate = tryTriggerDoubleTap() ? .distantPast : .now
} else {
endAction()
}
當按下視圖時,我們檢查是否有更早的注冊按壓,這應(yīng)該會導致新按壓作為雙擊處理。如果在doubleTapTimeout時間內(nèi)發(fā)生兩次按壓,我們會觸發(fā)雙擊,否則我們將doubleTapDate設(shè)置為遙遠的過去,以避免隨后的雙擊。
澄清一下,從技術(shù)上講,這不是雙擊手勢,而是雙擊。然而,重寫它以表現(xiàn)為雙擊有點復雜,所以現(xiàn)在讓我們繼續(xù)這個吧。
如何處理長壓機
要處理長按,我們只需要檢測按事件處于活動狀態(tài)多長時間。要為我們的按鈕樣式實現(xiàn)這一點,首先將此狀態(tài)添加到樣式中:
@State
var longPressDate = Date()
然后添加以下功能:
private extension ScrollViewGestureButtonStyle {
func tryTriggerLongPressAfterDelay(triggered date: Date) {
DispatchQueue.main.asyncAfter(deadline: .now() + longPressTime) {
guard date == longPressDate else { return }
longPressAction()
}
}
}
最后將以下內(nèi)容添加到isPressed處理中:
longPressDate = Date()
if isPressed {
pressAction()
doubleTapDate = tryTriggerDoubleTap() ? .distantPast : .now
tryTriggerLongPressAfterDelay(triggered: longPressDate)
} else {
endAction()
}
我們首先將longPressDate設(shè)置為當前日期,然后在longPressTime之后觸發(fā)執(zhí)行的異步操作。如果觸發(fā)時日期仍然相同,我們將觸發(fā)longPressAction。
總結(jié)我們的風格
我們的按鈕樣式現(xiàn)已完成??偠灾?,它看起來像這樣:
struct ScrollViewGestureButtonStyle: ButtonStyle {
init(
pressAction: @escaping () -> Void,
doubleTapTimeoutout: TimeInterval,
doubleTapAction: @escaping () -> Void,
longPressTime: TimeInterval,
longPressAction: @escaping () -> Void,
endAction: @escaping () -> Void
) {
self.pressAction = pressAction
self.doubleTapTimeoutout = doubleTapTimeoutout
self.doubleTapAction = doubleTapAction
self.longPressTime = longPressTime
self.longPressAction = longPressAction
self.endAction = endAction
}
private var doubleTapTimeoutout: TimeInterval
private var longPressTime: TimeInterval
private var pressAction: () -> Void
private var longPressAction: () -> Void
private var doubleTapAction: () -> Void
private var endAction: () -> Void
@State
var doubleTapDate = Date()
@State
var longPressDate = Date()
func makeBody(configuration: Configuration) -> some View {
configuration.label
.onChange(of: configuration.isPressed) { isPressed in
longPressDate = Date()
if isPressed {
pressAction()
doubleTapDate = tryTriggerDoubleTap() ? .distantPast : .now
tryTriggerLongPressAfterDelay(triggered: longPressDate)
} else {
endAction()
}
}
}
}
private extension ScrollViewGestureButtonStyle {
func tryTriggerDoubleTap() -> Bool {
let interval = Date().timeIntervalSince(doubleTapDate)
guard interval < doubleTapTimeoutout else { return false }
doubleTapAction()
return true
}
func tryTriggerLongPressAfterDelay(triggered date: Date) {
DispatchQueue.main.asyncAfter(deadline: .now() + longPressTime) {
guard date == longPressDate else { return }
longPressAction()
}
}
}
然而,我們?nèi)匀蝗鄙僖恍┕δ?,例如檢測按鈕何時釋放。這無法在樣式內(nèi)完成,因為樣式手勢可能會被取消,所以讓我們定義一個按鈕來應(yīng)用樣式并填寫這些缺失的部分。
如何處理手勢釋放
要實現(xiàn)release手勢,讓我們創(chuàng)建一個按鈕,該按鈕使用releaseAction作為其點擊操作,并應(yīng)用我們剛剛定義的按鈕樣式:
struct ScrollViewGestureButton<Label: View>: View {
init(
doubleTapTimeoutout: TimeInterval = 0.5,
longPressTime: TimeInterval = 1,
pressAction: @escaping () -> Void = {},
releaseAction: @escaping () -> Void = {},
endAction: @escaping () -> Void = {},
longPressAction: @escaping () -> Void = {},
doubleTapAction: @escaping () -> Void = {},
label: @escaping () -> Label
) {
self.style = ScrollViewGestureButtonStyle(
doubleTapTimeoutout: doubleTapTimeoutout,
longPressTime: longPressTime,
pressAction: pressAction,
endAction: endAction,
longPressAction: longPressAction,
doubleTapAction: doubleTapAction)
self.releaseAction = releaseAction
self.label = label
}
var label: () -> Label
var style: GestureButtonStyle
var releaseAction: () -> Void
var body: some View {
Button(action: releaseAction, label: label)
.buttonStyle(style)
}
}
就是這樣!該按鈕只需包裝提供的標簽,觸發(fā)提供的releaseAction,并應(yīng)用新創(chuàng)建的樣式來處理剩余的手勢。
結(jié)論
如果你嘗試這個,你會發(fā)現(xiàn)它確實有效。您可以按下、重復、雙擊、長按等,滾動仍然有效。這一切都是因為按鈕樣式可以在不阻止?jié)L動視圖手勢的情況下檢測按下。
走得更遠
雖然上述效果很好,可能足以滿足大多數(shù)需求,但如果您需要檢測拖動手勢,實際上還不夠。例如,我的鍵盤套件庫需要按鈕才能處理各種手勢,并在按鈕顯示帶有輔助操作的標注時過渡到拖動。
因此,我決定改進上述解決方案,以處理拖動手勢。結(jié)果比我最初預期的要復雜得多。例如,我們無法將拖動手勢直接應(yīng)用于Button,而必須將其應(yīng)用于按鈕內(nèi)容視圖:
Button(action: releaseAction) {
buttonContentView
.gesture(DragGesture(...)) // Must go here
}
.buttonStyle(style)
.gesture(DragGesture(...)) // This will not work!
然而,在視圖中添加DragGesture意味著它將開始與按鈕樣式?jīng)_突。例如,快速點擊按鈕只會觸發(fā)按鈕操作,不會觸發(fā)樣式。這意味著我們必須處理按鈕和樣式中的雙擊。此外,事實證明,正如我們之前討論的那樣,拖動手勢將再次阻止?jié)L動。因此,我們必須在它之前添加一個點擊手勢,以強制延遲拖動手勢,正如我們之前所討論的那樣,但這帶來了更多的復雜性,因為我們現(xiàn)在有一個點擊手勢、一個拖動手勢和一個按鈕樣式,必須一起播放。
添加拖動手勢后,可以打開一罐蠕蟲。
由于代碼的不同部分在不同情況下必須處理相同的功能,我還必須使代碼更加復雜,以避免代碼重復。在混合中添加拖動手勢時,上面的簡單解決方案變得更加復雜。
結(jié)論
ScrollViewGestureButton允許您用一個按鈕處理多個手勢。只需一個DragGesture,您就可以檢測按壓、外部和內(nèi)部的卷取、長按、雙擊、觸發(fā)重復操作等。
參考:來源:
https://danielsaidi.com/blog/2022/11/16/using-complex-gestures-in-a-scroll-view