GO FURTHER, FASTER: Try the Swift Career Accelerator today! >>

A guide to delaying gestures in ScrollView

Forums > SwiftUI

Motivation

A gesture inside a scrolling view will hijack scrolling if the gesture is configured to be recognised first:

ScrollView {
    FooView()
        .gesture(DragGesture(minimumDistance: 0))
}

Preventing scrolling in certain areas would result in a poor user experience. In a real app this could be a custom interactive chart in a ScrollView. See an example here.

It's not always feasible to configure the gesture differently. In this example we need the location of the user's initial touch, so increasing the minimumDistance is not an option. In UIKit we could handle this by setting delaysContentTouches on the UIScrollView; SwiftUI does not have a similar option.

Solution 1

Many system controls such as Button, Menu and Picker have magic behind the scenes to delay touches in scrolling views. We can use onTapGesture to apply this magic to custom views:

ScrollView {
    FooView()
        .onTapGesture {}
        .gesture(DragGesture(minimumDistance: 0))
}

Unfortunately this doesn't provide control over how long the gesture is delayed for. With this solution the user has to hold down for 1 second before the gesture is recognised, which is too long for the example above.

Solution 2

As mentioned, Button also provides the same magic behaviour. We can create a ViewModifier that uses Button and a custom ButtonStyle to control how long the gesture is delayed for. In use:

ScrollView {
    FooView()
        .delaysTouches(for: 0.2) {
            toggleHelpPrompt()
        }
        .gesture(DragGesture(minimumDistance: 0))
}

We can change the duration to any non-negative TimeInterval to control the delay. We can also provide a function to be called when the user taps. Neither of these parameters are required. The implementation:

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

fileprivate struct DelaysTouches: ViewModifier {
    @State private var disabled = false
    @State private var touchDownDate: Date? = nil

    var duration: TimeInterval
    var action: () -> Void

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

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

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

This requires iOS 14 as it uses the onChange modifier, however I imagine this could be rewritten for iOS 13 by using onPreferenceChange instead.

4      

Hacking with Swift is sponsored by Essential Developer.

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until September 29th.

Click to save your spot

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.