79

So I have a ScrollView holding a set of views:

    ScrollView {
        ForEach(cities) { city in
            NavigationLink(destination: ...) {
                CityRow(city: city)
            }
            .buttonStyle(BackgroundButtonStyle())
        }
    }

In every view I have a drag gesture:

    let drag = DragGesture()
        .updating($gestureState) { value, gestureState, _ in
            // ...
        }
        .onEnded { value in
            // ...
        }

Which I assign to a part of the view:

    ZStack(alignment: .leading) {
        HStack {
            // ...
        }
        HStack {
            // ...
        }
        .gesture(drag)
    }

As soon as I attach the gesture, the ScrollView stop scrolling. The only way to make it scroll it to start scrolling from a part of it which has no gesture attached. How can I avoid it and make both work together. In UIKit is was as simple as specifying true in shouldRecognizeSimultaneouslyWith method. How can I have the same in SwiftUI?

In SwiftUI I've tried attaching a gesture using .simultaneousGesture(drag) and .highPriorityGesture(drag) – they all work the same as .gesture(drag). I've also tried providing all possible static GestureMask values for including: parameter – I have either scroll working or my drag gesture working. Never both of them.

Here's what I'm using drag gesture for: enter image description here

zh.
  • 1,533
  • 1
  • 14
  • 18
  • There's a weird thing with this. If I add a `simultaneousGesture` to a `ScrollView` – it will work well with `ScrollView` children gestures. But the `ScrollView` scroll won't work. – zh. Aug 30 '19 at 15:24
  • Hi @zh, Did you find any solution to this question? – Amrit Sidhu Dec 13 '19 at 07:08
  • I'm running into the same issues on my watch app, where a scroll view wont scroll when a drag gesture is added to the view... – Benjamin B. Jan 12 '21 at 17:53

11 Answers11

41

You can set minimumDistance to some value (for instance 30). Then the drag only works when you drag horizontally and reach the minimum distance, otherwise the scrollview or list gesture override the view gesture

.gesture(DragGesture(minimumDistance: 30, coordinateSpace: .local)
Mac3n
  • 4,189
  • 3
  • 16
  • 29
  • 2
    I was sceptical at first, because I had my minimumDistance set to 10 and it didn't help to fix the issue, but then I've increased it to 30, and now it works like a charm! Thank you! – 4tuneTeller Mar 31 '22 at 09:44
37

Just before

.gesture(drag)

You can add

.onTapGesture { }

This works for me, apparently adding a tapGesture avoids confusion between the two DragGestures.

I hope this helps

Oscar
  • 1,899
  • 1
  • 22
  • 32
  • 4
    Worth noting that this approach works well but can unfortunately cause tap gesture hijacking - especially when working with UIKit wrapped components. I used this until I tried to tap on a UITableViewCell wrapped as a SwiftUI component - only to find that this method hijacked the tapGesture for the UIKit element. I'd recommend this approach if not using UIKit elements in your app - but if you are, then I have found Mac3n's solution o be more reliable – Dan Barclay Apr 17 '20 at 23:33
  • This really works well! Simple trick does it :) – Ryan Fung Feb 12 '22 at 04:32
  • 1
    For me this works, but delays the drag gesture considerably. – Tomáš Kafka Jul 22 '22 at 18:24
  • Unfortunately this gives a 1 second delay. I've [written up an answer](https://stackoverflow.com/a/73767378/9607863) after finding a forum post on another website, and thought it's worth adding it here too. All credit to the original author. – George Sep 19 '22 at 01:04
13

I have created an easy to use extension based on the Michel's answer.

struct NoButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
    }
}

extension View {
    func delayTouches() -> some View {
        Button(action: {}) {
            highPriorityGesture(TapGesture())
        }
        .buttonStyle(NoButtonStyle())
    }
}

You apply it after using a drag gesture.

Example:

