在SwiftUI ScrollView中使用复杂的手势(cell里面加长按手势就导致无法滚动bug)

在SwiftUI ScrollView中使用复杂的手势很复杂,因为它们以导致滚动停止工作的方式阻止滚动视图手势。我研究了这一点,并找到了一种使用按钮样式以不阻止滚动的方式处理手势的方法。

发布更新

**2022-11-20 **我添加了一个“继续”部分,描述了如何添加对拖动手势的支持,还添加了一个组件的链接,该组件支持检测和处理按压、释放(内部和外部)、长按、双击、重复(按住)、拖动手势以及手势何时结束。

问题

我解决这个问题的原因是,我在一个项目中遇到了问题,我需要在嵌套在ScrollView视图上进行复杂的手势。手势与滚动视图手势冲突,导致滚动停止工作,手势无法检测到。

为了解释,假设你有一个带有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") }

再次运行代码,您将看到事情仍然有效。您可以点击项目来触发操作,同时仍然像以前一样滚动浏览项目。

现在让我们更新代码以使用onLongPressGesture修饰符而不是onTapGesture

listItem
    .onLongPressGesture { print("Long press") }

如果您运行此代码,您现在可以长按项目来触发长按操作。但是,如果您尝试在列表中滚动,您会注意到滚动不再有效。

如果您使用带有LongPressGesturegesture,则没关系,滚动仍然损坏:

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

使用此代码,onChangeonEnded函数将按预期触发,但列表不会滚动。如果您使用DragGesture而不是LongPressGesture也会发生同样的情况。

为什么会发生这种情况?

滚动停止工作,因为长按和拖动手势修饰符从滚动视图中窃取手势,而当您应用点击手势修饰符时不会发生这种情况。

我不知道为什么点击手势可以工作,而长按和拖动却不起作用,但我想这与点击有关,只需检测按压和释放,而其他手势需要随着时间的推移检测手势,其方式可能与滚动手势相冲突。

一些不工作的解决方案

如果您在线搜索此问题,您会发现建议,您可以在长按和拖动手势之前添加emptionTapGesture来解决这个问题:

listItem
    .onTapGesture { print("Tap") }
    .gesture(
        LongPressGesture()
            .onEnded { _ in print("Long press") }
    )

这实际上会起作用。长按手势将触发,您仍然可以滚动浏览列表。但是,如果您用simultaneousGesture替换gesture,滚动将再次停止工作:

listItem
    .onTapGesture { print("Tap") }
    .simultaneousGesture(
        LongPressGesture()
            .onEnded { _ in print("Long press") }
    )

gesture起作用而simultaneousGesture不起作用,原因是gesture在任何先前的手势之后都会自行安排,而simultaneousGesture则会与它们一起安排自己。

换句话说,gestureonTapGesture之后触发,这意味着它不会干扰滚动。这就是为什么滚动仍然有效。然而,由于simultaneousGesture会立即触发,它干扰了滚动。这就是滚动停止工作的原因。

这意味着onTapGesture方法需要延迟。如果我们想使用即时手势,例如检测拖动和按压,这种方法是不可行的。

您可能会发现其他基于延迟的解决方案,其中一些非常复杂。由于这些也基于延迟,如果我们想立即检测到手势,它们将不起作用。

寻找变通办法

虽然UIKit具有非常精细的手势检测,但SwiftUI更有限。我们仍然可以做很多同样的事情,但工具更少。例如,您可以使用距离为0DragGesture来检测press手势。为了检测releases,我们可以监听拖动手势结束。

然而,由于长按和拖动手势在ScrollView中不起作用,面临的挑战是找到一种方法,以一种不会扰乱滚动的方式检测其中一些手势。

经过一段时间的思考并尝试了许多不工作的解决方案,我和康斯坦丁·齐里亚诺夫确实意识到我们有一种方法可以检测到正在按下视图-使用ButtonStyle

对于那些不熟悉SwiftUI按钮样式的人,它们允许您根据按钮roleisPressed状态更改按钮的样式。例如,此样式会更改其label的不透明度:

struct MyButtonStyle: ButtonStyle {

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(configuration.isPressed ? 0.5 : 1)
    }
}

正如大多数使用过SwiftUI的人可能知道的那样,按钮样式不会干扰滚动。这将使整个样式方法无法使用(就像无法在滚动视图中使用手势一样)。也许这就是我们一直在寻找的黑客?

也许我们可以使用按钮样式来绕过滚动视图限制,并用它来检测按下和释放,而无需使用拖动手势?让我们来了解一下!

构建滚动视图手势按钮

在创建这种基于按钮样式的方法时,我希望它能够检测以下手势:

  • 压力机
  • 释放(内部和外部)
  • 长压
  • 按住按压
  • 双水龙头
  • 手势结束

大多数将由样式处理,而一些必须由按钮处理。让我们从风格开始。

定义按钮样式

让我们创建一个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
    }
}

除了手势操作外,我们还添加了配置,让我们定义两个点击之间的最大时间,以算作双击,以及长按所需的时间。

有了这个基础,我们可以开始处理makeBody中的按下状态,我们使用configuration.isPressed值来检测:

