Applying complex gestures to a SwiftUI view

Nov 24, 2022 · Follow on Twitter and Mastodon

As we saw in last week’s post, gestures in a ScrollView are complicated, since they can block the scrolling. Without a scroll view, things become a lot easier. Let’s take a look.

Update 2024-09-02

The new GestureButton open-source project contains many improvements, so check it out for code samples and source-code that is adjusted for the future.

Less complexity

If we don’t have to consider the scroll view complexities, we can focus on handling estures in the cleanest way possible. For my needs, I need to handle the following gestures:

  • Presses
  • Releases (inside and outside)
  • Long presses
  • Hold presses
  • Double taps
  • Drag started
  • Drag changed
  • Drag ended
  • Gesture ended

As you will see, we will be able to detect all these gestures by using a single drag gesture. Let’s start with creating a view to which we can apply all these gestures.

Creating a ScrollView-incompatible gesture button

Let’s start with creating a GestureButton that we will use to implement all these gestures:

public struct GestureButton<Label: View>: View {

    init(
        isPressed: Binding<Bool>? = nil,
        pressAction: Action? = nil,
        releaseInsideAction: Action? = nil,
        releaseOutsideAction: Action? = nil,
        longPressDelay: TimeInterval = GestureButtonDefaults.longPressDelay,
        longPressAction: Action? = nil,
        doubleTapTimeout: TimeInterval = GestureButtonDefaults.doubleTapTimeout,
        doubleTapAction: Action? = nil,
        repeatDelay: TimeInterval = GestureButtonDefaults.repeatDelay,
        repeatTimer: RepeatGestureTimer = .shared,
        repeatAction: Action? = nil,
        dragStartAction: DragAction? = nil,
        dragAction: DragAction? = nil,
        dragEndAction: DragAction? = nil,
        endAction: Action? = nil,
        label: @escaping LabelBuilder
    ) {
        self.isPressedBinding = isPressed ?? .constant(false)
        self.pressAction = pressAction
        self.releaseInsideAction = releaseInsideAction
        self.releaseOutsideAction = releaseOutsideAction
        self.longPressDelay = longPressDelay
        self.longPressAction = longPressAction
        self.doubleTapTimeout = doubleTapTimeout
        self.doubleTapAction = doubleTapAction
        self.repeatDelay = repeatDelay
        self.repeatTimer = repeatTimer
        self.repeatAction = repeatAction
        self.dragStartAction = dragStartAction
        self.dragAction = dragAction
        self.dragEndAction = dragEndAction
        self.endAction = endAction
        self.label = label
    }

    public typealias Action = () -> Void
    public typealias DragAction = (DragGesture.Value) -> Void
    public typealias LabelBuilder = (_ isPressed: Bool) -> Label

    var isPressedBinding: Binding<Bool>

    let pressAction: Action?
    let releaseInsideAction: Action?
    let releaseOutsideAction: Action?
    let longPressDelay: TimeInterval
    let longPressAction: Action?
    let doubleTapTimeout: TimeInterval
    let doubleTapAction: Action?
    let repeatDelay: TimeInterval
    let repeatTimer: RepeatGestureTimer
    let repeatAction: Action?
    let dragStartAction: DragAction?
    let dragAction: DragAction?
    let dragEndAction: DragAction?
    let endAction: Action?
    let label: LabelBuilder

    @State
    private var isPressed = false

    @State
    private var longPressDate = Date()

    @State
    private var releaseDate = Date()

    @State
    private var repeatDate = Date()

    public var body: some View {
        label(isPressed)
            .onChange(of: isPressed) { isPressedBinding.wrappedValue = $0 }
            .accessibilityAddTraits(.isButton)
    }
}

Wow, that’s a pretty huge initializer. Feel free to group the parameters if you see fit, but I’ve chosen to keep it like this for simplicity. Let’s take a look at the parameters.

The initializer lets us provide an isPressed binding to observe the pressed state. Note that we set it to an isPressedBinding property and have a second isPressed state that we use to avoid problems if a .constant binding is provided.

We can provide a bunch of actions and configurations to handle press, release inside, release outside, long press, double tap, repeats (press and hold), drag start, drag, drag end and gesture end in a clean way.

In the body, we display the label builder result. It takes isPressed as input, then makes it sync isPressed changes to isPressedBinding. We also add accessibility traits to tell the system that this is a button.

Adding a drag gesture to the button

To detect whether a press is released inside or outside of the button’s bounds, we use a GeometryReader that will wrap the view to which the gestures are applied.

However, applying the GeometryReader to the button label would make the button greedy, which would cause it to float within a view that takes up all the available space.

To avoid this, we can keep the button view as is, and instead add the geometry reader as an overlay, then apply the drag gesture to a view within the reader.

Let’s start with defining this gestureView:

private extension GestureButton {

    var gestureView: some View {
        GeometryReader { geo in
            EmptyView() // We'll add the correct view soon
        }
    }
}

then add the view as an overlay to the button label:

var body: some View {
    label(isPressed)
        .overlay(gestureView)
        .onChange(of: isPressed) { isPressedBinding.wrappedValue = $0 }
        .accessibilityAddTraits(.isButton)
}

We can now replace the empty view with a view with gestures. Let’s use Color.clear and specify a .contentShape to it to make it detect taps, then add a DragGesture to it:

var gestureView: some View {
    Color.clear
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { _ in /* Let's add code here */ }
                .onEnded { _ in /* ...and here */ }
        )
}

