swiftuidraggesture

Interaction of DragGesture and ScrollView in SwiftUI


In an app I'm working on, there is a part that has, mostly, a "forward" navigation – tapping on buttons would display the next slide. However, a secondary "backward" navigation is also necessary. Here's the approach I've used:

import SwiftUI

struct Sample: View {
    @State private var dragOffset: CGFloat = -100
    var body: some View {
        VStack {

            Text("Perhaps a title")

            ScrollView {
                VStack {
                    Text("Some scrollable content is going to be here")

                    // ...

                    Button(action: {
                        // Go to the next slide
                    }) { Text("Next") }
                }
            }

            Text("and, maybe, something else")
        }
        .overlay(
            Image(systemName: "arrow.left").offset(x: dragOffset / 2),
            alignment: .leading
        )
        .gesture(
            DragGesture()
                .onChanged{
                    self.dragOffset = $0.translation.width
                }
                .onEnded {
                    self.dragOffset = -100 // Hide the arrow

                    if $0.translation.width > 100 {
                        // Go to the previous slide
                    }
                }
        )
    }
}

There is a small indicator (left arrow) that is, initially, hidden (dragOffset = -100). When the drag gesture begins, offset is fed into the dragOffset state variable and that, effectively, shows the arrow. When drag gesture ends, the arrow is hidden again and, if a certain offset is reached, the previous slide is displayed.

Works well enough, except, when the user scrolls the content in the ScrollView, this gesture is also triggered and updated for a while but then is, I assume, cancelled by the ScrollView and the "onEnded" is not called. As a result, the arrow indicator stays on the screen.

Hence the question: what is the correct way to do a gesture like that, that would work together with a ScrollView? Is it even possible with the current state of SwiftUI?


Solution

  • For such temporary states it is better to use GestureState as it is automatically reset to initial state after gesture cancels/finished.

    So here is possible approach

    Update: retested with Xcode 13.4 / iOS 15.5

    Demo:

    enter image description here

    Code:

    struct Sample: View {
        @GestureState private var dragOffset: CGFloat = -100
        var body: some View {
            VStack {
    
                Text("Perhaps a title")
    
                ScrollView {
                    VStack {
                        Text("Some scrollable content is going to be here")
    
                        // ...
    
                        Button(action: {
                            // Go to the next slide
                        }) { Text("Next") }
                    }
                }
    
                Text("and, maybe, something else")
            }
            .overlay(
                Image(systemName: "arrow.left").offset(x: dragOffset / 2),
                alignment: .leading
            )
            .gesture(
                DragGesture()
                    .updating($dragOffset) { (value, gestureState, transaction) in
                        let delta = value.location.x - value.startLocation.x
                        if delta > 10 { // << some appropriate horizontal threshold here
                            gestureState = delta
                        }
                    }
                    .onEnded {
                        if $0.translation.width > 100 {
                            // Go to the previous slide
                        }
                    }
            )
        }
    }
    

    Note: dragOffset: CGFloat = -100 this might have different effect on different real devices, so probably it is better to calculate it explicitly.