mrsimply
mrsimply

Reputation: 533

How to create haptic feedback for a Button in SwiftUI

I'm trying to implement haptic feedback at the beginning of a tap for a button in SwiftUI. Therefore I'm trying to use simultaneousGesture, but I'm sill struggling; I can't manage to figure out when the tap begins.

Also there is no haptic feedback implemented for Swift UI, so I guess I would blend it in from UIKit?

I tried to implement the updating method of the TapGesture but it does not seem to do anything. This is what I've got so far.

struct HapticButton : View {
    
    @GestureState var isDetectingTap = false

    var body: some View {
        
        let tap = TapGesture()
            .updating($isDetectingTap) { (body, stateType, transaction) in
                // nothing happens below
                print(body)
                print(stateType)
                print(transaction)
            }.onEnded { _ in
                // this one works but it is to late
                // I need to figure out the beginning of the tap
                print("Button was tapped, will invoke haptic feedback, maybe with UIKit")
        }
        
        return Button(action: {
            print("Action executed")
        }) {
            HStack {
                Image("icon")
                Text("Login")
            }
        }.simultaneousGesture(tap)
    }
}

Upvotes: 52

Views: 39187

Answers (8)

Mojtaba Hosseini
Mojtaba Hosseini

Reputation: 119292

iOS 17

From iOS 17, you can use a native SwiftUI view modifier called sensoryFeedback like:

