Criar efeitos táteis personalizados

Nesta página, mostramos exemplos de como usar diferentes APIs de retorno tátil para criar efeitos personalizados além das formas de onda de vibração padrão em um app Android.

Esta página inclui os seguintes exemplos:

Para mais exemplos, consulte Adicionar retorno tátil a eventos e sempre siga os princípios de design de retorno tátil.

Usar substitutos para lidar com a compatibilidade do dispositivo

Ao implementar efeitos táteis personalizados, a compatibilidade do dispositivo e o comportamento de substituição dependem da plataforma da API escolhida:

  • VibrationEffect.Builder (recomendado): a partir do Android 16 (4º trimestre de 2026), os efeitos criados usando VibrationEffect.Builder incluem substituição automática no nível do framework. Se um dispositivo não for compatível nativamente com um Preset solicitado ou um Envelope básico, o framework vai traduzi-lo automaticamente para uma alternativa adequada no momento da reprodução, da melhor maneira possível. Não é necessário verificar manualmente os recursos do dispositivo por primitiva antes de reproduzir efeitos compostos com VibrationEffect.Builder.
    • Exceção:os envelopes de forma de onda avançados criados com WaveformEnvelopeBuilder não oferecem suporte ao substituto automático porque dependem de mapeamentos de frequência de hardware específicos (FOAM). Se não forem compatíveis, eles não serão reproduzidos.
  • VibrationEffect.Composition:as composições criadas com a API startComposition() não têm substitutos automáticos. Se uma composição tiver pelo menos uma primitiva incompatível, a vibração inteira não será reproduzida. É preciso verificar manualmente os recursos usando vibrator.arePrimitivesSupported().
  • Formas de onda com controle de amplitude:as amplitudes diferentes de zero são arredondadas para 100% em dispositivos sem controle de amplitude. Verifique vibrator.hasAmplitudeControl() e use um padrão ON/OFF explicitamente projetado, se necessário.

Uso de primitivos táteis

O Android inclui várias primitivas de retorno tátil que variam em amplitude e frequência. Você pode usar uma única primitiva ou várias delas em combinação para conseguir efeitos táteis avançados.

  • Use atrasos de 50 ms ou mais para intervalos perceptíveis entre duas primitivas, considerando também a duração da primitiva, se possível.
  • Use escalas que tenham uma proporção de 1,4 ou mais para que a diferença de intensidade seja mais perceptível.
  • Use escalas de 0,5, 0,7 e 1,0 para criar uma versão de baixa, média e alta intensidade de uma primitiva.

Criar padrões de vibração personalizados

Os padrões de vibração costumam ser usados em recursos táteis de atenção, como notificações e toques. O serviço Vibrator pode reproduzir padrões de vibração longos que mudam a amplitude da vibração ao longo do tempo. Esses efeitos são chamados de formas de onda.

Os efeitos em forma de onda geralmente são perceptíveis, mas vibrações longas e repentinas podem assustar o usuário se forem reproduzidas em um ambiente silencioso. Aumentar para uma amplitude de destino muito rapidamente também pode produzir ruídos audíveis. Projete padrões em forma de onda para suavizar as transições de amplitude e criar efeitos de aumento e diminuição.

Exemplos de padrões de vibração

As seções a seguir mostram vários exemplos de padrões de vibração:

Padrão de otimização

As formas de onda são representadas como VibrationEffect com três parâmetros:

  1. Timings:uma matriz de durações, em milissegundos, para cada segmento da forma de onda.
  2. Amplitudes:a amplitude de vibração desejada para cada duração especificada no primeiro argumento, representada por um valor inteiro de 0 a 255, em que 0 representa o "estado desligado" do vibrador e 255 é a amplitude máxima do dispositivo.
  3. Índice de repetição:o índice na matriz especificada no primeiro argumento para começar a repetir a forma de onda ou -1 se o padrão for reproduzido apenas uma vez.

Veja um exemplo de forma de onda que pulsa duas vezes com uma pausa de 350 ms entre os pulsos. O primeiro pulso é uma rampa suave até a amplitude máxima, e o segundo é uma rampa rápida para manter a amplitude máxima. A parada no final é definida pelo valor negativo do índice de repetição.

val timings: LongArray = longArrayOf(
    50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200
)
val amplitudes: IntArray = intArrayOf(
    33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255
)
val repeatIndex = -1 // Don't repeat.

vibrator.vibrate(
    VibrationEffect.createWaveform(
        timings, amplitudes, repeatIndex
    )
)

Padrão repetido

As formas de onda também podem ser reproduzidas repetidamente até serem canceladas. Para criar uma forma de onda repetida, defina um parâmetro repeat não negativo. Quando você reproduz uma forma de onda repetida, a vibração continua até ser explicitamente cancelada no serviço:

fun startVibrating() {
    val timings: LongArray = longArrayOf(50, 50, 100, 50, 50)
    val amplitudes: IntArray = intArrayOf(64, 128, 255, 128, 64)
    val repeat = 1 // Repeat from the second entry, index = 1.
    val repeatingEffect: VibrationEffect = VibrationEffect.createWaveform(
        timings, amplitudes, repeat
    )
    // repeatingEffect can be used in multiple places.

    vibrator.vibrate(repeatingEffect)
}

fun stopVibrating() {
    vibrator.cancel()
}

