I have a horizontally scrolling ScrollView that encloses an HStack. I want to add a horizontal DragGesture somewhere in the view hierarchy:
The iOS Apple Weather app shows a real-world example of this:
I’ve seen code samples that use ButtonStyle to mix gestures with ScrollViews. For example: SwiftUI Delayed Gesture.
I put that code in a test view. If I swipe the HStack quickly, the ScrollView scrolls and the DragGesture doesn’t fire. If I touch, then swipe after a short delay, the DragGesture is processed. BUT the ScrollView also scrolls (which I don’t want). I tried attaching the modifier to different parts of the view (the HStack, one of the HStack subviews, a ZStack superview, etc.) but that didn’t help.
struct ContentView: View {
var body: some View {
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { value in
print("DragGesture")
}
ZStack {
ScrollView(.horizontal) {
HStack {
Color.red
.frame(width: 150, height: 150)
Color.blue
.frame(width: 150, height: 150)
Color.green
.frame(width: 150, height: 150)
Color.yellow
.frame(width: 150, height: 150)
Color.orange
.frame(width: 150, height: 150)
}
}
.delayedGesture(dragGesture, delay: 0.2)
}
}
public extension View {
func delayedGesture<T: Gesture>(
_ gesture: T,
including mask: GestureMask = .all,
delay: TimeInterval = 0.25,
onTapGesture action: @escaping () -> Void = {}
) -> some View {
self.modifier(DelayModifier(action: action, delay: delay))
.gesture(gesture, including: mask)
}
}
internal struct DelayModifier: ViewModifier {
@StateObject private var state = DelayState()
var action: () -> Void
var delay: TimeInterval
func body(content: Content) -> some View {
Button(action: action) {
content
}
.buttonStyle(DelayButtonStyle(delay: delay))
.accessibilityRemoveTraits(.isButton)
.environmentObject(state)
.disabled(state.disabled)
}
}
private struct DelayButtonStyle: ButtonStyle {
@EnvironmentObject private var state: DelayState
var delay: TimeInterval
func makeBody(configuration: Configuration) -> some View {
configuration.label
.onChange(of: configuration.isPressed) { isPressed in
state.onIsPressed(isPressed, delay: delay)
}
}
}
@MainActor
private final class DelayState: ObservableObject {
@Published private(set) var disabled = false
func onIsPressed(_ isPressed: Bool, delay: TimeInterval) {
workItem.cancel()
if isPressed {
workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.objectWillChange.send()
self.disabled = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + max(delay, 0), execute: workItem)
} else {
disabled = false
}
}
private var workItem = DispatchWorkItem(block: {})
}
Can I accomplish this with a standard ScrollView or do I need some kind of custom view?
...to the answer from @Andrei G. In my case, I need to know the initial touch location in order to update the UI so the user sees that the touch is registered (before they start dragging).
Problem: the LongPressGesture doesn't provide the touch location. And when you sequence the LongPressGesture before the DragGesture, the DragGesture doesn't provide the touch location until the drag actually starts.
Workaround: I created a second DragGesture that's independent of the composed LongPress/DragGesture:
let dragGesture2 = DragGesture(minimumDistance: 0)
.onChanged { value in
dragStartLocation = value.location
}
I then added it to my view as a simultaneousGesture:
.simultaneousGesture(dragGesture2)
This second DragGesture fires immediately when the view is touched so I can get the location and save it to "dragStartLocation". I then use that location to update the UI after the LongPressGesture is triggered (after minimumDuration).
Downside: there will be two DragGestures. But you only use the second one to get the initial touch location so this is pretty minor.
Conclusion: you can use a sequenced LongPressGesture and DragGesture as outlined in @Andrei G.'s answer. But if you need the initial touch location, you can add a second DragGesture in order to capture that initial location.
Here's an example that uses a composed gesture as shown in Composing SwiftUI gestures, and an Observable object as a helper to conditionally disable the horizontal scroll view when a circle is dragged/active:
import SwiftUI
struct ScrollDragDemoView: View {
//Observables
@State private var dragObserver = DragObserver()
//Gesture states
@GestureState private var isDetectingLongPress = false
//Body
var body: some View {
VStack {
Text("Tap and hold over a circle, then drag to change its color: ")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView(.horizontal) {
HStack(spacing: 10) {
Group {
DraggableCircle()
DraggableCircle()
DraggableCircle()
DraggableCircle()
DraggableCircle()
}
.containerRelativeFrame(.horizontal, count: 5, span: 2, spacing: 10)
}
.scrollTargetLayout()
.environment(dragObserver) //passed via environment to avoid having to pass a binding to each DraggableCircle
}
.scrollTargetBehavior(.viewAligned)
.scrollDisabled(dragObserver.isActive) // <- disable scrolling if dragging is active
}
}
}
@Observable
final class DragObserver {
var isActive: Bool = false
}
struct DraggableCircle: View {
//Environment values
@Environment(DragObserver.self) private var dragObserver
//Enums
enum DragState: Equatable {
case inactive
case pressing
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive, .pressing:
return .zero
case .dragging(let translation):
return translation
}
}
var isActive: Bool {
switch self {
case .inactive:
return false
case .pressing, .dragging:
return true
}
}
var isDragging: Bool {
switch self {
case .inactive, .pressing:
return false
case .dragging:
return true
}
}
}
// @State private var viewState = CGSize.zero
@State private var hue: Double = 0
//Gesture states
@GestureState private var dragState = DragState.inactive
//Body
var body: some View {
let minimumLongPressDuration = 0.5
//Dragging gesture
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { value in
self.hue += value.translation.width / 10
}
//Composed gesture: LongPressGesture + DragGesture
let longPressDrag = LongPressGesture(minimumDuration: minimumLongPressDuration)
.sequenced(before: dragGesture) // <- sequence long press before drag
.updating($dragState) { value, state, transaction in
switch value {
// Long press begins.
case .first(true):
state = .pressing
// Long press confirmed, dragging may begin.
case .second(true, let drag):
state = .dragging(translation: drag?.translation ?? .zero)
// Dragging ended or the long press cancelled.
default:
state = .inactive
}
}
Circle()
.fill(Color.blue)
.hueRotation(.degrees(hue))
.overlay(dragState.isDragging ? Circle().stroke(Color.white, lineWidth: 2) : nil)
.animation(.smooth, value: hue)
.shadow(radius: dragState.isActive ? 8 : 0)
.padding(10)
.simultaneousGesture(longPressDrag)
.onChange(of: dragState) { _, newValue in
switch newValue {
case .inactive, .pressing:
dragObserver.isActive = false // <- update observer
case .dragging(_):
dragObserver.isActive = true // <- update observer
}
}
}
}
#Preview {
ScrollDragDemoView()
}