Sensory Feedback no SwiftUI: Guia Completo de Haptics no iOS 26
Guia prático para adicionar haptics no SwiftUI usando .sensoryFeedback no iOS 26: os 12 tipos, quando usar Core Haptics, sincronia com springs e código pronto.
O modificador .sensoryFeedback do SwiftUI é a forma declarativa nativa de adicionar haptics a qualquer view no iOS 26, aceitando um estilo de feedback (como .impact, .selection ou .success) e um valor de trigger que dispara a vibração sempre que muda. Neste guia, mostro como escolher entre os 12 tipos de sensory feedback, quando descer para CHHapticEngine para padrões customizados, e como orquestrar haptics com animações spring para que o toque chegue exatamente no pico visual. Porque, sim, timing importa em milissegundos.
.sensoryFeedback(_:trigger:) substitui UIImpactFeedbackGenerator em código SwiftUI a partir do iOS 17 e continua sendo a API preferida no iOS 26.
Existem 12 estilos predefinidos. Os mais úteis são .selection, .impact(weight:), .success, .error e .pathComplete.
Para padrões contínuos, sequências ou sincronia com áudio, use CHHapticEngine com eventos .hapticTransient e .hapticContinuous.
iPad e a maioria dos Macs não reproduzem haptics. Sempre verifique CHHapticEngine.capabilitiesForHardware().supportsHaptics antes de assumir suporte.
Alinhe cada haptic ao pico da animação visual (uma spring com response 0.4 tem pico em ~180 ms), para que o toque reforce, e não compita com, a percepção.
Respeite UIAccessibility.isReduceMotionEnabled e ofereça um toggle nas configurações do app. Haptics excessivos irritam mais do que ajudam.
O que é sensory feedback no SwiftUI
Sensory feedback é a categoria de resposta tátil (e opcionalmente sonora) que o iOS entrega através do Taptic Engine em resposta a uma mudança de estado observável na UI. No SwiftUI, isso é encapsulado pelo modificador .sensoryFeedback(_:trigger:), introduzido no iOS 17 e mantido praticamente inalterado no iOS 26. A Apple adicionou o estilo .pathComplete (útil para gestos de desenho e assinaturas) e melhorou a integração com o novo sistema Liquid Glass do SwiftUI, onde microvibrações reforçam a impressão de "materialidade" do vidro.
A grande vantagem do modificador declarativo é que ele se pluga na árvore reativa do SwiftUI. Você não precisa preparar um UIImpactFeedbackGenerator, chamar prepare(), esperar o momento certo e disparar impactOccurred(). Basta mudar o valor de trigger e o framework cuida do resto, inclusive da otimização de latência. Honestamente, na minha experiência levando protótipos de designers para produção, cortar essas 15 linhas de boilerplate por view tem impacto real. Mais equipes começam a usar haptics porque a barreira ficou baixa, e quando a barreira baixa, a qualidade percebida do app sobe junto.
Como adicionar haptics no SwiftUI passo a passo
O passo mínimo é dois: declare uma @State que sirva de trigger, e conecte o modificador ao evento que faz sentido. Não use timers ou onAppear como triggers. O haptic deve estar semanticamente ligado a uma ação do usuário, senão você entra em território de dark pattern.
Repare que eu incremento um Int em vez de observar isFavorite diretamente. Isso é intencional. Se o usuário desmarcar e marcar de novo, quero disparar o haptic nas duas transições, e um Bool que já é true não vai mudar. O padrão contador é o que uso em ~80% dos casos porque garante idempotência do trigger.
Todos os tipos de SensoryFeedback
O tipo SensoryFeedback expõe 12 casos predefinidos. Escolher o correto é uma decisão semântica, não estética. Usuários com deficiência visual dependem do padrão do haptic para inferir o resultado da ação, então trocar .error por .success porque "vibra melhor" é ativamente nocivo.
Tipo
Padrão perceptivo
Quando usar
.selection
Toque leve e curto
Trocar item em um picker, alternar segmentos
.impact
Colisão configurável
Botões de ação, snap de scroll, toque em elementos "físicos"
.success
Dois pulsos ascendentes
Confirmar salvar, pagamento aprovado
.warning
Dois pulsos médios
Ação com consequência reversível (esvaziar carrinho)
.error
Três pulsos decrescentes
Falha de validação, autenticação incorreta
.start
Pulso inicial
Começo de gravação, cronômetro
.stop
Pulso final
Parar gravação, encerrar sessão
.alignment
Snap sutil
Alinhar em grid, encaixar em guia
.levelChange
Salto discreto
Progresso por estágios, ganho de nível
.increase / .decrease
Toque direcional
Stepper, brilho, volume
.pathComplete
Fechamento contínuo
Assinatura completa, desenho fechado
A regra prática que sigo: se a ação tem um resultado terminal (sucesso/falha), use .success, .warning ou .error. Se é um evento discreto intermediário (mudei de item, encaixei uma peça), use .selection, .alignment ou .impact. E se a ação tem direção (subir/descer volume), use .increase/.decrease. Apesar da documentação antiga dizer que são exclusivos do watchOS, elas funcionam no iOS 17+ e a Apple confirmou o suporte no iOS 26.
Impact: weight, flexibility e intensity
O caso .impact é o mais versátil e o que mais gera confusão porque tem duas assinaturas mutuamente exclusivas: weight (peso do objeto que colide) e flexibility (rigidez do material). Escolha uma metáfora e mantenha em todo o app. Misturar as duas em contextos parecidos deixa a resposta tátil incoerente.
// Botão primário: colisão pesada e sólida
.sensoryFeedback(.impact(weight: .heavy, intensity: 1.0), trigger: primaryTapTrigger)
// Card colapsando: material macio a 60% de intensidade
.sensoryFeedback(.impact(flexibility: .soft, intensity: 0.6), trigger: collapseTrigger)
// Toque em chip removível: rígido e discreto
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.4), trigger: chipTapTrigger)
intensity vai de 0.0 a 1.0. Na prática, valores abaixo de 0.3 são imperceptíveis em ambientes ruidosos ou quando o dispositivo está em uma mesa, e valores acima de 1.0 (o teto é 1.0 mesmo que você passe 2.0) apenas saturam. Meu conjunto padrão para um design system: 0.4 para ações secundárias, 0.7 para primárias, 1.0 reservado para confirmações críticas. Assim o usuário aprende a hierarquia sem ler nenhuma documentação.
Feedback condicional com oldValue e newValue
Para lógica mais rica, o modificador aceita uma closure que recebe o valor antigo e o novo do trigger e retorna qual SensoryFeedback tocar (ou nil para não tocar nada). Isso é o que uso para Steppers, Sliders com marcas, e formulários com validação inline:
Stepper("Quantidade: \(quantidade)", value: $quantidade, in: 0...99)
.sensoryFeedback(trigger: quantidade) { oldValue, newValue in
if newValue == 0 { return .warning } // atingiu o mínimo
if newValue == 99 { return .warning } // atingiu o máximo
return newValue > oldValue ? .increase : .decrease
}
A segunda variação usa um Bool-closure para condicionar sem trocar o tipo:
.sensoryFeedback(.selection, trigger: selecionado) { old, new in
new != nil // só vibra ao selecionar, não ao desmarcar
}
Esse padrão elimina a necessidade de onChange(of:) mais um contador auxiliar. O modificador vira a fonte única da política de haptics daquela view, o que é ótimo para code review. Se você já usa a nova macro @Observable no SwiftUI, os triggers podem apontar diretamente para propriedades do seu modelo sem @State intermediário.
Diferença entre .sensoryFeedback e Core Haptics
Resumindo: .sensoryFeedback é para respostas curtas e semânticas. Já o CHHapticEngine é para composições contínuas, sincronizadas com áudio, ou totalmente customizadas. A tabela abaixo é a matriz de decisão que reviso com times que estão começando a levar haptics a sério:
Critério
.sensoryFeedback
CHHapticEngine
Modelo de programação
Declarativo (SwiftUI)
Imperativo (baseado em eventos)
Duração típica
< 300 ms
Ilimitada (contínuo)
Padrões customizados
Não
Sim, via AHAP ou construção em código
Sincronia com áudio
Não
Sim (mesma timeline)
Setup necessário
Zero
Instanciar engine, tratar reinício
Fallback em iPad
Silenciosamente ignorado
Precisa verificar capabilities
Boa para
Botões, toggles, seleção, snap
Jogos, ARKit, feedback longo
Regra que aplico sem exceção: comece com .sensoryFeedback. Só migre para CHHapticEngine quando o designer trouxer um padrão que os presets não expressam, tipicamente quando envolve rampas de intensidade, uma sequência maior que 500 ms, ou casamento com um efeito sonoro. Antes disso, resistir à tentação de "engenharia por diversão" economiza semanas.
Como criar padrões de vibração personalizados
Quando o preset não basta, o caminho é CHHapticEngine. Ele tem três conceitos: eventos (uma vibração ou pulso individual), padrões (uma coleção temporizada de eventos) e o engine em si (que reproduz padrões). O exemplo abaixo cria um padrão "impacto crescente", cinco toques cada vez mais intensos ao longo de 500 ms, que uso para confirmar ações destrutivas com hold to confirm:
import CoreHaptics
import SwiftUI
@Observable
final class HapticsCoordinator {
private var engine: CHHapticEngine?
init() {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
do {
engine = try CHHapticEngine()
engine?.stoppedHandler = { [weak self] _ in
try? self?.engine?.start()
}
engine?.resetHandler = { [weak self] in
try? self?.engine?.start()
}
try engine?.start()
} catch {
print("Falha ao iniciar CHHapticEngine: \(error)")
}
}
func playRisingImpact() {
guard let engine else { return }
var events: [CHHapticEvent] = []
for step in stride(from: 0.2, through: 1.0, by: 0.2) {
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(step))
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(step))
events.append(
CHHapticEvent(
eventType: .hapticTransient,
parameters: [intensity, sharpness],
relativeTime: step * 0.5
)
)
}
do {
let pattern = try CHHapticPattern(events: events, parameters: [])
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: 0)
} catch {
print("Falha ao tocar padrão: \(error)")
}
}
}
Note dois detalhes que a maioria dos tutoriais ignora e que quebram em produção: o stoppedHandler reinicia o engine se o sistema o suspender (por chamada telefônica, Siri, câmera), e o resetHandler lida com resets de hardware (thermal throttling, principalmente). Eu bati nesse bug exato num app de música. Sem esses handlers, seus haptics silenciosamente param de funcionar depois de alguns minutos de uso e você não vai entender por quê. Para padrões complexos e reutilizáveis, considere descrever em arquivos AHAP e distribuir com o app. Consulte a documentação do Core Haptics para o schema completo.
Sincronizar haptics com animações spring
Aqui entra a parte que separa um app medíocre de um app que "sente bem": o haptic precisa cair no pico visual da animação, não no início da transição de estado. Uma spring padrão do SwiftUI com response: 0.4, dampingFraction: 0.7 alcança o pico de deslocamento em aproximadamente 180 ms. Se você disparar o haptic junto com a mudança do estado, ele chega antes da confirmação visual, e o cérebro percebe como desalinhado.
struct HeartButton: View {
@State private var liked = false
@State private var hapticTrigger = 0
var body: some View {
Button {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
liked.toggle()
}
// Atrasa o haptic para bater com o pico da spring (~180ms)
Task {
try? await Task.sleep(for: .milliseconds(160))
hapticTrigger += 1
}
} label: {
Image(systemName: liked ? "heart.fill" : "heart")
.scaleEffect(liked ? 1.2 : 1.0)
.foregroundStyle(liked ? .pink : .secondary)
}
.sensoryFeedback(.impact(weight: .medium, intensity: 0.7), trigger: hapticTrigger)
}
}
Se você usa um transition de símbolo (symbolEffect(.bounce), .replace, etc.), o pico visual varia entre 120 ms e 250 ms. Meça no seu app com Instruments → Animation Hitches e ajuste o atraso. Não existe um número mágico universal, mas 160 ms é minha média empírica em botões SwiftUI padrão.
Acessibilidade e preferências do usuário
Haptics são um sinal ambíguo se usados em excesso. A Apple recomenda respeitar três configurações do sistema, e um app profissional oferece um quarto: um toggle próprio dentro das preferências. Consulte também o Human Interface Guidelines sobre haptics antes de finalizar sua política:
Reduce Motion. Quando ativo, evite haptics ligados a animações puramente decorativas. UIAccessibility.isReduceMotionEnabled.
System Haptics. Configuração global em Ajustes; se desativada, .sensoryFeedback já respeita silenciosamente, mas CHHapticEngine não. Verifique manualmente.
Silent Mode. Decida se seu app respeita o ring/silent switch; a maioria dos apps de mensagem sim, jogos não.
Preferência do app. Armazene em @AppStorage("hapticsEnabled") e envolva o trigger em uma verificação.
@AppStorage("hapticsEnabled") private var hapticsEnabled = true
.sensoryFeedback(.impact(weight: .light), trigger: hapticTrigger) { _, _ in
hapticsEnabled && !UIAccessibility.isReduceMotionEnabled
}
Como testar haptics no simulador e em dispositivos
O simulador do iOS não reproduz haptics; nunca reproduziu. Em um Mac com Trackpad Force Touch, você pode ativar Features → Trackpad Haptics no simulador, mas o comportamento é uma aproximação grosseira, não uma simulação fiel. A única forma confiável de validar é em um iPhone real (iPhone 8 ou superior). Meu fluxo de teste:
Rode em um iPhone com System Haptics ativo e Silent Mode desativado. Cenário padrão.
Repita com Reduce Motion ligado para confirmar que a política de haptics respeita a preferência.
Teste em um iPad. Os haptics devem simplesmente não ocorrer, sem erros ou logs suspeitos.
Deixe o app aberto por 10 minutos, coloque em background durante uma chamada, volte, e confirme que CHHapticEngine ainda toca (é aqui que os handlers de reset entram).
Para diagnosticar problemas, o Instruments tem um template Core Animation que ajuda a alinhar o haptic ao pico da animação, e o Console.app mostra logs do HapticEngine se você filtrar por subsystem com.apple.CoreHaptics. Um erro comum é iniciar o engine antes de o app receber UIApplication.didBecomeActiveNotification. Nesse caso, engine.start() lança CHHapticError.engineStartTimeout. Adie a inicialização para o primeiro uso real (lazy) ou para o scenePhase == .active.
Perguntas Frequentes
O modificador .sensoryFeedback funciona no iPad?
Não. Nenhum iPad atual tem Taptic Engine, então o modificador é silenciosamente ignorado. Seu código não quebra, apenas nada acontece. Se você depende de haptics para comunicar informação essencial, ofereça uma alternativa visual ou sonora para iPad.
Posso disparar haptics no simulador do Xcode?
Não de forma fiel. O simulador em Macs com Force Touch pode simular vibrações via trackpad se você ativar em Features → Trackpad Haptics, mas a intensidade e o timing não correspondem a um iPhone real. Sempre valide em dispositivo físico.
Qual a diferença entre .sensoryFeedback e UIImpactFeedbackGenerator?
.sensoryFeedback é o modificador declarativo do SwiftUI e é a API recomendada para código SwiftUI a partir do iOS 17. UIImpactFeedbackGenerator continua sendo a API do UIKit e é útil se você tem código híbrido ou precisa suportar iOS 16 e anteriores.
Como sei se o dispositivo do usuário suporta Core Haptics?
Chame CHHapticEngine.capabilitiesForHardware().supportsHaptics. Retorna true em iPhone 8 e superiores, false em iPads e Macs (mesmo com Force Touch trackpad, a API não considera trackpad como suporte para Core Haptics de app).
Como oferecer ao usuário a opção de desativar haptics?
Use @AppStorage("hapticsEnabled") como Bool, exponha um Toggle em suas configurações, e envolva o trigger do modificador em uma closure condicional que retorna nil quando desativado. Combine com UIAccessibility.isReduceMotionEnabled para respeitar as preferências do sistema.
Sincronize SwiftData com CloudKit no iOS 26: configuração do ModelContainer, regras de schema, resolução de conflitos e ShareLink, com exemplos de código completos.
Aprenda a usar TipKit no iOS 26: configure Tips.configure(), crie tips com regras declarativas, personalize com Liquid Glass e adapte para iPad, Mac Catalyst e visionOS, com exemplos SwiftUI prontos para produção.
NavigationStack no iOS 26 exige um novo mindset: a pilha vira dado. Veja como fazer navegação programática, deep links tipados e acessibilidade correta com código Swift 6.2 testado.