Applying complex gestures to a SwiftUI view

Nov 24, 2022 · Follow on Twitter and Mastodon swiftuiopen-sourcegestures

As we saw in last week’s post, complex gestures in a SwiftUI ScrollView is complicated, since they can block the scrolling. However, if we don’t have a scroll view, things become a lot easier. Let’s take a look at a version of the button that we created last week, that uses a single gesture.

If we don’t have to consider the complexities that scroll views bring, we can focus on handling different gestures in the cleanest way possible. For my needs, I need to be able 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 gesture button

Let’s start with creating a GestureButton that we will use to implement all the gesture what we need:

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, in case we want to observe the pressed state. Note that we set it to a isPressedBinding property and have a second isPressed state property that we’ll use within the view, to avoid problems if a .constant binding is provided.

We can also provide a bunch of actions and action-specific configurations, which lets define which action to trigger when a certain gesture is detected and comfigur e.g. the time it takes for a press to count as a long press. This will let us handle press, release inside, release outside, long press, double tap, repeats (press and hold), drag start, drag, drag end and gesture end. We also define a couple of Date states for some of the gestures that will need to track time in different ways.

In the body, we display the label builder result, which takes isPressed as input, then makes it sync isPressed changes to isPressedBinding. We also add accessibility traits to inform the system that this is a button. Now, let’s proceed with adding a drag gesture to the button.

Adding a drag gesture to the button

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

However, if we would wrap the label itself within a geometry reader, the button would become greedy, since the geometry reader is greedy. This would cause the button to float within a view that takes up all the available space, which is something that we absolutely don’t want.

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. Since we want the underlying view to still show, let’s just 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

As you can see, the drag gesture has an onChanged and an onEnded event, but no onStarted. This means that we’ll have to use onChanged to handle both when the 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)
                }
        )
}

As you can see, onChanged will try to handle a press whenever the drag gesture starts, but it should only be triggered once, as well as 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 the pressAction and the dragStart, after we try to trigger the long press and repeat actions.

We now call pressAction, dragStartAction and dragAction at the correct places. Let’s now 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 the longPressDelay to trigger an async operation, in which we check if longPressDate is still the same as. If so, the gesture is still active, which means that we 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 actions that trigger on a regular basis for as long as you keep the button pressed.

To handle the repeats, we will use a component called 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. Tap the link to see the source code, if you’re interested in the implementation.

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 with the repeatDate and repeatDelay 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 the releaseDate according to if the release counts as a double tap or not, call the proper release action depending on the end location and wrap it all up by calling dragEndAction and 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 see 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 configuration. If the release is a double tap, we call the doubleTapAction then return the result, which will update the releaseDate depending on if the gesture was a double tap or not.

How to trigger the correct release action

Triggering the correct release action involves using the GeometryProxy to check if the drag 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 bounds, we call releaseInsideAction otherwise we call releaseOutsideAction. With this in place, our gesture button is done. Let’s wrap up!

Conclusion

GestureButton lets you handle multiple gestures with a single button. You can detect presses, relases outside and inside, long presses, double taps, trigger repeating actions etc. all with 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.

Happy button mashing!

Discussions & More

Please share any ideas, feedback or comments you may have in the Disqus section below, or by replying to this tweet or this toot.

If you found this text interesting, make sure to follow me on Twitter and Mastodon for more content like this, and to be notified when new content is published.

If you like & want to support my work, please consider sponsoring me on GitHub Sponsors.