Branching Haptics in SwiftUI: Getting More Out of sensoryFeedback

Branching Haptics in SwiftUI: Getting More Out of sensoryFeedback

Most apps reach for sensoryFeedback exactly once and wire it to a single outcome: a button tap plays .impact, a save plays .success. That works, but it leaves most of the API on the table. The modifier was built to branch — one declaration can pick a different haptic for success, failure, or no feedback at all, all from the data you already have.

iOS 17 moved haptics into SwiftUI as a first-class view modifier. No more UIImpactFeedbackGenerator, no prepare() calls, no UIKit bridging. But the real win isn't the convenience — it's that sensoryFeedback is driven by a trigger value, and that opens up patterns the one-shot examples never show.

The trigger model

Every sensoryFeedback modifier watches an Equatable value. When that value changes, SwiftUI plays the feedback. The simplest form looks like this:

struct SaveButton: View {
    @State private var didSave = false

    var body: some View {
        Button("Save") { didSave.toggle() }
            .sensoryFeedback(.success, trigger: didSave)
    }
}

The key word is changes. Feedback fires on a transition from one value to the next — never on the initial value when the view first appears. That's usually what you want, but it's the first thing that trips people up (more on that below).

Branching with the closure variant

The variant almost nobody reaches for takes a closure instead of a fixed feedback. SwiftUI hands you the old and new values of the trigger, and you return a SensoryFeedback — or nil for silence:

enum UploadResult: Equatable {
    case idle, success, failure, retrying
}

struct UploadView: View {
    @State private var result: UploadResult = .idle

    var body: some View {
        UploadForm(result: $result)
            .sensoryFeedback(trigger: result) { _, new in
                switch new {
                case .success:  return .success
                case .failure:  return .error
                case .retrying: return .warning
                case .idle:     return nil   // cleared — stay silent
                }
            }
    }
}

One modifier, three distinct haptics and a silent case. The trigger already carries the context you need to choose the right feedback, so branching inside the closure beats scattering three separate .sensoryFeedback modifiers across the view — each of which would need its own bespoke trigger.

Intensity: matching the physical weight

SensoryFeedback ships with a rich vocabulary — .success, .warning, .error, .selection, .increase, .decrease, .start, .stop, .alignment, .levelChange, and .impact. The .impact case is the one with hidden depth. It takes a weight (.light, .medium, .heavy) or a flexibility (.rigid, .solid, .soft), plus an intensity from 0.0 to 1.0:

struct DragTarget: View {
    @State private var didLandSoftly = false
    @State private var didSnapToEnd = false

    var body: some View {
        CardStack()
            // a gentle landing
            .sensoryFeedback(
                .impact(weight: .medium, intensity: 0.3),
                trigger: didLandSoftly
            )
            // a hard end-of-scroll snap
            .sensoryFeedback(
                .impact(weight: .heavy, intensity: 1.0),
                trigger: didSnapToEnd
            )
    }
}

The same .impact API expresses both a barely-there nudge and a firm thud — you just turn the intensity dial. This is how the system haptics in Maps, Photos, and the share sheet get their texture, and you get it without touching Core Haptics.

The condition variant

There's a third form: keep a fixed feedback, but gate it with a closure that returns a Bool. Feedback plays only when the closure says so:

.sensoryFeedback(.success, trigger: results) { _, newValue in
    !newValue.isEmpty   // skip the haptic on an empty result set
}

Reach for this when the type of feedback is constant but you only want it some of the time.

Gotchas worth knowing

It never fires on the initial value

If you set a trigger to its "success" state before the view appears — say, restoring saved state — no haptic plays. Feedback needs a transition. If you genuinely need a haptic on appearance, change the trigger after onAppear.

The trigger must be Equatable

SwiftUI compares old and new values to detect a change. Custom enums and structs work fine as long as they conform. If your trigger is a struct that changes in a field SwiftUI can't see as different, the haptic silently won't fire.

It respects the system, and the platform

Feedback honors the user's system haptics setting — if they've turned haptics off, your code still compiles and runs, it just stays quiet. Behavior also varies by device: iPhones use the Taptic Engine, Apple Watch plays its own haptics, and on macOS only Force Touch trackpads respond. Treat haptics as an enhancement, never as the only channel for important information.

Don't overwhelm

A haptic on every state change becomes noise. Branching to nil for routine transitions — and saving feedback for outcomes that actually matter — is a feature, not a limitation.

Summary

sensoryFeedback is far more than a one-shot haptic. Its closure variant lets a single modifier branch across success, failure, and silence from the trigger value you already have, and .impact's intensity parameter matches the physical weight of an action without dropping into Core Haptics. Remember that it fires only on change, needs an Equatable trigger, and respects the user's settings — design around those three facts and your haptics will feel native everywhere they play.

Subscribe to Swiftloop

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe