iosswiftui

Can I add a horizontal DragGesture to a horizontal ScrollView?


I have a horizontally scrolling ScrollView that encloses an HStack. I want to add a horizontal DragGesture somewhere in the view hierarchy:

  1. If the user pans the HStack without pausing, the ScrollView should scroll like normal.
  2. If the user touches and waits a fraction of a second before panning, the DragGesture should take precedence (and the ScrollView should NOT scroll).

The iOS Apple Weather app shows a real-world example of this:

  1. Tap an hour: a graph for the day will open.
  2. Swipe the graph horizontally: it will scroll to a different day.
  3. Touch, then swipe horizontally: the graph will show a vertical line that you can drag without scrolling the ScrollView.

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?


FOLLOW-UP

...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.


Solution

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