Isso é muito útil para eventos intermitentes que exigem uma ação do usuário para reconhecê-los. Alguns exemplos desses eventos incluem chamadas telefônicas recebidas e alarmes acionados.

Padrão com substituto

O controle da amplitude de uma vibração é um recurso que depende do hardware. A reprodução de uma forma de onda em um dispositivo mais simples sem esse recurso faz com que o dispositivo vibre na amplitude máxima para cada entrada positiva na matriz de amplitude. Se o app precisar acomodar esses dispositivos, use um padrão que não gere um efeito de zumbido quando reproduzido nessa condição ou crie um padrão de ativação/desativação mais simples que possa ser reproduzido como substituto.

if (vibrator.hasAmplitudeControl()) {
    vibrator.vibrate(
        VibrationEffect.createWaveform(
            smoothTimings, amplitudes, smoothRepeatIdx
        )
    )
} else {
    vibrator.vibrate(
        VibrationEffect.createWaveform(
            onOffTimings, onOffRepeatIdx
        )
    )
}

Composições fixadas na linha do tempo com VibrationEffect.Builder

A partir do Android 16 (26Q4), VibrationEffect.Builder é a API preferencial para criar efeitos e composições de vibração complexos. Ele permite criar sensações táteis expressivas sequenciando elementos táteis discretos em uma linha do tempo absoluta com startTimeMillis.

O VibrationEffect.Builder permite combinar vários tipos de vibração:

As vibrações criadas com VibrationEffect.Builder têm substituição automática integrada no nível do framework em todos os elementos (incluindo predefinições, envelopes básicos e efeitos de vibração combinados), garantindo uma experiência do usuário consistente em diferentes dispositivos sem exigir verificações manuais de capacidade. (Envelopes de forma de onda avançados criados com WaveformEnvelopeBuilder exigem suporte de hardware e não têm suporte de substituição automática.)

Escrever com predefinições

Use VibrationEffect.Preset para adicionar pulsos táteis curtos e predefinidos comuns (como PRESET_CLICK, PRESET_TICK ou PRESET_LOW_TICK) a uma composição. As predefinições são implementadas pelo fabricante do dispositivo para fornecer uma vibração nítida, curta e agradável que se alinha aos princípios de resposta tátil para uma resposta tátil clara. Para mais detalhes sobre esses recursos e como eles funcionam, consulte Introdução aos atuadores de vibração.

As predefinições substituem as primitivas curtas da API VibrationEffect.Composition. Para sensações táteis mais longas ou contínuas, como aumentar e diminuir, use formas de onda de envelope (PWLE, na sigla em inglês).

Cada predefinição pode receber uma escala opcional entre 0.0f e 1.0f e é colocada em um horário de início explícito (em milissegundos) a partir do início da composição.

Kotlin

val clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
)
val tickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
)

val effect = VibrationEffect.Builder()
    .addPreset(/* startTimeMillis= */ 0L, clickPreset)
    .addPreset(/* startTimeMillis= */ 100L, tickPreset)
    .build()

vibrator.vibrate(effect)

Java

VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
);
VibrationEffect.Preset tickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
);

VibrationEffect effect = new VibrationEffect.Builder()
    .addPreset(/* startTimeMillis= */ 0L, clickPreset)
    .addPreset(/* startTimeMillis= */ 100L, tickPreset)
    .build();

vibrator.vibrate(effect);

Compor com envelopes e predefinições

É possível combinar instâncias de VibrationEffect.Envelope (criadas com BasicEnvelopeBuilder ou WaveformEnvelopeBuilder) com predefinições para criar padrões táteis avançados de vários segmentos.

Veja um exemplo que reproduz um envelope de aumento e diminuição gradual seguido por uma predefinição de clique nítido:

Kotlin

val basicEnvelope = VibrationEffect.Envelope.create(
    VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 300L)
        .addControlPoint(0.0f, 0.5f, 100L)
)
val clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, 0.9f
)

val mixedEffect = VibrationEffect.Builder()
    .addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
    .addPreset(/* startTimeMillis= */ 450L, clickPreset)
    .build()

vibrator.vibrate(mixedEffect)

Java

VibrationEffect.Envelope basicEnvelope = VibrationEffect.Envelope.create(
    new VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 300L)
        .addControlPoint(0.0f, 0.5f, 100L)
);
VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, 0.9f
);

VibrationEffect mixedEffect = new VibrationEffect.Builder()
    .addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
    .addPreset(/* startTimeMillis= */ 450L, clickPreset)
    .build();

vibrator.vibrate(mixedEffect);

Reutilizar e mudar eventos atuais

Para reutilizar ou concatenar um VibrationEffect (incluindo VibrationEffect.Composition), recupere a lista de objetos VibrationEffect.Event usando getEvents() e adicione-os ao final com um deslocamento usando addEvents(startTimeShiftMillis, events) (ou transmita o efeito diretamente para o construtor VibrationEffect.Builder(effect)). Ao importar instâncias de VibrationEffect.Composition dessa forma, o framework converte automaticamente as primitivas em predefinições, permitindo o suporte ao substituto de ambiente de execução.

Kotlin

val existingEffect = VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
    )
    .addPreset(
        80L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
    )
    .build()

// Shift and append the existing events 200ms into the new composition.
val combinedEffect = VibrationEffect.Builder()
    .addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.events)
    .build()

