BS
BleepingSwift
Published on
4 min read

> Sensory Feedback and Haptics in SwiftUI

Haptic feedback makes interactions feel more tangible. A subtle vibration when toggling a switch or completing an action helps users understand that something happened. iOS 17 added the sensoryFeedback modifier to SwiftUI, making haptics easy to add without dropping into UIKit.

Basic Usage

The sensoryFeedback modifier takes a feedback type and a trigger value. When the trigger changes, the feedback plays:

struct LikeButton: View {
    @State private var isLiked = false

    var body: some View {
        Button {
            isLiked.toggle()
        } label: {
            Image(systemName: isLiked ? "heart.fill" : "heart")
                .foregroundStyle(isLiked ? .red : .gray)
                .font(.title)
        }
        .sensoryFeedback(.selection, trigger: isLiked)
    }
}

Every time isLiked changes, a selection haptic plays. The feedback type determines the vibration pattern.

Feedback Types

SwiftUI provides several predefined feedback styles:

// Light tap for selections
.sensoryFeedback(.selection, trigger: selectedItem)

// Confirm successful actions
.sensoryFeedback(.success, trigger: saveCompleted)

// Indicate something went wrong
.sensoryFeedback(.error, trigger: errorOccurred)

// Caution without being an error
.sensoryFeedback(.warning, trigger: approachingLimit)

// Values increasing
.sensoryFeedback(.increase, trigger: volume)

// Values decreasing
.sensoryFeedback(.decrease, trigger: volume)

// Beginning an action
.sensoryFeedback(.start, trigger: isRecording)

// Ending an action
.sensoryFeedback(.stop, trigger: isRecording)

// Snapping to a position
.sensoryFeedback(.alignment, trigger: alignedPosition)

// Discrete level changes
.sensoryFeedback(.levelChange, trigger: currentLevel)

Impact Feedback

For physical collision-style feedback, use .impact with weight and intensity:

struct DraggableCard: View {
    @State private var offset = CGSize.zero
    @State private var dropped = false

    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(.blue)
            .frame(width: 100, height: 100)
            .offset(offset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { _ in
                        withAnimation {
                            offset = .zero
                        }
                        dropped.toggle()
                    }
            )
            .sensoryFeedback(
                .impact(weight: .medium, intensity: 0.7),
                trigger: dropped
            )
    }
}

Weight options are .light, .medium, and .heavy. Intensity ranges from 0.0 to 1.0.

Conditional Feedback

You can choose feedback dynamically based on the old and new values:

struct SearchResults: View {
    @State private var results: [String] = []

    var body: some View {
        List(results, id: \.self) { result in
            Text(result)
        }
        .sensoryFeedback(trigger: results) { oldValue, newValue in
            if newValue.isEmpty {
                return .error
            } else if newValue.count > oldValue.count {
                return .success
            } else {
                return .selection
            }
        }
    }
}

This plays error feedback when results become empty, success when new results arrive, and selection otherwise.

Conditional Triggering

Sometimes you only want feedback under certain conditions:

struct FormSubmit: View {
    @State private var submitted = false
    @State private var hasErrors = false

    var body: some View {
        Button("Submit") {
            validateAndSubmit()
        }
        .sensoryFeedback(.success, trigger: submitted) { _, newValue in
            newValue && !hasErrors
        }
        .sensoryFeedback(.error, trigger: submitted) { _, newValue in
            newValue && hasErrors
        }
    }

    func validateAndSubmit() {
        // validation logic
        submitted.toggle()
    }
}

The closure returns a Bool indicating whether to play the feedback.

Practical Example

Here's a counter with different haptics for increment and decrement:

struct HapticCounter: View {
    @State private var count = 0
    @State private var lastAction: CounterAction?

    enum CounterAction {
        case increment, decrement, reset
    }

    var body: some View {
        VStack(spacing: 24) {
            Text("\(count)")
                .font(.system(size: 72, weight: .bold, design: .rounded))

            HStack(spacing: 16) {
                Button {
                    count -= 1
                    lastAction = .decrement
                } label: {
                    Image(systemName: "minus.circle.fill")
                        .font(.largeTitle)
                }

                Button {
                    count = 0
                    lastAction = .reset
                } label: {
                    Image(systemName: "arrow.counterclockwise.circle.fill")
                        .font(.largeTitle)
                }

                Button {
                    count += 1
                    lastAction = .increment
                } label: {
                    Image(systemName: "plus.circle.fill")
                        .font(.largeTitle)
                }
            }
        }
        .sensoryFeedback(trigger: lastAction) { _, newValue in
            switch newValue {
            case .increment:
                return .increase
            case .decrement:
                return .decrease
            case .reset:
                return .impact(weight: .heavy)
            case nil:
                return nil
            }
        }
    }
}

Increment plays an "increase" haptic, decrement plays "decrease," and reset gives a heavier impact.

Device Support

Haptic feedback only works on iPhones with a Taptic Engine. iPads, Macs, and the Simulator won't produce vibrations. Your app should work fine without haptics since they're supplementary feedback, not essential information.

Best Practices

Use haptics sparingly. Too much vibration becomes annoying and drains battery. Good candidates for haptics include confirming destructive actions, success or failure of important operations, toggle state changes, and snapping to positions in custom controls.

Avoid haptics for scrolling, typing, or other high-frequency interactions. Match the feedback intensity to the action's significance. A minor selection should feel lighter than completing a major task.

For more control over haptics, including custom patterns, you can still use UIFeedbackGenerator subclasses directly. But for most cases, sensoryFeedback covers what you need with less code.

Sample Project

Want to see this code in action? Check out the complete sample project on GitHub:

View on GitHub

The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.