func makeBody(configuration: Configuration) -> some View {
    configuration.label
        .onChange(of: configuration.isPressed) { isPressed in
            if isPressed {
                pressAction()
            } else {
                endAction()
            }
        }
}

在上面的代码中,我们订阅了按下状态,并在每次状态更改时触发一个函数。如果按下配置,我们将触发pressAction,如果不按下,则触发endAction

如果你想知道为什么endAction不叫releaseAction,让我破坏未来的发现。如果我们将此样式应用于滚动视图中的按钮,然后在按下按钮时开始滚动,即使我们仍然按下按钮,也会在取消手势时触发endAction。换句话说,这不是释放动作。我们必须以另一种方式处理释放。

如何处理双水龙头

要处理双击,我们只需要检测两个按压事件的触发速度。要为我们的按钮样式实现这一点,首先将此状态添加到样式中:

@State
var doubleTapDate = Date()

然后添加以下功能:

private extension ScrollViewGestureButtonStyle {

    func tryTriggerDoubleTap() -> Bool {
        let interval = Date().timeIntervalSince(doubleTapDate)
        guard interval < doubleTapTimeout else { return false }
        doubleTapAction()
        return true
    }
}

最后将以下内容添加到isPressed处理中:

if isPressed {
    pressAction()
    doubleTapDate = tryTriggerDoubleTap() ? .distantPast : .now
} else {
    endAction()
}

当按下视图时,我们检查是否有更早的注册按压,这应该会导致新按压作为双击处理。如果在doubleTapTimeout时间内发生两次按压,我们会触发双击,否则我们将doubleTapDate设置为遥远的过去,以避免随后的双击。

澄清一下,从技术上讲,这不是双击手势,而是双击。然而,重写它以表现为双击有点复杂,所以现在让我们继续这个吧。

如何处理长压机

要处理长按,我们只需要检测按事件处于活动状态多长时间。要为我们的按钮样式实现这一点,首先将此状态添加到样式中:

@State
var longPressDate = Date()

然后添加以下功能:

private extension ScrollViewGestureButtonStyle {

    func tryTriggerLongPressAfterDelay(triggered date: Date) {
        DispatchQueue.main.asyncAfter(deadline: .now() + longPressTime) {
            guard date == longPressDate else { return }
            longPressAction()
        }
    }
}

最后将以下内容添加到isPressed处理中:

longPressDate = Date()
if isPressed {
    pressAction()
    doubleTapDate = tryTriggerDoubleTap() ? .distantPast : .now
    tryTriggerLongPressAfterDelay(triggered: longPressDate)
} else {
    endAction()
}

我们首先将longPressDate设置为当前日期,然后在longPressTime之后触发执行的异步操作。如果触发时日期仍然相同,我们将触发longPressAction

总结我们的风格

我们的按钮样式现已完成。总而言之,它看起来像这样:

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()
        }
    }
}

然而,我们仍然缺少一些功能,例如检测按钮何时释放。这无法在样式内完成,因为样式手势可能会被取消,所以让我们定义一个按钮来应用样式并填写这些缺失的部分。

如何处理手势释放

要实现release手势,让我们创建一个按钮,该按钮使用releaseAction作为其点击操作,并应用我们刚刚定义的按钮样式:

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)
    }
}

就是这样!该按钮只需包装提供的标签,触发提供的releaseAction,并应用新创建的样式来处理剩余的手势。

结论

如果你尝试这个,你会发现它确实有效。您可以按下、重复、双击、长按等,滚动仍然有效。这一切都是因为按钮样式可以在不阻止滚动视图手势的情况下检测按下。

走得更远

虽然上述效果很好,可能足以满足大多数需求,但如果您需要检测拖动手势,实际上还不够。例如,我的键盘套件库需要按钮才能处理各种手势,并在按钮显示带有辅助操作的标注时过渡到拖动。

因此,我决定改进上述解决方案,以处理拖动手势。结果比我最初预期的要复杂得多。例如,我们无法将拖动手势直接应用于Button,而必须将其应用于按钮内容视图:

Button(action: releaseAction) {
    buttonContentView
        .gesture(DragGesture(...))  // Must go here
}
.buttonStyle(style)
.gesture(DragGesture(...))  // This will not work!

然而,在视图中添加DragGesture意味着它将开始与按钮样式冲突。例如,快速点击按钮只会触发按钮操作,不会触发样式。这意味着我们必须处理按钮和样式中的双击。此外,事实证明,正如我们之前讨论的那样,拖动手势将再次阻止滚动。因此,我们必须在它之前添加一个点击手势,以强制延迟拖动手势,正如我们之前所讨论的那样,但这带来了更多的复杂性,因为我们现在有一个点击手势、一个拖动手势和一个按钮样式,必须一起播放。

添加拖动手势后,可以打开一罐蠕虫。

由于代码的不同部分在不同情况下必须处理相同的功能,我还必须使代码更加复杂,以避免代码重复。在混合中添加拖动手势时,上面的简单解决方案变得更加复杂。

结论

ScrollViewGestureButton允许您用一个按钮处理多个手势。只需一个DragGesture,您就可以检测按压、外部和内部的卷取、长按、双击、触发重复操作等。

参考:来源:
https://danielsaidi.com/blog/2022/11/16/using-complex-gestures-in-a-scroll-view

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容