vibrator.vibrate(combinedEffect)

Java

VibrationEffect existingEffect = new VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
    )
    .addPreset(
        80L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
    )
    .build();

// Shift and append the existing events 200ms into the new composition.
VibrationEffect combinedEffect = new VibrationEffect.Builder()
    .addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.getEvents())
    .build();

vibrator.vibrate(combinedEffect);

Criar composições repetidas

Use setRepeatingEffect(startTimeMillis, repeatingEffect, durationMillis) para adicionar um padrão de repetição a uma composição:

Kotlin

val repeatingPattern = VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_CLICK, 1.0f
        )
    )
    .addPreset(
        150L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
        )
    )
    .build()

val repeatingEffect = VibrationEffect.Builder()
    .setRepeatingEffect(
        /* startTimeMillis= */ 0L,
        /* effect= */ repeatingPattern,
        /* durationMillis= */ 300L
    )
    .build()

vibrator.vibrate(repeatingEffect)

Java

VibrationEffect repeatingPattern = new VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_CLICK, 1.0f
        )
    )
    .addPreset(
        150L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
        )
    )
    .build();

VibrationEffect repeatingEffect = new VibrationEffect.Builder()
    .setRepeatingEffect(
        /* startTimeMillis= */ 0L,
        /* effect= */ repeatingPattern,
        /* durationMillis= */ 300L
    )
    .build();

vibrator.vibrate(repeatingEffect);

Tempo, validação e gerenciamento de desvios

Ao projetar composições com VibrationEffect.Builder, lembre-se das seguintes regras de tempo e validação:

  • Horários de início estritamente crescentes:cada elemento adicionado ao builder precisa ter um startTimeMillis estritamente maior ou igual ao horário de início do elemento anterior.
  • Validação no momento da criação:o builder realiza a validação de melhor esforço no momento de build() usando durações de elementos conhecidas (ou um mínimo de 1 ms para predefinições). Se uma sobreposição impossível for detectada, ela vai gerar uma IllegalArgumentException.
  • Mudança sequencial de reprodução:se um elemento de vibração anterior ainda estiver sendo reproduzido fisicamente quando o horário de início do próximo elemento chegar, o framework vai mudar automaticamente o próximo elemento para o primeiro horário disponível. Isso garante que os eventos não se sobreponham e que nenhuma vibração seja descartada, mas pode introduzir uma pequena variação de tempo se os eventos forem programados muito próximos uns dos outros. Para minimizar o desvio, permita tempo suficiente (por exemplo, 50 ms ou mais) entre eventos táteis consecutivos.

Criar composições de primitivos de vibração

Nesta seção, mostramos como compor vibrações usando VibrationEffect.Composition. As primitivas de composição, descritas anteriormente nesta página, são implementadas pelo fabricante do dispositivo. Eles fornecem uma vibração nítida, curta e agradável que se alinha aos princípios de retorno tátil para um retorno tátil claro. Para mais detalhes sobre esses recursos e como eles funcionam, consulte Introdução aos atuadores de vibração.

Ao contrário de VibrationEffect.Builder, a API VibrationEffect.Composition não tem substitutos automáticos para primitivos sem suporte. Assim:

  1. Antes de ativar a resposta tátil avançada, verifique se um determinado dispositivo oferece suporte a todas as primitivas que você está usando.

  2. Desative o conjunto consistente de experiências que não são compatíveis, não apenas os efeitos que não têm uma primitiva.

  • Kotlin: val fallbackEffect = VibrationEffect.Builder(compositionEffect).build() (ou .addEvents(0L, compositionEffect.events))
  • Java: VibrationEffect fallbackEffect = new VibrationEffect.Builder(compositionEffect).build(); (ou .addEvents(0L, compositionEffect.getEvents()))

Quando construído com VibrationEffect.Builder, o framework converte as primitivas de composição em predefinições e fornece automaticamente um substituto de ambiente de execução se alguma primitiva não for compatível com o dispositivo do usuário.

Efeitos de vibração compostos

É possível criar efeitos de vibração compostos com VibrationEffect.Composition. Veja um exemplo de um efeito de aumento lento seguido por um efeito de clique nítido:

vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
    ).addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_CLICK
    ).compose()
)

Uma composição é criada adicionando primitivos para serem reproduzidos em sequência. Cada primitiva também é escalonável, para que você possa controlar a amplitude da vibração gerada por cada uma delas. A escala é definida como um valor entre 0 e 1, em que 0 é mapeado para uma amplitude mínima em que essa primitiva pode ser (mal) sentida pelo usuário.

Criar variantes em primitivas de vibração

Se você quiser criar uma versão fraca e uma forte da mesma primitiva, crie proporções de intensidade de 1,4 ou mais, para que a diferença de intensidade possa ser facilmente percebida. Não tente criar mais de três níveis de intensidade da mesma primitiva, porque eles não são perceptíveis. Por exemplo, use escalas de 0,5, 0,7 e 1,0 para criar versões de baixa, média e alta intensidade de uma primitiva.

Adicionar intervalos entre as primitivas de vibração

A composição também pode especificar atrasos a serem adicionados entre primitivas consecutivas. Esse atraso é expresso em milissegundos desde o fim da primitiva anterior. Em geral, um intervalo de 5 a 10 ms entre duas primitivas é muito curto para ser detectado. Use um intervalo de 50 ms ou mais se quiser criar um intervalo perceptível entre duas primitivas. Veja um exemplo de composição com atrasos:

