在SwiftUI ScrollView
中使用复杂的手势很复杂,因为它们以导致滚动停止工作的方式阻止滚动视图手势。我研究了这一点,并找到了一种使用按钮样式以不阻止滚动的方式处理手势的方法。
发布更新
**2022-11-20 **我添加了一个“继续”部分,描述了如何添加对拖动手势的支持,还添加了一个组件的链接,该组件支持检测和处理按压、释放(内部和外部)、长按、双击、重复(按住)、拖动手势以及手势何时结束。
问题
我解决这个问题的原因是,我在一个项目中遇到了问题,我需要在嵌套在ScrollView
视图上进行复杂的手势。手势与滚动视图手势冲突,导致滚动停止工作,手势无法检测到。
为了解释,假设你有一个带有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") }
再次运行代码,您将看到事情仍然有效。您可以点击项目来触发操作,同时仍然像以前一样滚动浏览项目。
现在让我们更新代码以使用onLongPressGesture
修饰符而不是onTapGesture
:
listItem
.onLongPressGesture { print("Long press") }
如果您运行此代码,您现在可以长按项目来触发长按操作。但是,如果您尝试在列表中滚动,您会注意到滚动不再有效。
如果您使用带有LongPressGesture
的gesture
,则没关系,滚动仍然损坏:
listItem
.gesture(
LongPressGesture()
.onChanged { _ in print("Long press changed") }
.onEnded { _ in print("Long press ended") }
)
使用此代码,onChange
和onEnded
函数将按预期触发,但列表不会滚动。如果您使用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
则会与它们一起安排自己。
换句话说,gesture
在onTapGesture
之后触发,这意味着它不会干扰滚动。这就是为什么滚动仍然有效。然而,由于simultaneousGesture
会立即触发,它干扰了滚动。这就是滚动停止工作的原因。
这意味着onTapGesture
方法需要延迟。如果我们想使用即时手势,例如检测拖动和按压,这种方法是不可行的。
您可能会发现其他基于延迟的解决方案,其中一些非常复杂。由于这些也基于延迟,如果我们想立即检测到手势,它们将不起作用。
寻找变通办法
虽然UIKit
具有非常精细的手势检测,但SwiftUI
更有限。我们仍然可以做很多同样的事情,但工具更少。例如,您可以使用距离为0
的DragGesture
来检测press
手势。为了检测releases
,我们可以监听拖动手势结束。
然而,由于长按和拖动手势在ScrollView
中不起作用,面临的挑战是找到一种方法,以一种不会扰乱滚动的方式检测其中一些手势。
经过一段时间的思考并尝试了许多不工作的解决方案,我和康斯坦丁·齐里亚诺夫确实意识到我们有一种方法可以检测到正在按下视图-使用ButtonStyle
。
对于那些不熟悉SwiftUI按钮样式的人,它们允许您根据按钮role
和isPressed
状态更改按钮的样式。例如,此样式会更改其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