With the gesture in place, we can now start to implement the various actions that we want to be able to trigger with this single gesture.

Implementing gesture actions

The drag gesture has an onChanged and an onEnded event, but no onStarted. This means that we have to use onChanged to handle both when a gesture starts and when it changes.

Let’s define two functions for trying to handle presses and releases:

private extension GestureButton {

    func tryHandlePress(_ value: DragGesture.Value) {
        /* Let's add code here */
    }

    func tryHandleRelease(_ value: DragGesture.Value, in geo: GeometryProxy) {
        /* ...and here */
    }
}

We can now call these functions from the drag gesture above:

var gestureView: some View {
    Color.clear
        .contentShape(Rectangle())
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { value in
                    tryHandlePress(value)
                    dragAction?(value)
                }
                .onEnded { value in 
                    tryHandleRelease(value, in: geo)
                }
        )
}

onChanged will try to handle a press whenever the drag gesture starts, but it should only be triggered once and also call the dragAction. onEnded will only try to handle the release.

Handling presses

Trying to handle presses involves the following operations:

func tryHandlePress(_ value: DragGesture.Value) {
    if isPressed { return }
    isPressed = true
    pressAction?()
    dragStartAction?(value)
    tryTriggerLongPressAfterDelay()
    tryTriggerRepeatAfterDelay()
}

Since we should only handle presses once, we abort if isPressed is true. If not, we set it to true, call pressAction & dragStart, after triggering the long press and repeat actions.

We now call pressAction, dragStartAction & dragAction at the correct places. Let’s look at how to trigger a long press.

How to trigger a long press

Trying to trigger a long press involves the following operations:

func tryTriggerLongPressAfterDelay() {
    guard let action = longPressAction else { return }
    let date = Date()
    longPressDate = date
    let delay = longPressDelay
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
        guard self.longPressDate == date else { return }
        action()
    }
}

We first check that we have a longPressAction, otherwise we abort the operation. If we have one, we take the current date and set the longPressDate to it.

We then use longPressDelay to trigger an async operation that check if longPressDate is still the same. If so, the gesture is still active, and should trigger the longPressAction.

How to trigger a repeating action

With the long press taken care of, let’s look at how to handle repeats, which are the actions that trigger on a regular basis for as long as you keep the button pressed.

To handle the repeats, we will use a RepatGestureTimer, which is a simple class that starts calling an action with a certain interval when it’s started, then stops when it’s stopped.

Trying to trigger a repeating action involve the following operations:

func tryTriggerRepeatAfterDelay() {
    guard let action = repeatAction else { return }
    let date = Date()
    repeatDate = date
    let delay = repeatDelay
    DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
        guard self.repeatDate == date else { return }
        repeatTimer.start(action: action)
    }
}

Just like with long presses, we check that we have a repeatAction, otherwise we abort the operation. If we have one, we trigger a delay and start the repeatTimer if the gesture is still active after the delay.

That’s all we need to do when we press the button. Let’s now look how to handle when the drag gesture eventually ends.

Handling releases

Trying to handle releases involves the following operations:

func tryHandleRelease(_ value: DragGesture.Value, in geo: GeometryProxy) {
    if !isPressed { return }
    isPressed = false
    longPressDate = Date()
    repeatDate = Date()
    repeatTimer.stop()
    releaseDate = tryTriggerDoubleTap() ? .distantPast : Date()
    if geo.contains(value.location) {
        releaseInsideAction?()
    } else {
        releaseOutsideAction?()
    }
    dragEndAction?(value)
    endAction?()
}

Since we should only handle releases once, we abort if isPressed is false. If not, we set it to false, then reset the longPressDate and repeatDate and stop the repeatTimer.

We then update releaseDate according to if the release counts as a double tap or not, call the proper release action based on the end location then call dragEndAction & endAction.

How to trigger a double tap

Trying to trigger a double tap involves the following operations:

func tryTriggerDoubleTap() -> Bool {
    let interval = Date().timeIntervalSince(releaseDate)
    let isDoubleTap = interval < doubleTapTimeout
    if isDoubleTap { doubleTapAction?() }
    return isDoubleTap
}

We use the releaseDate that we update in tryHandleRelease to check how long time that has passed since the last release.

The release counts as a double tap if the time is less than the doubleTapTimeout. If so, we call the doubleTapAction and return the result, which updates the releaseDate accordingly.

How to trigger the correct release action

Triggering the correct release action involves using the GeometryProxy to check if a gesture was released inside or outside of the view’s bounds. To do this, we can use this extension:

private extension GeometryProxy {

    func contains(_ dragEndLocation: CGPoint) -> Bool {
        let x = dragEndLocation.x
        let y = dragEndLocation.y
        guard x > 0, y > 0 else { return false }
        guard x < size.width, y < size.height else { return false }
        return true
    }
}

And that’s it! If the release is done within the view, we call releaseInsideAction otherwise we call releaseOutsideAction. With this in place, our gesture button is done.

Conclusion

GestureButton lets you handle multiple gestures with a single button. You can detect many different type of gestures, using a single DragGesture.

I have added GestureButton to my SwiftUIKit library. You can find the source code here. If you decide to give it a try, I’d be very interested in hearing what you think.

Discussions & More

If you found this interesting and would like to share your thoughts, please comment in the Disqus section below or reply to this tweet or this toot.

Follow on Twitter and Mastodon to be notified when new content & articles are published.