val delayMs = 100
vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_SPIN, 0.8f
    ).addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_SPIN, 0.6f
    ).addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_THUD, 1.0f, delayMs
    ).compose()
)

Verificar quais primitivos são compatíveis

As APIs a seguir podem ser usadas para verificar se o dispositivo é compatível com primitivas específicas:

val primitive = VibrationEffect.Composition.PRIMITIVE_LOW_TICK

if (vibrator.areAllPrimitivesSupported(primitive)) {
    vibrator.vibrate(
        VibrationEffect.startComposition()
            .addPrimitive(primitive).compose()
    )
} else {
    // Play a predefined effect or custom pattern as a fallback.
}

Também é possível verificar várias primitivas e decidir quais compor com base no nível de suporte do dispositivo:

val primitives: IntArray = intArrayOf(
    VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
    VibrationEffect.Composition.PRIMITIVE_TICK,
    VibrationEffect.Composition.PRIMITIVE_CLICK
)
val supported: BooleanArray = vibrator.arePrimitivesSupported(*primitives)

Exemplos de composições de vibração

As seções a seguir mostram vários exemplos de composições de vibração, retirados do app de exemplo de recursos táteis no GitHub.

Resistência (com poucos tiques)

É possível controlar a amplitude da vibração primitiva para transmitir feedback útil para uma ação em andamento. Valores de escala próximos podem ser usados para criar um efeito de crescendo suave de uma primitiva. O atraso entre primitivas consecutivas também pode ser definido dinamicamente com base na interação do usuário. Isso é ilustrado no exemplo a seguir de uma animação de visualização controlada por um gesto de arrastar e aumentada com recursos táteis.

Animação de um círculo sendo arrastado para baixo.
Gráfico da forma de onda de vibração de entrada.

Figura 1. Essa forma de onda representa a aceleração de saída da vibração em um dispositivo.

@Composable
fun ResistScreen() {
    // Control variables for the dragging of the indicator.
    var isDragging by remember { mutableStateOf(false) }
    var dragOffset by remember { mutableFloatStateOf(0f) }

    // Only vibrates while the user is dragging
    LaunchedEffect(isDragging) {
        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        while (isDragging) {
            // Calculate the interval inversely proportional to the drag offset.
            val vibrationInterval = calculateVibrationInterval(dragOffset)
            // Calculate the scale directly proportional to the drag offset.
            val vibrationScale = calculateVibrationScale(dragOffset)

            delay(vibrationInterval)
            vibrator.vibrate(
                VibrationEffect.startComposition().addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                    vibrationScale
                ).compose()
            )
        }
    }

    Column(
        Modifier
            .draggable(
                orientation = Orientation.Vertical,
                onDragStarted = {
                    isDragging = true
                },
                onDragStopped = {
                    isDragging = false
                },
                state = rememberDraggableState { delta ->
                    dragOffset += delta
                }
            )
    ) {
        // Build the indicator UI based on how much the user has dragged it.
        ResistIndicator(dragOffset)
    }
}

Expandir (com aumento e queda)

Há duas primitivas para aumentar a intensidade de vibração percebida: PRIMITIVE_QUICK_RISE e PRIMITIVE_SLOW_RISE. Ambos atingem o mesmo objetivo, mas com durações diferentes. Há apenas um primitivo para redução, PRIMITIVE_QUICK_FALL. Essas primitivas funcionam melhor juntas para criar um segmento em forma de onda que aumenta de intensidade e depois diminui. É possível alinhar primitivas dimensionadas para evitar saltos repentinos de amplitude entre elas, o que também funciona bem para estender a duração geral do efeito. Em termos de percepção, as pessoas sempre notam mais a parte ascendente do que a descendente. Portanto, reduzir a parte ascendente em relação à descendente pode ser usado para mudar a ênfase para a parte descendente.

Veja um exemplo de aplicação dessa composição para expandir e recolher um círculo. O efeito de elevação pode melhorar a sensação de expansão durante a animação. A combinação de efeitos de subida e descida ajuda a enfatizar o colapso no final da animação.

Animação de um círculo em expansão.
Gráfico da forma de onda de vibração de entrada.

Figura 2.Essa forma de onda representa a aceleração de saída da vibração em um dispositivo.

enum class ExpandShapeState {
    Collapsed,
    Expanded
}

@Composable
fun ExpandScreen() {
    // Control variable for the state of the indicator.
    var currentState by remember { mutableStateOf(ExpandShapeState.Collapsed) }

    // Animation between expanded and collapsed states.
    val transitionData = updateTransitionData(currentState)

    Column(
        Modifier
            .clickable(
                onClick = {
                    if (currentState == ExpandShapeState.Collapsed) {
                        currentState = ExpandShapeState.Expanded
                        vibrator.vibrate(
                            VibrationEffect.startComposition().addPrimitive(
                                VibrationEffect.Composition.PRIMITIVE_SLOW_RISE,
                                0.3f
                            ).addPrimitive(
                                VibrationEffect.Composition.PRIMITIVE_QUICK_FALL,
                                0.3f
                            ).compose()
                        )
                    } else {
                        currentState = ExpandShapeState.Collapsed
                        vibrator.vibrate(
                            VibrationEffect.startComposition().addPrimitive(
                                VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
                            ).compose()
                        )
                    }
                }
            )
    ) {
        // Build the indicator UI based on the current state.
        ExpandIndicator(transitionData)
    }
}

