55

I'm getting into building Apple Watch apps.

What I'm currently working on will require me to make use of detecting swipes in the four main directions (UP, DOWN, LEFT and RIGHT)

The problem is I have no idea how to detect this. I've been looking around and I'm reaching dead ends.

What can I do to my view below to just print swiped up when the user swipes UP on the view?

struct MyView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

Thanks.

Barry Michael Doyle
  • 9,333
  • 30
  • 83
  • 143

9 Answers9

67

You could use DragGesture

.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .onEnded({ value in
                        if value.translation.width < 0 {
                            // left
                        }

                        if value.translation.width > 0 {
                            // right
                        }
                        if value.translation.height < 0 {
                            // up
                        }

                        if value.translation.height > 0 {
                            // down
                        }
                    }))
Sorin Lica
  • 6,894
  • 10
  • 35
  • 68
53

With the other solutions being a bit inconsistent on a physical device, I decided to come up with another one that seems to be much more consistent across different screen sizes as there are no hardcoded values except for the minimumDistance.

.gesture(DragGesture(minimumDistance: 20, coordinateSpace: .global)
            .onEnded { value in
                let horizontalAmount = value.translation.width
                let verticalAmount = value.translation.height
                
                if abs(horizontalAmount) > abs(verticalAmount) {
                    print(horizontalAmount < 0 ? "left swipe" : "right swipe")
                } else {
                    print(verticalAmount < 0 ? "up swipe" : "down swipe")
                }
            })
Twitter khuong291
  • 11,328
  • 15
  • 80
  • 116
Fynn Becker
  • 539
  • 4
  • 3
34

Based on Benjamin's answer this is a swiftier way to handle the cases

.gesture(DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
    .onEnded { value in
        print(value.translation)
        switch(value.translation.width, value.translation.height) {
            case (...0, -30...30):  print("left swipe")
            case (0..., -30...30):  print("right swipe")
            case (-100...100, ...0):  print("up swipe")
            case (-100...100, 0...):  print("down swipe")
            default:  print("no clue")
        }
    }
)
vadian
  • 274,689
  • 30
  • 353
  • 361
32

If you want one that is more "forgiving" to the directionality of the swipe, you can use a few more conditionals to help even it out:

EDIT: did some more testing, apparently the values for the second conditional add some confusion, so I adjusted them to remove said confusion and make the gesture bulletproof (drags to the corners will now come up with "no clue" instead of one of the gestures)...