.sensoryFeedback(.success, trigger: <#YourTrigger#>)

It also has overloads to give you more control and you can return an impact like:

.sensoryFeedback(trigger: randomNumber) { old, new in
    .impact(flexibility: .solid, intensity: <#amount#>)
}

Upvotes: 16

Joemama
Joemama

Reputation: 1

I think the button doesn’t have a property for detecting before the button is let go, so my idea is to create a View that acts as a button instead, so the haptic is delivered as soon as the button is tapped and not after you let go. Maybe try that! This also enables you to create an animation that is played as soon as the button is pressed, rather than after, and it also prevents that weird glitchy button thing. It’s a shame that Apple doesn’t support something as simple as this......

Upvotes: 0

damd
damd

Reputation: 6957

I've made a couple of extensions that let you use Button or onTapGesture just normally, adding some impact feedback style.

Example usage:

Button(.heavy) {
  something()
} label: {
  Text("Beam me up, Scotty!")
}

VStack {
  Text("Yo yo yo")
  Text("What uuup!")
}.onTapGesture(.heavy) { something() }

Here's the code, it's pretty self-explanatory, just a simple higher-order function which generates some feedback and then calls a given function. Then that function is basically bundled up nicely into a ViewModifier for onTapGesture, and an extra constructor for Button.

import SwiftUI

func withFeedback(
  _ style: UIImpactFeedbackGenerator.FeedbackStyle,
  _ action: @escaping () -> Void
) -> () -> Void {
  { () in
    let impact = UIImpactFeedbackGenerator(style: style)
    impact.prepare()
    impact.impactOccurred()
    action()
  }
}

struct HapticTapGestureViewModifier: ViewModifier {
  var style: UIImpactFeedbackGenerator.FeedbackStyle
  var action: () -> Void

  func body(content: Content) -> some View {
    content.onTapGesture(perform: withFeedback(self.style, self.action))
  }
}

extension View {
  func onTapGesture(
    _ style: UIImpactFeedbackGenerator.FeedbackStyle,
    perform action: @escaping () -> Void
  ) -> some View {
    modifier(HapticTapGestureViewModifier(style: style, action: action))
  }
}

extension Button {
  init(
    _ style: UIImpactFeedbackGenerator.FeedbackStyle,
    action: @escaping () -> Void,
    @ViewBuilder label: () -> Label
  ) {
    self.init(action: withFeedback(style, action), label: label)
  }
}

Upvotes: 3

umayanga
umayanga

Reputation: 2764

As I know, this is the most simplest way to get haptic feedback in SwiftUI

When tapping a button :

Button(action: {
    let impactMed = UIImpactFeedbackGenerator(style: .medium)
    impactMed.impactOccurred()
}) {
    Text("This is a Button")
}

You can change the intensity of the haptic feedback by replacing .medium with .soft, .light, .heavy, or .rigid

or when tapping anything else :

.onTapGesture {
    let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
    impactHeavy.impactOccurred()
}

If you want to make something like Haptic Touch, replace .onTapGesture with .onLongPressGesture like this

.onLongPressGesture {
    let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
    impactHeavy.impactOccurred()
}

Upvotes: 96

rbaldwin
rbaldwin

Reputation: 4858

You can also encapsulate the Haptic generator in a singleton:

import UIKit

class Haptics {
    static let shared = Haptics()
    
    private init() { }

    func play(_ feedbackStyle: UIImpactFeedbackGenerator.FeedbackStyle) {
        UIImpactFeedbackGenerator(style: feedbackStyle).impactOccurred()
    }
    
    func notify(_ feedbackType: UINotificationFeedbackGenerator.FeedbackType) {
        UINotificationFeedbackGenerator().notificationOccurred(feedbackType)
    }
}

Callsite:

// As a button action
Button(action: {
    // some action
    Haptics.shared.play(.heavy)
}, label: {
    Text("Button")
})

// As a tap gesture on a View
SomeView()
.onTapGesture(perform: {
    Haptics.shared.play(.light)
})

// In a function
func someFunc() {
    Haptics.shared.notify(.success)
}

Options:

Haptics.shared.play(.heavy)
Haptics.shared.play(.light)
Haptics.shared.play(.medium)
Haptics.shared.play(.rigid)
Haptics.shared.play(.soft)
                
Haptics.shared.notify(.error)
Haptics.shared.notify(.success)
Haptics.shared.notify(.warning)

Upvotes: 27

Chris
Chris

Reputation: 2907

I have a wrapper view that plays a haptic event, and then calls the action:

struct HapticButton: View {
  let content: String
  let action: () -> Void

  init(_ content: String, _ action: @escaping () -> Void) {
    self.content = content
    self.action = action
  }

  var body: some View {
    
    Button(content) {
        HapticService.shared.play(event: .buttonTap)
        self.action()
    }
  }
}

Used like:

HapticButton("Press me") { print("hello") } // plays haptic and prints

Upvotes: -2

average Joe
average Joe

Reputation: 4605

pure SwiftUI solution below:

HStack {
    Image("icon")
    Text("Login")
}
.onTouchGesture(
    touchBegan: { self.generateHapticFeedback = true },
    touchEnd: { _ in self.generateHapticFeedback = false }
)

Just add this snippet somewhere in your project:

struct TouchGestureViewModifier: ViewModifier {
    let touchBegan: () -> Void
    let touchEnd: (Bool) -> Void

    @State private var hasBegun = false
    @State private var hasEnded = false

    private func isTooFar(_ translation: CGSize) -> Bool {
        let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
        return distance >= 20.0
    }

    func body(content: Content) -> some View {
        content.gesture(DragGesture(minimumDistance: 0)
                .onChanged { event in
                    guard !self.hasEnded else { return }

                    if self.hasBegun == false {
                        self.hasBegun = true
                        self.touchBegan()
                    } else if self.isTooFar(event.translation) {
                        self.hasEnded = true
                        self.touchEnd(false)
                    }
                }
                .onEnded { event in
                    if !self.hasEnded {
                        let success = !self.isTooFar(event.translation)
                        self.touchEnd(success)
                    }
                    self.hasBegun = false
                    self.hasEnded = false
                })
    }
}

extension View {
    func onTouchGesture(touchBegan: @escaping () -> Void = {},
                        touchEnd: @escaping (Bool) -> Void = { _ in }) -> some View {
        modifier(TouchGestureViewModifier(touchBegan: touchBegan, touchEnd: touchEnd))
    }
}

Upvotes: 3

vrwim
vrwim

Reputation: 14310

You can use UIFeedbackGenerator like this:

let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.error)

Or, as you're using SwiftUI, you'll be able to use CoreHaptics like this:

let engine = try CHHapticEngine()
try engine.start()

let hapticEvent = CHHapticEvent(eventType: .hapticTransient, parameters: [
    CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness), CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
], relativeTime: 0)
let audioEvent = CHHapticEvent(eventType: .audioContinuous, parameters: [
    CHHapticEventParameter(parameterID: .audioVolume, value: volume),
    CHHapticEventParameter(parameterID: .decayTime, value: decay),
    CHHapticEventParameter(parameterID: .sustained, value: 0),
], relativeTime: 0)

let pattern = try CHHapticPattern(events: [hapticEvent, audioEvent], parameters: [])
let hapticPlayer = try engine.makePlayer(with: pattern)
try hapticPlayer?.start(atTime: CHHapticTimeImmediate)

Upvotes: 1

Related Questions