Oscilação (com giros)

Um dos principais princípios de retorno tátil é encantar os usuários. Uma maneira divertida de introduzir um efeito de vibração inesperado e agradável é usar PRIMITIVE_SPIN. Essa primitiva é mais eficaz quando é chamada mais de uma vez. Várias rotações concatenadas podem criar um efeito de oscilação e instabilidade, que pode ser aprimorado ainda mais com a aplicação de um dimensionamento um tanto aleatório em cada primitiva. Também é possível testar o intervalo entre primitivas de rotação sucessivas. Dois giros sem intervalo (0 ms entre eles) criam uma sensação de giro intensa. Aumentar o intervalo entre giros de 10 para 50 ms leva a uma sensação de giro mais solta e pode ser usado para corresponder à duração de um vídeo ou uma animação.

Não use um intervalo maior que 100 ms, porque as rotações sucessivas não se integram bem e começam a parecer efeitos individuais.

Veja um exemplo de forma elástica que volta ao lugar depois de ser arrastada para baixo e solta. A animação é aprimorada com um par de efeitos de giro, reproduzidos com intensidades variadas que são proporcionais ao deslocamento do salto.

Animação de uma forma elástica quicando
Gráfico da forma de onda de vibração de entrada

Figura 3. Essa forma de onda representa a aceleração de saída da vibração em um dispositivo.

@Composable
fun WobbleScreen() {
    // Control variables for the dragging and animating state of the elastic.
    var dragDistance by remember { mutableFloatStateOf(0f) }
    var isWobbling by remember { mutableStateOf(false) }

    // Use drag distance to create an animated float value behaving like a spring.
    val dragDistanceAnimated by animateFloatAsState(
        targetValue = if (dragDistance > 0f) dragDistance else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioHighBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        // The spring ends on its target. An end value of 0f means that the
        // shape is back at rest, so the wobble is over.
        finishedListener = { endValue -> if (endValue == 0f) isWobbling = false },
    )

    LaunchedEffect(isWobbling) {
        while (isWobbling) {
            val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE
            // Use some sort of minimum displacement so the final few frames
            // of animation don't generate a vibration.
            if (displacement > SPIN_MIN_DISPLACEMENT) {
                vibrator.vibrate(
                    VibrationEffect.startComposition().addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SPIN,
                        nextSpinScale(displacement)
                    ).addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SPIN,
                        nextSpinScale(displacement)
                    ).compose()
                )
            }
            // Delay the next check for a sufficient duration until the
            // current composition finishes. Note that you can use
            // Vibrator.getPrimitiveDurations API to calculcate the delay.
            delay(VIBRATION_DURATION)
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .draggable(
                onDragStopped = {
                    // Wobble only if the shape is far enough from rest to vibrate.
                    // It then springs back to 0f, and the finishedListener ends
                    // the wobble.
                    val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE
                    isWobbling = displacement > SPIN_MIN_DISPLACEMENT
                    dragDistance = 0f
                },
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    isWobbling = false
                    dragDistance += delta
                }
            )
    ) {
        // Draw the wobbling shape using the animated spring-like value. The
        // lambda defers the read to the draw phase, so the animation doesn't
        // recompose this screen on every frame.
        WobbleShape { dragDistanceAnimated }
    }
}

// Calculate a random scale for each spin to vary the full effect.
fun nextSpinScale(displacement: Float): Float {
    // Generate a random offset in the range [-0.1, +0.1] to be added to the
    // vibration scale so the spin effects have slightly different values.
    val randomOffset: Float = Random.Default.nextFloat() * 0.2f - 0.1f
    return (displacement + randomOffset).absoluteValue.coerceIn(0f, 1f)
}

Quicar (com baques)

Outra aplicação avançada de efeitos de vibração é simular interações físicas. PRIMITIVE_THUD pode criar um efeito forte e reverberante, que pode ser combinado com a visualização de um impacto, em um vídeo ou animação, por exemplo, para melhorar a experiência geral.

Confira um exemplo de animação de queda de bola aprimorada com um efeito de batida reproduzido sempre que a bola quica na parte de baixo da tela:

Animação de uma bola caindo e quicando na parte de baixo da tela.
Gráfico da forma de onda de vibração de entrada.

Figura 4. Essa forma de onda representa a aceleração de saída da vibração em um dispositivo.

enum class BallPosition {
    Start,
    End
}

@Composable
fun BounceScreen() {
    // Control variable for the state of the ball.
    var ballPosition by remember { mutableStateOf(BallPosition.Start) }
    // Plain holder for the bounce count. It's read only in the effect and in
    // the click handler, never in composition, so it needs no snapshot state.
    val bounceCount = remember { intArrayOf(0) }

    // Animation for the bouncing ball.
    val transitionData = updateTransitionData(ballPosition)
    val collisionData = updateCollisionData(transitionData)

    // Ball is about to contact floor. The effect runs only when the collision
    // state changes, so it vibrates once per collision. Nothing here suspends,
    // so a keyed SideEffect does the job without launching a coroutine.
    SideEffect(collisionData.collisionWithFloor) {
        if (collisionData.collisionWithFloor) {
            val vibrationScale = 0.7.pow(bounceCount[0]++).toFloat()
            vibrator.vibrate(
                VibrationEffect.startComposition().addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_THUD,
                    vibrationScale
                ).compose()
            )
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .clickable {
                if (transitionData.isAtStart) {
                    ballPosition = BallPosition.End
                } else {
                    ballPosition = BallPosition.Start
                    bounceCount[0] = 0
                }
            },
    ) {
        // Build the ball UI based on the current state.
        BouncingBall(transitionData)
    }
}

