Aggiornato: 19 settembre 2026
Il modificatore .sensoryFeedback in SwiftUI è l'API dichiarativa introdotta in iOS 17 e maturata in iOS 26 per attivare haptics del Taptic Engine in risposta ai cambi di stato di una vista, senza gestire manualmente UIImpactFeedbackGenerator. In questa guida traduco l'intento del design in codice concreto: valori spring specifici (response 0.4, dampingFraction 0.7), i dieci tipi di SensoryFeedback disponibili in iOS 26, l'integrazione con Core Haptics per pattern custom e le regole di accessibilità per il motion design.
Cos'è il modificatore .sensoryFeedback in SwiftUI
Il modificatore .sensoryFeedback è un'API view che riproduce un feedback tattile ogni volta che un valore osservato cambia in un modo definito da te. La firma canonica è .sensoryFeedback(_ feedback: SensoryFeedback, trigger: some Equatable): quando trigger muta rispetto al valore precedente, il sistema chiama il Taptic Engine con il pattern corrispondente. Rispetto all'API imperativa di UIKit non devi istanziare un generatore, chiamare prepare() per ridurre la latenza e poi impactOccurred(): SwiftUI gestisce il lifecycle e il preparing per te.
Onestamente, la sottigliezza chiave è che il trigger deve essere Equatable, non necessariamente un booleano. In produzione uso spesso un Int incrementale (una "tick count") quando voglio disaccoppiare il feedback dallo stato reale, oppure un enum semantico quando ogni transizione ha un significato diverso. Esempio minimo:
import SwiftUI
struct LikeButton: View {
@State private var likes = 0
var body: some View {
Button {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
likes += 1
}
} label: {
Label("\(likes)", systemImage: "heart.fill")
.font(.title2.bold())
.foregroundStyle(.pink)
.contentTransition(.numericText(value: Double(likes)))
}
.sensoryFeedback(.impact(weight: .medium, intensity: 0.8), trigger: likes)
}
}Ho scelto impact con peso medium e intensità 0.8 perché per un'azione ricorrente come "like" un peso heavy stanca il polso dopo dieci pressioni e uno light si perde con il device sulla scrivania. L'intensità 0.8 lascia margine per differenziare uno stato "double tap" da uno singolo, semplicemente riproducendo due impact ravvicinati. L'ho scoperto la prima volta shipando un'app di fitness: dopo una sessione di test di venti minuti con heavy, sentivo ancora il pollice pulsare.
Quali tipi di SensoryFeedback sono disponibili in iOS 26
iOS 26 stabilizza dieci varianti che coprono le interazioni standard di HIG. Le riassumo con la mia mappatura d'uso, testata su iPhone 15 Pro e successivi:
| Variante | Quando usarla | Note UX |
|---|---|---|
.success | Operazione completata (salvataggio, pagamento, invio form) | Doppio tick discendente, autoritativo |
.warning | Stato ambiguo che richiede attenzione (batteria bassa, verifica campo) | Doppio tick con separazione più marcata |
.error | Fallimento definitivo (validazione fallita, network error non recuperabile) | Tre tick, più duro; usare con parsimonia |
.impact | Contatto fisico simulato (bottone premuto, elemento incontra un limite) | Parametrizzabile con weight e intensity |
.selection | Cambio di selezione discreta (picker, segmented control, tab) | Molto leggero, non "conclusivo" |
.increase / .decrease | Regolazione di un valore in una direzione (slider a step, stepper) | Coppia asimmetrica: increase ha attack più netto |
.alignment | Snap a una guida (allineamento in canvas, ruler) | Ottimo per drag con guides |
.levelChange | Passaggio di livello significativo (XP, chapter, milestone) | Meno frequente di impact, più "premiante" |
.pathComplete | Completamento di un percorso guidato (checkmark disegnato, gesture completata) | Da abbinare a un'animazione di completion |
Un errore ricorrente che vedo nei code review è usare .error per validazioni inline del form. È troppo aggressivo: fa sentire l'utente sbagliato prima ancora che abbia finito di digitare. Preferisco .warning per uno stato "quasi", riservando .error al submit finale bloccato.
Come combinare haptics e animazioni spring in SwiftUI
Un feedback tattile senza animazione visiva sembra glitch; un'animazione senza feedback sembra plastica. La regola che applico è: l'inizio dell'haptic e l'inizio della trasformazione visiva devono cadere nello stesso frame. In SwiftUI questo significa collegare entrambi allo stesso trigger di stato:
struct ToggleFavorite: View {
@State private var isFavorite = false
@State private var scale: CGFloat = 1
var body: some View {
Image(systemName: isFavorite ? "star.fill" : "star")
.font(.system(size: 44))
.foregroundStyle(isFavorite ? .yellow : .gray)
.scaleEffect(scale)
.onTapGesture {
isFavorite.toggle()
withAnimation(.spring(response: 0.35, dampingFraction: 0.55)) {
scale = 1.25
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
scale = 1.0
}
}
}
.sensoryFeedback(.selection, trigger: isFavorite)
}
}Numeri spring specifici che uso come baseline nel design system: response 0.35 e dampingFraction 0.55 per il "punch" iniziale (rimbalzo percepibile), response 0.4 e dampingFraction 0.7 per il ritorno al riposo (settle pulito senza oscillazioni residue). Un dampingFraction < 0.5 rende l'animazione "gommosa" e Apple stessa lo evita nel proprio SDK. Sopra 0.9 il movimento diventa piatto e l'haptic sembra scollegato dal visual.
Se stai già usando il pattern macro @Observable per gestire lo stato, sposta il trigger su una proprietà del modello anziché su @State locale: mantieni logica di feedback e logica di dominio nella stessa unità testabile.
Sincronizzazione con matchedGeometryEffect
Quando uso matchedGeometryEffect per transizioni hero (es. lista → dettaglio), il feedback deve partire con l'inizio della transizione, non alla fine. Metto quindi il modificatore .sensoryFeedback sul contenitore che ospita la @State boolean che pilota la transizione, non sulla destinazione. Se lo attacco alla destinazione, il primo frame di render della vista di dettaglio è già a interpolazione avviata e il feedback arriva 8–12 ms in ritardo, abbastanza per sembrare desincronizzato.
Core Haptics: pattern custom oltre le preset
Le dieci preset di SensoryFeedback coprono l'80% dei casi. Per il restante 20% (un fizz continuo mentre l'utente trascina, una texture ruvida su un bottone di conferma pagamento) serve Core Haptics. Il framework espone due tipi di evento: hapticTransient (impulso singolo, ~20 ms) e hapticContinuous (fino a 30 secondi), entrambi con parametri hapticIntensity (0–1) e hapticSharpness (0–1, sharpness alta = "clic metallico", bassa = "thud").
Wrapper che uso per esporlo a SwiftUI:
import CoreHaptics
import SwiftUI
@MainActor
final class HapticEngine: ObservableObject {
private var engine: CHHapticEngine?
init() {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
do {
engine = try CHHapticEngine()
engine?.stoppedHandler = { [weak self] _ in
try? self?.engine?.start()
}
try engine?.start()
} catch {
engine = nil
}
}
func playTexture(intensity: Float = 0.6, sharpness: Float = 0.9, duration: TimeInterval = 0.4) {
guard let engine else { return }
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
.init(parameterID: .hapticIntensity, value: intensity),
.init(parameterID: .hapticSharpness, value: sharpness)
],
relativeTime: 0,
duration: duration
)
do {
let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: 0)
} catch {
// Fallback silenzioso: preferisco perdere l'haptic che crashare
}
}
}Note dal campo: stoppedHandler è fondamentale. Il sistema ferma l'engine quando l'app va in background o quando arriva una chiamata; senza il restart il primo tap dopo il resume produce silenzio. Ci ho perso un pomeriggio, la prima volta. In secondo luogo, CHHapticEngine.capabilitiesForHardware().supportsHaptics è false su iPad (nessun Taptic Engine dedicato): il fallback non è opzionale se il tuo target include tablet.
SensoryFeedback vs UIImpactFeedbackGenerator: quale scegliere
La domanda ricorre in ogni review di codebase legacy in fase di migrazione a iOS 17+. La risposta breve: se il tuo minimum deployment target è iOS 17 e stai in SwiftUI, usa .sensoryFeedback. UIKit e generatori manuali restano utili in tre scenari concreti.
- Feedback fuori dal ciclo di rendering. Se devi triggerare un haptic da una callback di
URLSessiono dalla completion di unaTasknon ancorata a un cambio di stato osservabile,UINotificationFeedbackGenerator().notificationOccurred(.success)è più diretto che sintetizzare un trigger fittizio. - Frequenza superiore a 20 Hz. Il modificatore SwiftUI ha throttling implicito. Per un gesture drag con feedback su ogni pixel di soglia (raro, ma esiste; pensa a un ruler di editing audio) il generatore manuale con
prepare()pre-cachato scala meglio. - Codebase misto UIKit/SwiftUI. Se la vista è ancora un
UIViewControllerospitato inUIHostingController, mantieni un solo layer di feedback per evitare doppi tap.
In tutti gli altri casi, il modificatore vince perché elimina lo stato mutabile del generatore, testa più facilmente (basta osservare il valore del trigger), e si integra con il ciclo delle transizioni di NavigationStack senza race condition tra push e feedback.
Accessibilità e best practice di motion design
Le haptics sono un canale sensoriale a tutti gli effetti: per un utente daltonico o con visione ridotta, un feedback tattile può essere l'unico segnale che un'azione è andata a buon fine. Ma per un utente con disturbi sensoriali o iperacusia tattile, la stessa haptic è invasiva. Le tre impostazioni di sistema che devo rispettare in ogni build:
- System Haptics (Settings → Sounds & Haptics → System Haptics). Se disattivato, il sistema silenzia automaticamente le preset di SensoryFeedback ma non i pattern di Core Haptics: devo controllarlo io.
- Reduce Motion (
UIAccessibility.isReduceMotionEnabled). Riduce le animazioni ma anche i pattern haptic complessi (un pulse continuo diventa un singolo transient). Per il mio design system la regola è: se dimezzo la durata dell'animazione visiva, riduco l'intensità haptic dello stesso fattore. - Prefer Cross-Fade Transitions. Segnala esplicitamente che l'utente non vuole slide/scale animate; l'haptic deve seguire, sostituendo
.impactcon.selection.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var feedback: SensoryFeedback {
reduceMotion ? .selection : .impact(weight: .medium)
}
var body: some View {
Toggle("Notifiche push", isOn: $enabled)
.sensoryFeedback(feedback, trigger: enabled)
.animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.7), value: enabled)
}Haptics su watchOS con SwiftUI
Su watchOS 11 lo stesso modificatore .sensoryFeedback è disponibile ma la lista di preset è ridotta: .success, .failure (equivalente di .error), .retry, .start, .stop, .click, .notification. Sotto il cofano si mappa su WKInterfaceDevice.current().play(_:). La cosa da sapere è che il Taptic Engine di Apple Watch ha una signature acustica diversa: pattern che su iPhone sono impercettibili, sul polso vengono amplificati. Testo sempre ogni feedback su hardware reale prima del ship, perché il simulatore watchOS non riproduce haptics.
Un pattern che ho spedito su Series 9 e Ultra 2 per un'app di allenamento:
#if os(watchOS)
Button("Inizio serie") {
session.start()
}
.sensoryFeedback(.start, trigger: session.isActive)
#endifPattern di produzione: tap, drag e conferme
Chiudo con tre pattern che riutilizzo in ogni app di produzione, con valori spring e haptic già calibrati.
1. Tap con conferma visiva
Bottone primario di CTA (checkout, submit). Scale down 0.94 in 120 ms, ritorno in 240 ms, .impact(weight: .medium) al tap, .success alla conferma network.
struct PrimaryButton: View {
let title: String
let action: () async throws -> Void
@State private var isPressed = false
@State private var confirmationTick = 0
var body: some View {
Button {
Task {
try? await action()
confirmationTick += 1
}
} label: {
Text(title)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(.tint, in: .rect(cornerRadius: 14))
.foregroundStyle(.white)
.scaleEffect(isPressed ? 0.94 : 1)
}
.buttonStyle(.plain)
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: isPressed)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in isPressed = true }
.onEnded { _ in isPressed = false }
)
.sensoryFeedback(.impact(weight: .medium), trigger: isPressed)
.sensoryFeedback(.success, trigger: confirmationTick)
}
}2. Drag con snap ad allineamento
Cursore in un editor. Ogni volta che il valore attraversa un multiplo di 5, un feedback .alignment. Uso un @State private var lastSnap: Int = 0 come trigger.
3. Slider con range discreto
Stepper di volume. .increase a ogni step verso l'alto, .decrease verso il basso, differenziando il senso di marcia. In combinazione con la nostra guida Liquid Glass in SwiftUI, l'effetto è che il tick visivo dell'indicatore di volume "respira" con lo stesso response spring del feedback tattile. Motion e touch coincidono.
Domande frequenti
Il modificatore .sensoryFeedback funziona sul simulatore iOS?
No, il simulatore non ha un Taptic Engine e ignora silenziosamente le chiamate. Devi testare su device fisico. Il codice non crasha né logga warning, il che rende facile dimenticarsi di verificare, quindi controlla sempre almeno una build su iPhone reale prima del merge.
Come triggero un feedback haptic da una Task async in SwiftUI?
Aggiorna una proprietà @State Equatable (tipicamente un contatore Int) al completamento della Task, e collega .sensoryFeedback a quel valore. Il modificatore osserva il cambio anche se avviene fuori dal main thread, purché l'aggiornamento dello state passi per il main actor.
Posso disabilitare le haptics quando l'utente attiva Reduce Motion?
Sì, leggi @Environment(\.accessibilityReduceMotion) e sostituisci il pattern con una variante meno intensa (es. .selection al posto di .impact), oppure passa nil come feedback per silenziarlo del tutto. Rispettare l'impostazione è un requisito, non un vezzo.
Qual è la differenza tra .impact e .selection in SensoryFeedback?
.impact simula un contatto fisico ed è parametrizzabile con weight (light/medium/heavy) e intensity; adatto a tap di conferma. .selection è più leggero e discreto, pensato per il cambio di stato tra opzioni discrete come picker, segmented control e tab.
Perché il mio Core Haptics engine smette di funzionare quando l'app torna dal background?
Il sistema ferma l'engine automaticamente quando l'app entra in background, riceve una chiamata o va in interruzione. Devi implementare stoppedHandler su CHHapticEngine e richiamare start() nel handler, altrimenti il primo tap post-resume non produce feedback.