ScrollView {
    YourView()
        .gesture(DragGesture(minimumDistance: 0)
            .onChanged { _ in }
            .onEnded { _ in }
        )
        .delayTouches()
}
Tomáš Linhart
  • 13,509
  • 5
  • 51
  • 54
  • 2
    I have found that this does work and facilitates drag gestures on a ScrollView but it is worth noting that this approach can block tap gestures/button presses on the same view as the ScrollView – Dan Barclay Feb 13 '20 at 15:53
  • Works great for me with custom sliders in a List - good workaround! – disconnectionist Mar 14 '20 at 09:33
9

I finally found a solution that seems to work with me. I have found Button to be magical creatures. They propagate events properly, and keep on working even if you are inside a ScrollView or a List.

Now, you will say

Yeah, but Michel, I don't want a friggin button that taps with some effects, I want to long-press something, or drag something.

Fair enough. But you must consider the Button of lore as something that actually makes everything underneath its label: as actually working correctly, if you know how to do things! Because the Button will actually try to behave, and delegate its gestures to controls underneath if they actually implement onTapGesture, so you can get a toggle or an info.circle button you can tap inside. In other words, All gestures that appears after the onTapGesture {} (but not the ones before) will work.

As a complex code example, what you must have is as follow:

ScrollView {
    Button(action: {}) {        // Makes everything behave in the "label:"
        content                 // Notice this uses the ViewModifier ways ... hint hint
            .onTapGesture {}    // This view overrides the Button
            .gesture(LongPressGesture(minimumDuration: 0.01)
                .sequenced(before: DragGesture(coordinateSpace: .global))
                .updating(self.$dragState) { ...

The example uses a complex gesture because I wanted to show they do work, as long as that elusive Button/onTapGesture combo are there.

Now you will notice this is not totally perfect, the long press is actually long-pressed too by the button before it delegates its long press to yours (so that example will have more than 0.01 second of long press). Also, you must have a ButtonStyle if you wish to remove the pressed effects. In other words, YMMV, a lot of testing, but for my own usage, this is the closest I've been able to make an actual long press / drag work in a List of items.

Michel Donais
  • 474
  • 4
  • 13
  • Looking over in other posts in Stack Overflow, I found this answer that does apply and uses the same technique than mine: https://stackoverflow.com/a/58643879/1060383 – Michel Donais Nov 28 '19 at 03:24
  • 1
    That’s a great found, thank you! I’ve been attaching gesture recognizers to a `Button` (and to a `NavigationLink` which is basically the same) but I haven’t tried to add `onTapGesture` first. I have to try if it works in my case and select your answer as the correct one if so. – zh. Nov 29 '19 at 14:44
  • @zh. just want to verify with you if you ever found a better solution, or improved on mine. – Michel Donais Feb 01 '20 at 02:44
  • I found that `onTapGesture {}` before gesture is enough even without the button, but sadly this also delays the gesture for me unacceptably :(. – Tomáš Kafka Jul 22 '22 at 18:22
  • @MichelDonais This is brilliant, thanks so much for sharing! Behavior-wise, this seems to be what Apple itself is using for posts in App Store's "Today" tab. – agurtovoy Dec 20 '22 at 00:11
7

I can't find a pure SwiftUI solution to this so I used a UIViewRepresentable as a work around. In the meantime, I've submitted a bug to Apple. Basically, I've created a clear view with a pan gesture on it which I will present over any SwiftUI view I want to add the gesture to. It's not a perfect solution, but maybe it's good enough for you.

public struct ClearDragGestureView: UIViewRepresentable {
    public let onChanged: (ClearDragGestureView.Value) -> Void
    public let onEnded: (ClearDragGestureView.Value) -> Void

    /// This API is meant to mirror DragGesture,.Value as that has no accessible initializers
    public struct Value {
        /// The time associated with the current event.
        public let time: Date

        /// The location of the current event.
        public let location: CGPoint

        /// The location of the first event.
        public let startLocation: CGPoint

        public let velocity: CGPoint

        /// The total translation from the first event to the current
        /// event. Equivalent to `location.{x,y} -
        /// startLocation.{x,y}`.
        public var translation: CGSize {
            return CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y)
        }

        /// A prediction of where the final location would be if
        /// dragging stopped now, based on the current drag velocity.
        public var predictedEndLocation: CGPoint {
            let endTranslation = predictedEndTranslation
            return CGPoint(x: location.x + endTranslation.width, y: location.y + endTranslation.height)
        }

        public var predictedEndTranslation: CGSize {
            return CGSize(width: estimatedTranslation(fromVelocity: velocity.x), height: estimatedTranslation(fromVelocity: velocity.y))
        }

        private func estimatedTranslation(fromVelocity velocity: CGFloat) -> CGFloat {
            // This is a guess. I couldn't find any documentation anywhere on what this should be
            let acceleration: CGFloat = 500
            let timeToStop = velocity / acceleration
            return velocity * timeToStop / 2
        }
    }

    public class Coordinator: NSObject, UIGestureRecognizerDelegate {
        let onChanged: (ClearDragGestureView.Value) -> Void
        let onEnded: (ClearDragGestureView.Value) -> Void

        private var startLocation = CGPoint.zero

        init(onChanged: @escaping (ClearDragGestureView.Value) -> Void, onEnded: @escaping (ClearDragGestureView.Value) -> Void) {
            self.onChanged = onChanged
            self.onEnded = onEnded
        }

        public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
            return true
        }

        @objc func gestureRecognizerPanned(_ gesture: UIPanGestureRecognizer) {
            guard let view = gesture.view else {
                Log.assertFailure("Missing view on gesture")
                return
            }

            switch gesture.state {
            case .possible, .cancelled, .failed:
                break
            case .began:
                startLocation = gesture.location(in: view)
            case .changed:
                let value = ClearDragGestureView.Value(time: Date(),
                                                       location: gesture.location(in: view),
                                                       startLocation: startLocation,
                                                       velocity: gesture.velocity(in: view))
                onChanged(value)
            case .ended:
                let value = ClearDragGestureView.Value(time: Date(),
                                                       location: gesture.location(in: view),
                                                       startLocation: startLocation,
                                                       velocity: gesture.velocity(in: view))
                onEnded(value)
            @unknown default:
                break
            }
        }
    }

    public func makeCoordinator() -> ClearDragGestureView.Coordinator {
        return Coordinator(onChanged: onChanged, onEnded: onEnded)
    }

    public func makeUIView(context: UIViewRepresentableContext<ClearDragGestureView>) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear

        let drag = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.gestureRecognizerPanned))
        drag.delegate = context.coordinator
        view.addGestureRecognizer(drag)

        return view
    }

    public func updateUIView(_ uiView: UIView,
                             context: UIViewRepresentableContext<ClearDragGestureView>) {
    }
}
plivesey
  • 2,337
  • 1
  • 16
  • 18