Forma de onda de vibração com envelopes

O processo de criação de padrões de vibração personalizados permite controlar a amplitude de vibração para criar efeitos suaves de aumento e diminuição. Esta seção explica como criar efeitos táteis dinâmicos usando envelopes de forma de onda que permitem o controle preciso da amplitude e da frequência da vibração ao longo do tempo. Isso permite criar experiências táteis mais ricas e detalhadas.

A partir do Android 16 (nível 36 da API), o sistema oferece as seguintes APIs para criar um envelope de forma de onda de vibração definindo uma sequência de pontos de controle:

  • BasicEnvelopeBuilder:uma abordagem acessível para criar efeitos táteis independentes de hardware.
  • WaveformEnvelopeBuilder:uma abordagem mais avançada para criar efeitos táteis. Requer familiaridade com o hardware tátil.

O Android não oferece substitutos para efeitos de envelope. Se você precisar desse suporte, siga estas etapas:

  1. Verifique se um determinado dispositivo é compatível com efeitos de envelope usando Vibrator.areEnvelopeEffectsSupported().
  2. Desative o conjunto consistente de experiências que não são compatíveis ou use padrões de vibração personalizados ou composições como alternativas de substituição.

Para criar efeitos de envelope mais básicos, use o BasicEnvelopeBuilder com estes parâmetros:

  • Um valor de intensity no intervalo \( [0, 1] \), que representa a força percebida da vibração. Por exemplo, um valor de \( 0.5 \) é percebido como metade da intensidade máxima global que pode ser alcançada pelo dispositivo.
  • Um valor de nitidez no intervalo \( [0, 1] \), que representa a nitidez da vibração. Valores mais baixos se traduzem em vibrações mais suaves, enquanto valores mais altos criam uma sensação mais nítida.

  • Um valor de duração, que representa o tempo, em milissegundos, necessário para fazer a transição do último ponto de controle (ou seja, um par de intensidade e nitidez) para o novo.

Veja um exemplo de forma de onda que aumenta a intensidade de uma vibração de baixa frequência para uma de alta frequência e força máxima em 500 ms e, em seguida, diminui para\( 0 \) (desativado) em 100 ms.

vibrator.vibrate(
    VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 500)
        .addControlPoint(0.0f, 1.0f, 100)
        .build()
)

Se você tiver um conhecimento mais avançado de recursos táteis, poderá definir efeitos de envelope usando WaveformEnvelopeBuilder. Ao usar esse objeto, é possível acessar o mapeamento de frequência para aceleração de saída (FOAM, na sigla em inglês) pelo VibratorFrequencyProfile.

  • Um valor de amplitude no intervalo \( [0, 1] \), que representa a intensidade de vibração alcançável em uma determinada frequência, conforme determinado pelo FOAM do dispositivo. Por exemplo, um valor de \( 0.5 \) gera metade da aceleração de saída máxima que pode ser alcançada na frequência especificada.
  • Um valor de frequência, especificado em Hertz.

  • Um valor de duração, que representa o tempo, em milissegundos, necessário para fazer a transição do último ponto de controle para o novo.

O código a seguir mostra um exemplo de forma de onda que define um efeito de vibração de 400 ms. Ele começa com uma rampa de amplitude de 50 ms, de desligado para completo, a uma frequência constante de 60 Hz. Em seguida, a frequência aumenta para 120 Hz nos próximos 100 ms e permanece nesse nível por 200 ms. Por fim, a amplitude diminui para \( 0 \), e a frequência retorna para 60 Hz nos últimos 50 ms:

vibrator.vibrate(
    VibrationEffect.WaveformEnvelopeBuilder()
        .addControlPoint(1.0f, 60f, 50)
        .addControlPoint(1.0f, 120f, 100)
        .addControlPoint(1.0f, 120f, 200)
        .addControlPoint(0.0f, 60f, 50)
        .build()
)

As seções a seguir mostram vários exemplos de formas de onda de vibração com envelopes.

Mola saltitante

Um exemplo anterior usa PRIMITIVE_THUD para simular interações de retorno físico. A API de envelope básico oferece um controle significativamente mais refinado, permitindo que você ajuste com precisão a intensidade e a nitidez da vibração. Isso resulta em um retorno tátil que segue os eventos animados com mais precisão.

Veja um exemplo de uma mola em queda livre com a animação aprimorada por um efeito de envelope básico reproduzido sempre que a mola quica na parte de baixo da tela:

Animação de uma mola caindo e quicando na parte de baixo da tela.
Gráfico da forma de onda de vibração de entrada.

Figura 5. Um gráfico de forma de onda de aceleração de saída para uma vibração que simula uma mola quicando.

