在SwiftUI ScrollView中使用復雜的手勢(cell里面加長按手勢就導致無法滾動bug)

在SwiftUI ScrollView中使用復雜的手勢很復雜,因為它們以導致滾動停止工作的方式阻止?jié)L動視圖手勢。我研究了這一點,并找到了一種使用按鈕樣式以不阻止?jié)L動的方式處理手勢的方法。

發(fā)布更新

**2022-11-20 **我添加了一個“繼續(xù)”部分,描述了如何添加對拖動手勢的支持,還添加了一個組件的鏈接,該組件支持檢測和處理按壓、釋放(內(nèi)部和外部)、長按、雙擊、重復(按?。?、拖動手勢以及手勢何時結(jié)束。

問題

我解決這個問題的原因是,我在一個項目中遇到了問題,我需要在嵌套在ScrollView視圖上進行復雜的手勢。手勢與滾動視圖手勢沖突,導致滾動停止工作,手勢無法檢測到。

為了解釋,假設(shè)你有一個帶有LazyHStackScrollView,你在其中添加了一堆視圖:

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ā)長按操作。但是,如果您嘗試在列表中滾動,您會注意到滾動不再有效。

如果您使用帶有LongPressGesturegesture,則沒關(guān)系,滾動仍然損壞:

listItem
    .gesture(
        LongPressGesture()
            .onChanged { _ in print("Long press changed") }
            .onEnded { _ in print("Long press ended") }
    )

使用此代碼,onChangeonEnded函數(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則會與它們一起安排自己。

換句話說,gestureonTapGesture之后觸發(fā),這意味著它不會干擾滾動。這就是為什么滾動仍然有效。然而,由于simultaneousGesture會立即觸發(fā),它干擾了滾動。這就是滾動停止工作的原因。

這意味著onTapGesture方法需要延遲。如果我們想使用即時手勢,例如檢測拖動和按壓,這種方法是不可行的。

您可能會發(fā)現(xiàn)其他基于延遲的解決方案,其中一些非常復雜。由于這些也基于延遲,如果我們想立即檢測到手勢,它們將不起作用。

尋找變通辦法

雖然UIKit具有非常精細的手勢檢測,但SwiftUI更有限。我們?nèi)匀豢梢宰龊芏嗤瑯拥氖虑?,但工具更少。例如,您可以使用距離為0DragGesture來檢測press手勢。為了檢測releases,我們可以監(jiān)聽拖動手勢結(jié)束。

然而,由于長按和拖動手勢在ScrollView中不起作用,面臨的挑戰(zhàn)是找到一種方法,以一種不會擾亂滾動的方式檢測其中一些手勢。

經(jīng)過一段時間的思考并嘗試了許多不工作的解決方案,我和康斯坦丁·齊里亞諾夫確實意識到我們有一種方法可以檢測到正在按下視圖-使用ButtonStyle。

對于那些不熟悉SwiftUI按鈕樣式的人,它們允許您根據(jù)按鈕roleisPressed狀態(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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容