7

I'm trying to implement a ScrollView with elements which can be tapped and dragged. It should work the following way:

  1. The ScrollView should work normally, so swiping up/down should not interfere with the gestures.
  2. Tapping an entry should run some code. It would be great if there would be a "tap indicator", so the user knows that the tap has been registered (What makes this difficult is that the tap indicator should be triggered on touch down, not on touch up, and should be active until the finger gets released).
  3. Long pressing an entry should activate a drag gesture, so items can be moved around.

The code below covers all of those requirements (except the tap indicator). However, I'm not sure why it works, to be specific, why I need to use .highPriorityGesture and for example can't sequence the Tap Gesture and the DragGesture with .sequenced(before: ...) (that will block the scrolling).

Also, I'd like to be notified on a touch down event (not touch up, see 2.). I tried to use LongPressGesture() instead of TapGesture(), but that blocks the ScrollView scrolling as well and doesn't even trigger the DragGesture afterwards.

Does somebody know how this can be achieved? Or is this the limit of SwiftUI? And if so, would it be possible to port UIKit stuff over to achieve this (I already tried that, too, but was unsuccessful, the content of the ScrollView should also be dynamic so porting over the whole ScrollView might be difficult)?

Thanks for helping me out!

struct ContentView: View {
   
    var body: some View {
        ScrollView() {
            ForEach(0..<5, id: \.self) { i in
                ListElem()
                    .highPriorityGesture(TapGesture().onEnded({print("tapped!")}))
                    .frame(maxWidth: .infinity)
            }
        }
    }
}

struct ListElem: View {
    @GestureState var dragging = CGSize.zero
    
    var body: some View {
        Circle()
        .frame(width: 100, height: 100)
            .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
                .updating($dragging, body: {t, state, _ in
                    state = t.translation
            }))
        .offset(dragging)

    }
}
leonboe1
  • 1,004
  • 9
  • 27

1 Answers1

10

I tried a few option and I think a combination of sequenced and simultaneously allows two gestures to run the same time. To achieve a onTouchDown I used a DragGesture with minimum distance of 0.

struct ContentView: View {
    
    var body: some View {
        ScrollView() {
            ForEach(0..<5, id: \.self) { i in
                ListElem()
                    .frame(maxWidth: .infinity)
            }
        }
    }
}

struct ListElem: View {
    
    @State private var offset = CGSize.zero
    @State private var isDragging = false
    @GestureState var isTapping = false
    
    var body: some View {
        
        // Gets triggered immediately because a drag of 0 distance starts already when touching down.  
        let tapGesture = DragGesture(minimumDistance: 0)
            .updating($isTapping) {_, isTapping, _ in
                isTapping = true
            }

        // minimumDistance here is mainly relevant to change to red before the drag
        let dragGesture = DragGesture(minimumDistance: 0)
            .onChanged { offset = $0.translation }
            .onEnded { _ in
                withAnimation {
                    offset = .zero
                    isDragging = false
                }
            }
        
        let pressGesture = LongPressGesture(minimumDuration: 1.0)
            .onEnded { value in
                withAnimation {
                    isDragging = true
                }
            }
        
        // The dragGesture will wait until the pressGesture has triggered after minimumDuration 1.0 seconds.
        let combined = pressGesture.sequenced(before: dragGesture)
        
        // The new combined gesture is set to run together with the tapGesture.
        let simultaneously = tapGesture.simultaneously(with: combined)
        
        return Circle()
            .overlay(isTapping ? Circle().stroke(Color.red, lineWidth: 5) : nil) //listening to the isTapping state
            .frame(width: 100, height: 100)
            .foregroundColor(isDragging ? Color.red : Color.black) // listening to the isDragging state.
            .offset(offset)
            .gesture(simultaneously)
        
    }
}

For anyone interested here is a custom scroll view that will not be blocked by other gestures as mentioned in one of the comments. As this was not possible to be solved with the standard ScrollView.

OpenScrollView for SwiftUI on Github

Credit to

https://stackoverflow.com/a/59897987/12764795 http://developer.apple.com/documentation/swiftui/composing-swiftui-gestures https://www.hackingwithswift.com/books/ios-swiftui/how-to-use-gestures-in-swiftui

Marco Boerner
  • 1,243
  • 1
  • 11
  • 34
  • 1
    Wow, works really well! Thank you! Now I just need to understand why it works, thanks for providing the links :D – leonboe1 Oct 29 '20 at 13:50
  • I added a few comments. : ) – Marco Boerner Oct 29 '20 at 14:05
  • 2
    Thanks! However, there is one caveat to this approach: The scrolling of the ScrollView gets blocked. ScrollView is really annoying. I think I'm going to reimplement it from the ground up in a few weeks. – leonboe1 Oct 29 '20 at 14:09
  • Can you explain when exactly the scrolling is blocked? Do you mean when you're clicking one of those circles but before you're dragging? – Marco Boerner Oct 29 '20 at 14:54
  • What I mean is if you try to move the contents of the scroll view up/down by swiping up/down, that doesn’t work if the swipe gesture starts on on of the circles. – leonboe1 Oct 29 '20 at 15:11
  • I see what you mean, in fact when you run it on the device it works when you first scroll somewhere outside of the dots, then you can also scoll the dots too. But the moment you have triggered any of the dot's gestures you cannot anymore. It seems as if it's changing focus somehow. I tried using `.simultaneousGesture(simultaneously, including: GestureMask.all)` instead of `.gesture(simultaneously)` and option with `.allowsHitTesting()` but no luck so far. – Marco Boerner Oct 29 '20 at 19:50
  • That’s what I discovered, too. It seems to be impossible to have a simultaneous Drag gesture in ScrollView. – leonboe1 Oct 29 '20 at 20:46
  • With UIKit you had the option to scroll another scrollview programmatically to an exact position, but SwiftUI only seems to have methods to only scroll to set positions of subviews programmatically. Waiting on Apple on this one I guess. – Marco Boerner Oct 29 '20 at 20:56