@Composable
fun BouncingSpringAnimation() {
    // The spring's height above the floor: 1f is the drop height, 0f is the floor.
    val springHeight = remember { Animatable(1f) }
    var isAnimating by remember { mutableStateOf(true) }

    LaunchedEffect(isAnimating) {
        if (!isAnimating) return@LaunchedEffect
        springHeight.snapTo(1f)

        // Each bounce reaches a fraction of the previous height.
        var bounceHeight = 1f
        while (bounceHeight > MIN_BOUNCE_HEIGHT) {
            // Fall to the floor, speeding up like a falling object. A fall from
            // a greater height takes longer.
            val fallDurationMs = (DROP_DURATION_MS * sqrt(bounceHeight)).toInt()
            springHeight.animateTo(0f, tween(fallDurationMs, easing = EaseInQuad))

            // The spring hits the floor: play a "boing" envelope effect that fades
            // out. A higher bounce gives a stronger and sharper vibration.
            vibrator.vibrate(
                VibrationEffect.BasicEnvelopeBuilder()
                    // Starting from zero sharpness here, will simulate a smoother
                    // "boing" effect.
                    .setInitialSharpness(0f)
                    // Add a control point to reach the target intensity and
                    // sharpness very quickly.
                    .addControlPoint(bounceHeight, bounceHeight * MAX_SHARPNESS, 20L)
                    // Add a control point to fade out the vibration intensity while
                    // maintaining sharpness.
                    .addControlPoint(0f, bounceHeight * MAX_SHARPNESS, fallDurationMs.toLong())
                    .build()
            )

            // Bounce back up to a lower height, slowing down like a thrown object.
            // When the next bounce is too small, the spring stays on the floor.
            bounceHeight *= BOUNCE_DAMPING
            if (bounceHeight > MIN_BOUNCE_HEIGHT) {
                val riseDurationMs = (DROP_DURATION_MS * sqrt(bounceHeight)).toInt()
                springHeight.animateTo(bounceHeight, tween(riseDurationMs, easing = EaseOutQuad))
            }
        }
        isAnimating = false
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .clickable(enabled = !isAnimating) { isAnimating = true }
            .drawBehind {
                // Read the height in the draw phase, and scale it to the distance
                // between the top of the box and the floor.
                val floorTop = size.height - FLOOR_HEIGHT.toPx()
                val dropDistance = floorTop - SPRING_HEIGHT.toPx()
                drawSpring(topY = dropDistance * (1f - springHeight.value))
                drawFloor(top = floorTop)
            }
    ) {
        if (!isAnimating) {
            Text("Tap to restart", Modifier.align(Alignment.Center))
        }
    }
}

Lançamento de foguete

Um exemplo anterior mostra como usar a API de envelope básica para simular uma reação de mola saltitante. O WaveformEnvelopeBuilder permite um controle preciso sobre toda a faixa de frequência do dispositivo, possibilitando efeitos táteis altamente personalizados. Ao combinar isso com os dados do FOAM, é possível adaptar as vibrações a recursos de frequência específicos.

Veja um exemplo que demonstra uma simulação de lançamento de foguete usando um padrão de vibração dinâmico. O efeito vai da saída de aceleração de frequência mínima com suporte, 0,1 G, até a frequência de ressonância, sempre mantendo uma entrada de amplitude de 10%. Isso permite que o efeito comece com uma saída razoavelmente forte e aumente a intensidade e a nitidez percebidas, mesmo que a amplitude de condução seja a mesma. Ao atingir a ressonância, a frequência do efeito volta ao mínimo, o que é percebido como uma diminuição da intensidade e da nitidez. Isso cria uma sensação de resistência inicial seguida por uma liberação, imitando um lançamento no espaço.

Esse efeito não é possível com a API de envelope básica, porque ela abstrai as informações específicas do dispositivo sobre a frequência de ressonância e a curva de aceleração de saída. Aumentar a nitidez pode empurrar a frequência equivalente além da ressonância, o que pode causar uma queda não intencional na aceleração.

Animação de um foguete decolando da parte de baixo da tela.
Gráfico da forma de onda de vibração de entrada.

Figura 6. Um gráfico de forma de onda de aceleração de saída para uma vibração que simula o lançamento de um foguete.

@Composable
fun RocketLaunchAnimation() {
    var isLaunched by remember { mutableStateOf(false) }
    val animation = remember { Animatable(0f) }

    val animationDuration = 3000
    LaunchedEffect(isLaunched) {
        if (isLaunched) {
            animation.animateTo(
                1.2f, // Overshoot so that the rocket goes off the screen.
                animationSpec = tween(
                    durationMillis = animationDuration,
                    // Applies an easing curve with a slow start and rapid acceleration
                    // towards the end.
                    easing = CubicBezierEasing(1f, 0f, 0.75f, 1f)
                )
            )
            animation.snapTo(0f)
            isLaunched = false
        }
    }

    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .clickable {
                if (!isLaunched) {
                    // Play vibration with same duration as the animation, using 70% of
                    // the time for the rise of the vibration, to match the easing curve
                    // defined previously.
                    playVibration(vibrator, animationDuration.toLong(), 0.7f)
                    isLaunched = true
                }
            }
            .background(colorResource(R.color.background))
    ) {
        // Read the animation in the draw phase, and scale it to the canvas height.
        drawRocket(size.height * animation.value)
    }
}