4

Delighted to see that iOS 15 brings the long awaited .swipeActions view modifier to List in SwiftUI with an easy to use API.

Sample code based on original question:

List {
        ForEach(cities) { city in
            NavigationLink(destination: ...) {
                CityRow(city: city)
            }
            .buttonStyle(BackgroundButtonStyle())
            .swipeActions(edge: .trailing, allowFullSwipe: true) {
               Button(role: .destructive) {
                    // call delete method
               } label: {
                    Label("Delete", systemImage: "trash")
               }
               Button {
                    // call flag method
               } label: {
                    Label("Flag", systemImage: "flag")
               }
            }
        }
    }

Actions appear in the order listed, starting from the originating edge working inwards.

The example above produces:

swipe actions

Note that swipeActions override the onDelete handler if provided that is available on ForEach

Read more in Apple's developer docs

Dan Barclay
  • 5,827
  • 2
  • 19
  • 22
3

I attempted to implement a similar list style in my app only to find that the gestures conflicted with the ScrollView. After having spent hours researching and attempting possible fixes and workarounds for this issue, as of XCode 11.3.1, I believe this to be a bug that Apple needs to resolve in future versions of SwiftUI.

A Github repo with sample code to replicate the issue has been put together here and has been reported to Apple with the reference FB7518403.