let detectDirectionalDrags = DragGesture(minimumDistance: 3.0, coordinateSpace: .local)
.onEnded { value in
    print(value.translation)
    
    if value.translation.width < 0 && value.translation.height > -30 && value.translation.height < 30 {
        print("left swipe")
    }
    else if value.translation.width > 0 && value.translation.height > -30 && value.translation.height < 30 {
        print("right swipe")
    }
    else if value.translation.height < 0 && value.translation.width < 100 && value.translation.width > -100 {
        print("up swipe")
    }
    else if value.translation.height > 0 && value.translation.width < 100 && value.translation.width > -100 {
        print("down swipe")
    }
    else {
        print("no clue")
    }
Benjamin B.
  • 521
  • 7
  • 15
  • Where should one put this variable in their view? – Soylent Graham Dec 17 '20 at 12:13
  • @soylentgraham put this in the struct between the var body: some View { and the return...by default, swiftUI (if you don't have a return statement in this section) will return the first swiftui element (first ZStack/HStack/etc), but if you put "return" before your first swiftui element, you can add variables that would normally give you an error if added before the var body some view... – Benjamin B. Dec 17 '20 at 20:29
  • 7
    I'm shocked that defining behavior like this requires what seems to be very explicit construction, and error-prone construction. Does the swiping "feel nice" on both landscape and portrait orientations? This seems like a big hole in the API. – neuralmer Feb 02 '21 at 21:43
  • What are the best translation widths/heights for swiping left, right, up and down and how to make that "relative" to the device size (compact/regular) used? Here fixed with 30/100 but is this the same Apple uses for their apps? – JeanNicolas Jul 08 '21 at 14:47
  • I just want to echo the question that @JeanNicolas asked above. Apple's documentation offers no help in determining what the number 30 or 100 in the presented solutions really means - or what these values should be to gain useful functionality across all Apple devices. – KeithB Jun 14 '23 at 22:01
7

Create an extension:

extension View {
    func swipe(
        up: @escaping (() -> Void) = {},
        down: @escaping (() -> Void) = {},
        left: @escaping (() -> Void) = {},
        right: @escaping (() -> Void) = {}
    ) -> some View {
        return self.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
            .onEnded({ value in
                if value.translation.width < 0 { left() }
                if value.translation.width > 0 { right() }
                if value.translation.height < 0 { up() }
                if value.translation.height > 0 { down() }
            }))
    }
}

Then:

            Image() // or any View
                .swipe(
                    up: {
                        // action for up
                    },
                    right: {
                        // action for right
                    })

Notice that each direction is an optional parameter

4

I would create a modifier for simplicity. Usage will look like that:

yourView
        .onSwiped(.down) {
            // Action for down swipe
        }

OR

yourView
        .onSwiped { direction in 
            // React to detected swipe direction
        }

You can also use trigger parameter in order to configure receiving updates: continuously or only when the gesture ends.

Here's the full code:

struct SwipeModifier: ViewModifier {
    enum Directions: Int {
        case up, down, left, right
    }

    enum Trigger {
        case onChanged, onEnded
    }

    var trigger: Trigger
    var handler: ((Directions) -> Void)?

    func body(content: Content) -> some View {
        content.gesture(
            DragGesture(
                minimumDistance: 24,
                coordinateSpace: .local
            )
            .onChanged {
                if trigger == .onChanged {
                    handle($0)
                }
            }.onEnded {
                if trigger == .onEnded {
                    handle($0)
                }
            }
        )
    }

    private func handle(_ value: _ChangedGesture<DragGesture>.Value) {
        let hDelta = value.translation.width
        let vDelta = value.translation.height

        if abs(hDelta) > abs(vDelta) {
            handler?(hDelta < 0 ? .left : .right)
        } else {
            handler?(vDelta < 0 ? .up : .down)
        }
    }
}

extension View {
    func onSwiped(
        trigger: SwipeModifier.Trigger = .onChanged,
        action: @escaping (SwipeModifier.Directions) -> Void
    ) -> some View {
        let swipeModifier = SwipeModifier(trigger: trigger) {
            action($0)
        }
        return self.modifier(swipeModifier)
    }
    func onSwiped(
        _ direction: SwipeModifier.Directions,
        trigger: SwipeModifier.Trigger = .onChanged,
        action: @escaping () -> Void
    ) -> some View {
        let swipeModifier = SwipeModifier(trigger: trigger) {
            if direction == $0 {
                action()
            }
        }
        return self.modifier(swipeModifier)
    }
}
3

This is much more responsive:

.gesture(
    DragGesture()
        .onEnded { value in
            
            let pi = Double.pi
            
            let direction = atan2(value.translation.width, value.translation.height)
            
            switch direction {
            case (-pi/4..<pi/4): print("down swipe")
            case (pi/4..<pi*3/4): print("right swipe")
            case (pi*3/4...pi), (-pi..<(-pi*3/4)):
                print("up swipe")
            case (-pi*3/4..<(-pi/4)): print("left swipe")
            default:
                print("no clue")
            }
        }

Explanation:

  • Drag gesture returns a vector of change as value.translation
  • This method then uses atan2 to find the direction of that vector as follows

Based on how value.translation is returned, the values of atan2 will be as follows:

  • π would match an ideal "up" gesture
  • -π/2 is an ideal "left" gesture
  • 0.0 would match an ideal "down" gesture
  • π/2 is an ideal "right" gesture

So now we want to split the circle into 4 quarters, with each quarter including each value above in the middle. For instance (-π/4...π/4) would include any value that could be considered an approximation of a "down" direction.

timbre timbre
  • 12,648
  • 10
  • 46
  • 77
  • Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community May 25 '22 at 11:56
2

Little bit late to this, but here's another implementation which uses OptionSet to make its use a bit more like various other SwiftUI components -

struct Swipe: OptionSet, Equatable {
    
    init(rawValue: Int) {
        self.rawValue = rawValue
    }
    
    let rawValue: Int
    
    fileprivate var swiped: ((DragGesture.Value, Double) -> Bool) = { _, _ in false } // prevents a crash if someone creates a swipe directly using the init
    
    private static let sensitivityFactor: Double = 400 // a fairly arbitrary figure which gives a reasonable response
    
    static var left: Swipe {
        var swipe = Swipe(rawValue: 1 << 0)
        swipe.swiped = { value, sensitivity in
            value.translation.width < 0 && value.predictedEndTranslation.width < sensitivity * sensitivityFactor
        }
        return swipe
    }

    static var right: Swipe {
        var swipe = Swipe(rawValue: 1 << 1)
        swipe.swiped = { value, sensitivity in
            value.translation.width > 0 && value.predictedEndTranslation.width > sensitivity * sensitivityFactor
        }
        return swipe
    }

    static var up: Swipe {
        var swipe = Swipe(rawValue: 1 << 2)
        swipe.swiped = { value, sensitivity in
            value.translation.height < 0 && value.predictedEndTranslation.height < sensitivity * sensitivityFactor
        }
        return swipe
    }

    static var down: Swipe {
        var swipe = Swipe(rawValue: 1 << 3)
        swipe.swiped = { value, sensitivity in
            value.translation.height > 0 && value.predictedEndTranslation.height > sensitivity * sensitivityFactor
        }
        return swipe
    }
    
    static var all: Swipe {
        [.left, .right, .up, .down]
    }
    
    private static var allCases: [Swipe] = [.left, .right, .up, .down]
    
    fileprivate var array: [Swipe] {
        Swipe.allCases.filter { self.contains($0) }
    }
}

extension View {
    
    func swipe(_ swipe: Swipe, sensitivity: Double = 1, action: @escaping (Swipe) -> ()) -> some View {
        
        return gesture(DragGesture(minimumDistance: 30, coordinateSpace: .local)
            .onEnded { value in
                swipe.array.forEach { swipe in
                    if swipe.swiped(value, sensitivity) {
                        action(swipe)
                    }
                }
            }
        )
    }
}

In a SwiftUI view -

HStack {
    // content
}
.swipe([.left, .right]) { swipe in // could also be swipe(.left) or swipe(.all), etc
    doSomething(with: swipe)
}

Obviously the logic for detecting swipes is a bit basic, but that's easy enough to tailor to your requirements.

SomaMan
  • 4,127
  • 1
  • 34
  • 45
1
import SwiftUI

struct SwipeModifier: ViewModifier {

let rightToLeftAction: () -> ()
let leftToRightAction: () -> ()

func body(content: Content) -> some View {
    content
        .gesture(DragGesture(minimumDistance: 20, coordinateSpace: .global)
            .onEnded { value in
                let horizontalAmount = value.translation.width
                let verticalAmount = value.translation.height
                
                guard abs(horizontalAmount) > abs(verticalAmount) else {
                    return
                }
                
                withAnimation {
                    horizontalAmount < 0 ? rightToLeftAction() :  leftToRightAction()
                }
            })
    }
}

extension View {
  func swipe(rightToLeftAction: @escaping () -> (), leftToRightAction: @escaping () -> ()) -> some View {
     modifier(SwipeModifier(rightToLeftAction: rightToLeftAction, leftToRightAction: leftToRightAction))
   }
}

Improved version of Fynn Becker's answer.