private fun playVibration(
    vibrator: Vibrator,
    totalDurationMs: Long,
    riseBias: Float,
    minOutputAccelerationGs: Float = 0.1f,
) {
    require(riseBias in 0f..1f) { "Rise bias must be between 0 and 1." }

    if (!vibrator.areEnvelopeEffectsSupported()) {
        return
    }

    val resonantFrequency = vibrator.resonantFrequency
    if (resonantFrequency.isNaN()) {
        // Device doesn't have or expose a resonant frequency.
        return
    }

    val startFrequency = vibrator.frequencyProfile?.getFrequencyRange(minOutputAccelerationGs)?.lower ?: return

    if (startFrequency >= resonantFrequency) {
        // Vibrator can't generate the minimum required output at lower frequencies.
        return
    }

    val minDurationMs = vibrator.envelopeEffectInfo.minControlPointDurationMillis
    val rampUpDurationMs = (riseBias * totalDurationMs).toLong() - minDurationMs
    val rampDownDurationMs = totalDurationMs - rampUpDurationMs - minDurationMs

    vibrator.vibrate(
        VibrationEffect.WaveformEnvelopeBuilder()
            // Quickly reach the target output at the start frequency
            .addControlPoint(0.1f, startFrequency, minDurationMs)
            .addControlPoint(0.1f, resonantFrequency, rampUpDurationMs)
            .addControlPoint(0.1f, startFrequency, rampDownDurationMs)
            // Controlled ramp down to zero to avoid ringing after the vibration.
            .addControlPoint(0.0f, startFrequency, minDurationMs)
            .build()
    )
}

LavaBeats

Como no exemplo de lançamento de foguete, a API WaveformEnvelopeBuilder permite o design de muitos efeitos táteis complexos, permitindo o controle de segmentos de amplitude e frequência em uma vibração. Outro exemplo desse tipo de design é a emulação de sensações físicas mais abstratas, como "vivacidade".

Isso pode ser feito representando biomarcadores de um sinal de eletrocardiograma (ECG) típico com segmentos de vibração de determinadas amplitudes e frequências. LavaBeats é um exemplo em que dois segmentos característicos de um registro de ECG são representados como dois pulsos separados por um atraso de tempo. O primeiro pulso característico é o complexo QRS, que aparece como um pico nítido de alta amplitude e curta duração. O segundo pulso é a onda T, que tem amplitude menor, duração maior e formato mais suave (consulte a Figura 7).

Use o WaveformEnvelopeBuilder para construir várias repetições desses dois pulsos separados por um atraso fixo do primeiro para o segundo pulso. O primeiro pulso pode ser um sinal de chirp que começa em uma frequência baixa e termina em uma frequência mais alta em um curto período. O segundo pulso pode ser representado como um único período de uma senoide de baixa frequência. Podemos compor os dois pulsos em uma batida e repetir a composição várias vezes com atraso entre elas, seguindo uma taxa típica de batidas por minuto (BPM). O resultado é um efeito tátil que lembra um coração batendo.

Você pode testar LavaBeats no nosso app de exemplo de efeitos táteis no GitHub e sentir o efeito acompanhado por uma visualização de lâmpada de lava que bate no mesmo ritmo do efeito tátil. Também é possível mudar as configurações do efeito para criar sensações de batimento diferentes modificando as amplitudes, frequências, durações e atrasos dos dois pulsos.

Animação de uma lâmpada de lava que pulsa com um padrão de batimento cardíaco tátil.
Gráfico de um segmento de ECG e suas formas de onda características

Figura 7. Um segmento de um registro de ECG com o complexo QRS e a onda T

private fun createEnvelopeEffect(
    beatParameters: List<BeatParameter>
): VibrationEffect =
    VibrationEffect.WaveformEnvelopeBuilder()
        .apply {
            repeat(beatParameters.getNumBeats()) {
                // First pulse chirp
                addControlPoint(
                    beatParameters.getFirstPulseAmplitude(),
                    beatParameters.getFirstPulseStartFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    beatParameters.getFirstPulseAmplitude(),
                    beatParameters.getFirstPulseEndFreq(),
                    beatParameters.getFirstPulseDurationMillis().toLong(),
                )
                addControlPoint(
                    0f,
                    beatParameters.getFirstPulseEndFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )

                // Delay between first and second pulse
                addControlPoint(
                    0f,
                    beatParameters.getFirstPulseEndFreq(),
                    beatParameters.getFirstToSecondPulseDelayMillis().toLong(),
                )

                // Second pulse
                addControlPoint(
                    beatParameters.getSecondPulseAmplitude(),
                    beatParameters.getSecondPulseFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    beatParameters.getSecondPulseAmplitude(),
                    beatParameters.getSecondPulseFreq(),
                    (1_000 / (2f * beatParameters.getSecondPulseFreq())).toLong(),
                )
                addControlPoint(
                    0f,
                    beatParameters.getSecondPulseFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    0f,
                    beatParameters.getSecondPulseFreq(),
                    beatParameters.getBeatDelayMillis().toLong(),
                )
            }
        }
        .build()

/** A parameter of a haptic beat effect that represents an ECG signal parameter */
data class BeatParameter(
    val description: String = "",
    val value: Float = 0f,
    val range: ClosedFloatingPointRange<Float> = 0f..1f,
    val steps: Int = 0,
    val isFrequencyType: Boolean = false,
)