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.

Sensory Feedback SwiftUI iOS 26 Guia

Atualizado: 8 de Agosto, 2026

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.

import SwiftUI

struct FavoriteButton: View {
    @State private var isFavorite = false
    @State private var hapticTrigger = 0

    var body: some View {
        Button {
            isFavorite.toggle()
            hapticTrigger += 1
        } label: {
            Image(systemName: isFavorite ? "heart.fill" : "heart")
                .font(.title)
                .foregroundStyle(isFavorite ? .red : .secondary)
                .contentTransition(.symbolEffect(.replace))
        }
        .sensoryFeedback(.impact(weight: .light), trigger: hapticTrigger)
    }
}

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.

TipoPadrão perceptivoQuando usar
.selectionToque leve e curtoTrocar item em um picker, alternar segmentos
.impactColisão configurávelBotões de ação, snap de scroll, toque em elementos "físicos"
.successDois pulsos ascendentesConfirmar salvar, pagamento aprovado
.warningDois pulsos médiosAção com consequência reversível (esvaziar carrinho)
.errorTrês pulsos decrescentesFalha de validação, autenticação incorreta
.startPulso inicialComeço de gravação, cronômetro
.stopPulso finalParar gravação, encerrar sessão
.alignmentSnap sutilAlinhar em grid, encaixar em guia
.levelChangeSalto discretoProgresso por estágios, ganho de nível
.increase / .decreaseToque direcionalStepper, brilho, volume
.pathCompleteFechamento contínuoAssinatura 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.sensoryFeedbackCHHapticEngine
Modelo de programaçãoDeclarativo (SwiftUI)Imperativo (baseado em eventos)
Duração típica< 300 msIlimitada (contínuo)
Padrões customizadosNãoSim, via AHAP ou construção em código
Sincronia com áudioNãoSim (mesma timeline)
Setup necessárioZeroInstanciar engine, tratar reinício
Fallback em iPadSilenciosamente ignoradoPrecisa verificar capabilities
Boa paraBotões, toggles, seleção, snapJogos, 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 InstrumentsAnimation 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:

  1. Reduce Motion. Quando ativo, evite haptics ligados a animações puramente decorativas. UIAccessibility.isReduceMotionEnabled.
  2. System Haptics. Configuração global em Ajustes; se desativada, .sensoryFeedback já respeita silenciosamente, mas CHHapticEngine não. Verifique manualmente.
  3. Silent Mode. Decida se seu app respeita o ring/silent switch; a maioria dos apps de mensagem sim, jogos não.
  4. 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:

  1. Rode em um iPhone com System Haptics ativo e Silent Mode desativado. Cenário padrão.
  2. Repita com Reduce Motion ligado para confirmar que a política de haptics respeita a preferência.
  3. Teste em um iPad. Os haptics devem simplesmente não ocorrer, sem erros ou logs suspeitos.
  4. 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.

Diana Kowalski
Sobre o Autor Diana Kowalski

Mobile UX engineer translating design intent into pixel-perfect SwiftUI. Has strong opinions about haptics.