Here's hoping it is fixed soon!

Dan Barclay
  • 5,827
  • 2
  • 19
  • 22
2

This has been solved be @ciaranrobrien on the Hacking With Swift forums here. All credit solely to them. Most importantly, this allows a variable delay which was essential in my case.

View modifier code:

extension View {
    func delaysTouches(for duration: TimeInterval = 0.25, action: @escaping () -> Void = {}) -> some View {
        modifier(DelaysTouches(duration: duration, action: action))
    }
}

fileprivate struct DelaysTouches: ViewModifier {
    @State private var disabled = false
    private let duration: TimeInterval
    private let action: () -> Void

    init(duration: TimeInterval, action: @escaping () -> Void) {
        self.duration = duration
        self.action = action
    }

    func body(content: Content) -> some View {
        Button(action: action) {
            content
        }
        .buttonStyle(DelaysTouchesButtonStyle(
            disabled: $disabled,
            duration: duration
        ))
        .disabled(disabled)
    }
}

fileprivate struct DelaysTouchesButtonStyle: ButtonStyle {
    @Binding private var disabled: Bool
    @State private var touchDownDate: Date?
    private let duration: TimeInterval

    init(disabled: Binding<Bool>, duration: TimeInterval) {
        _disabled = disabled
        self.duration = duration
    }

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onChange(of: configuration.isPressed, perform: handleIsPressed)
    }

    private func handleIsPressed(isPressed: Bool) {
        if isPressed {
            let date = Date()
            touchDownDate = date

            DispatchQueue.main.asyncAfter(deadline: .now() + max(duration, 0)) {
                if date == touchDownDate {
                    disabled = true

                    DispatchQueue.main.async {
                        disabled = false
                    }
                }
            }
        } else {
            touchDownDate = nil
            disabled = false
        }
    }
}

Usage:

ScrollView {
    FooView()
        .delaysTouches(for: 0.2) {
            toggleHelpPrompt()
        }
        .gesture(DragGesture(minimumDistance: 0))
}
George
  • 25,988
  • 10
  • 79
  • 133
2
DragGesture(minimumDistance: 0)

The code above solved my problem

Artem Zaytsev
  • 1,621
  • 20
  • 19
0

I had a similar problem with dragging a slider at:

stackoverflow question

This is the working answer code, with the "trick" of the "DispatchQueue.main.asyncAfter"

Maybe you could try something similar for your ScrollView.

struct ContentView: View {
@State var pos = CGSize.zero
@State var prev = CGSize.zero
@State var value = 0.0
@State var flag = true

var body: some View {
    let drag = DragGesture()
        .onChanged { value in
            self.pos = CGSize(width: value.translation.width + self.prev.width, height: value.translation.height + self.prev.height)
    }
    .onEnded { value in
        self.pos = CGSize(width: value.translation.width + self.prev.width, height: value.translation.height + self.prev.height)
        self.prev = self.pos
    }
    return VStack {
        Slider(value: $value, in: 0...100, step: 1) { _ in
            self.flag = false
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                self.flag = true
            }
        }
    }
    .frame(width: 250, height: 40, alignment: .center)
    .overlay(RoundedRectangle(cornerRadius: 25).stroke(lineWidth: 2).foregroundColor(Color.black))
    .offset(x: self.pos.width, y: self.pos.height)
    .gesture(flag ? drag : nil)
}

}

  • I've tried this approach. I've added another gesture as a `simultaneousGesture` for `ScrollView`, calculating the drag direction and allowing or not the swipe gesture of child views. But there's a problem with this approach. The ScrollView scroll won't start if I cancel all the drag gestures, it will only start if those gestures are canceled before the gesture starts. – zh. Aug 30 '19 at 15:21
-5

You can use:

UIScrollView.appearance().isScrollEnabled = false
Hitarth Bhatt
  • 43
  • 1
  • 5