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.