Cómo crear efectos táctiles personalizados

En esta página, se incluyen ejemplos de cómo usar diferentes APIs de hápticos para crear efectos personalizados más allá de las formas de onda de vibración estándar en una app para Android.

En esta página, se incluyen los siguientes ejemplos:

Para ver ejemplos adicionales, consulta Cómo agregar respuestas táctiles a eventos y sigue siempre los principios de diseño de hápticos.

Usa alternativas para controlar la compatibilidad de los dispositivos

Cuando implementes cualquier efecto personalizado, ten en cuenta lo siguiente:

  • Qué capacidades del dispositivo se requieren para el efecto
  • Qué hacer cuando el dispositivo no puede reproducir el efecto

La referencia de la API de hápticos de Android proporciona detalles para verificar la compatibilidad con los componentes involucrados en los hápticos, de modo que tu app pueda brindar una experiencia general coherente.

Según tu caso de uso, es posible que desees inhabilitar los efectos personalizados o proporcionar efectos personalizados alternativos basados en diferentes capacidades potenciales.

Planifica las siguientes clases de capacidades de dispositivos de alto nivel:

  • Si usas primitivas hápticas, los dispositivos deben admitir las primitivas que necesitan los efectos personalizados. (Consulta la siguiente sección para obtener detalles sobre las primitivas).

  • Dispositivos con control de amplitud.

  • Dispositivos con compatibilidad con vibración básica (activada/desactivada), es decir, aquellos que no tienen control de amplitud.

Si la elección del efecto háptico de tu app tiene en cuenta estas categorías, la experiencia del usuario háptica debería seguir siendo predecible para cualquier dispositivo individual.

Uso de elementos hápticos básicos

Android incluye varias primitivas hápticas que varían en amplitud y frecuencia. Puedes usar un solo elemento primitivo o varios en combinación para lograr efectos táctiles enriquecidos.

  • Usa retrasos de 50 ms o más para que se perciban las brechas entre dos primitivas, y también ten en cuenta la duración de la primitiva si es posible.
  • Usa escalas que difieran en una proporción de 1.4 o más para que se perciba mejor la diferencia en la intensidad.
  • Usa escalas de 0.5, 0.7 y 1.0 para crear una versión de intensidad baja, media y alta de una primitiva.

Cómo crear patrones de vibración personalizados

Los patrones de vibración se suelen usar en la háptica atencional, como las notificaciones y los tonos de llamada. El servicio Vibrator puede reproducir patrones de vibración largos que cambian la amplitud de la vibración con el tiempo. Estos efectos se conocen como formas de onda.

Por lo general, los efectos de forma de onda son perceptibles, pero las vibraciones largas y repentinas pueden asustar al usuario si se reproducen en un entorno silencioso. Aumentar la amplitud hasta un nivel objetivo demasiado rápido también puede producir zumbidos audibles. Diseña patrones de forma de onda para suavizar las transiciones de amplitud y crear efectos de aumento y disminución gradual.

Ejemplos de patrones de vibración

En las siguientes secciones, se proporcionan varios ejemplos de patrones de vibración:

Patrón de aumento

Las formas de onda se representan como VibrationEffect con tres parámetros:

  1. Tiempos: Es un array de duraciones, en milisegundos, para cada segmento de forma de onda.
  2. Amplitudes: La amplitud de vibración deseada para cada duración especificada en el primer argumento, representada por un valor entero de 0 a 255, en el que 0 representa el "estado de apagado" del vibrador y 255 es la amplitud máxima del dispositivo.
  3. Índice de repetición: Es el índice del array especificado en el primer argumento para comenzar a repetir la forma de onda o -1 si se debe reproducir el patrón solo una vez.

A continuación, se muestra un ejemplo de forma de onda que parpadea dos veces con una pausa de 350 ms entre los parpadeos. El primer pulso es un aumento gradual y uniforme hasta la amplitud máxima, y el segundo es un aumento rápido para mantener la amplitud máxima. El índice de repetición negativo define la detención al final.

Kotlin

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))

Java

long[] timings = new long[] {
    50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200 };
int[] amplitudes = new int[] {
    33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255 };
int repeatIndex = -1; // Don't repeat.

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

Patrón repetitivo

Las formas de onda también se pueden reproducir de forma repetida hasta que se cancelen. Para crear una forma de onda repetitiva, debes establecer un parámetro repeat no negativo. Cuando reproduces una forma de onda repetitiva, la vibración continúa hasta que se cancela de forma explícita en el servicio:

Kotlin

void 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.
VibrationEffect repeatingEffect = VibrationEffect.createWaveform(
    timings, amplitudes, repeat)
// repeatingEffect can be used in multiple places.

vibrator.vibrate(repeatingEffect)
}

void stopVibrating() {
vibrator.cancel()
}

Java

void startVibrating() {
long[] timings = new long[] { 50, 50, 100, 50, 50 };
int[] amplitudes = new int[] { 64, 128, 255, 128, 64 };
int repeat = 1; // Repeat from the second entry, index = 1.
VibrationEffect repeatingEffect = VibrationEffect.createWaveform(
    timings, amplitudes, repeat);
// repeatingEffect can be used in multiple places.

vibrator.vibrate(repeatingEffect);
}

void stopVibrating() {
vibrator.cancel();
}

Esto es muy útil para los eventos intermitentes que requieren la acción del usuario para confirmarlos. Algunos ejemplos de estos eventos son las llamadas telefónicas entrantes y las alarmas activadas.

Patrón con resguardo

Controlar la amplitud de una vibración es una capacidad dependiente del hardware. Reproducir una forma de onda en un dispositivo de gama baja sin esta capacidad hace que el dispositivo vibre con la amplitud máxima para cada entrada positiva en el array de amplitud. Si tu app necesita adaptarse a estos dispositivos, usa un patrón que no genere un efecto de zumbido cuando se reproduzca en esa condición o diseña un patrón de ENCENDIDO/APAGADO más simple que se pueda reproducir como alternativa.

Kotlin

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

Java

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

Crea composiciones de vibración

En esta sección, se presentan formas de componer vibraciones en efectos personalizados más largos y complejos, y se explora la háptica enriquecida con capacidades de hardware más avanzadas. Puedes usar combinaciones de efectos que varían la amplitud y la frecuencia para crear efectos hápticos más complejos en dispositivos con actuadores hápticos que tienen un ancho de banda de frecuencia más amplio.

El proceso para crear patrones de vibración personalizados, que se describió anteriormente en esta página, explica cómo controlar la amplitud de la vibración para crear efectos suaves de aumento y disminución. La háptica enriquecida mejora este concepto explorando el rango de frecuencia más amplio del vibrador del dispositivo para que el efecto sea aún más suave. Estas formas de onda son especialmente eficaces para crear un efecto de crescendo o diminuendo.

El fabricante del dispositivo implementa los primitivos de composición, que se describieron anteriormente en esta página. Proporcionan una vibración nítida, corta y agradable que se alinea con los principios de la tecnología háptica para una háptica clara. Para obtener más detalles sobre estas capacidades y cómo funcionan, consulta Introducción a los actuadores de vibración.

Android no proporciona alternativas para las composiciones con elementos primitivos no admitidos. Por lo tanto, sigue estos pasos:

  1. Antes de activar la respuesta háptica avanzada, verifica que un dispositivo determinado admita todos los elementos primitivos que usas.

  2. Inhabilita el conjunto coherente de experiencias que no son compatibles, no solo los efectos a los que les falta una primitiva.

En las siguientes secciones, se muestra más información para verificar la compatibilidad del dispositivo.

Cómo crear efectos de vibración compuestos

Puedes crear efectos de vibración compuestos con VibrationEffect.Composition. A continuación, se muestra un ejemplo de un efecto de aumento lento seguido de un efecto de clic pronunciado:

Kotlin

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

Java

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

Una composición se crea agregando elementos primitivos que se reproducirán en secuencia. Cada primitiva también es escalable, por lo que puedes controlar la amplitud de la vibración que genera cada una de ellas. La escala se define como un valor entre 0 y 1, donde 0 se asigna a una amplitud mínima en la que el usuario puede sentir (apenas) esta primitiva.

Crea variantes en los elementos gráficos primitivos de vibración

Si deseas crear una versión débil y una fuerte del mismo elemento primitivo, crea relaciones de fuerza de 1.4 o más, de modo que la diferencia en la intensidad se pueda percibir fácilmente. No intentes crear más de tres niveles de intensidad de la misma primitiva, ya que no son perceptualmente distintos. Por ejemplo, usa escalas de 0.5, 0.7 y 1.0 para crear versiones de intensidad baja, media y alta de una primitiva.

Agrega espacios entre los elementos primitivos de vibración

La composición también puede especificar retrasos que se agregarán entre las primitivas consecutivas. Este retraso se expresa en milisegundos desde el final de la primitiva anterior. En general, una brecha de 5 a 10 ms entre dos elementos primitivos es demasiado corta para ser detectable. Usa una brecha de 50 ms o más si quieres crear una brecha discernible entre dos elementos primitivos. A continuación, se muestra un ejemplo de una composición con retrasos:

Kotlin

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()
)

Java

int 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());

Verifica qué elementos primitivos se admiten

Se pueden usar las siguientes APIs para verificar la compatibilidad del dispositivo con elementos primitivos específicos:

Kotlin

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.
}

Java

int 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.
}

También es posible verificar varios elementos primitivos y, luego, decidir cuáles componer según el nivel de compatibilidad del dispositivo:

Kotlin

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

Java

int[] primitives = new int[] {
VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
VibrationEffect.Composition.PRIMITIVE_TICK,
VibrationEffect.Composition.PRIMITIVE_CLICK
};
boolean[] supported = vibrator.arePrimitivesSupported(effects);

Ejemplos de composiciones de vibración

En las siguientes secciones, se proporcionan varios ejemplos de composiciones de vibración, extraídos de la app de ejemplo de hápticos en GitHub.

Resistencia (con pocas marcas)

Puedes controlar la amplitud de la vibración primitiva para transmitir comentarios útiles sobre una acción en curso. Se pueden usar valores de escala muy cercanos para crear un efecto de crescendo suave de una primitiva. La demora entre primitivas consecutivas también se puede establecer de forma dinámica según la interacción del usuario. Esto se ilustra en el siguiente ejemplo de una animación de vista controlada por un gesto de arrastre y aumentada con hápticos.

Animación de un círculo que se arrastra hacia abajo.
Gráfico de la forma de onda de vibración de entrada.

Figura 1: Esta forma de onda representa la aceleración de salida de la vibración en un dispositivo.

Kotlin

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

    // Only vibrates while the user is dragging
    if (isDragging) {
        LaunchedEffect(Unit) {
        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        while (true) {
            // 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()
            )
        }
        }
    }

    Screen() {
        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)
        }
    }
}

Java

class DragListener implements View.OnTouchListener {
    // Control variables for the dragging of the indicator.
    private int startY;
    private int vibrationInterval;
    private float vibrationScale;

    @Override
    public boolean onTouch(View view, MotionEvent event) {
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            startY = event.getRawY();
            vibrationInterval = calculateVibrationInterval(0);
            vibrationScale = calculateVibrationScale(0);
            startVibration();
            break;
        case MotionEvent.ACTION_MOVE:
            float dragOffset = event.getRawY() - startY;
            // Calculate the interval inversely proportional to the drag offset.
            vibrationInterval = calculateVibrationInterval(dragOffset);
            // Calculate the scale directly proportional to the drag offset.
            vibrationScale = calculateVibrationScale(dragOffset);
            // Build the indicator UI based on how much the user has dragged it.
            updateIndicator(dragOffset);
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            // Only vibrates while the user is dragging
            cancelVibration();
            break;
        }
        return true;
    }

    private void startVibration() {
        vibrator.vibrate(
            VibrationEffect.startComposition()
                .addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                        vibrationScale)
                .compose());

        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        handler.postDelayed(this::startVibration, vibrationInterval);
    }

    private void cancelVibration() {
        handler.removeCallbacksAndMessages(null);
    }
}

Expandir (con aumento y disminución)

Hay dos primitivas para aumentar la intensidad de vibración percibida: PRIMITIVE_QUICK_RISE y PRIMITIVE_SLOW_RISE. Ambas alcanzan el mismo objetivo, pero con duraciones diferentes. Solo hay una primitiva para la reducción gradual, PRIMITIVE_QUICK_FALL. Estas primitivas funcionan mejor juntas para crear un segmento de forma de onda que aumenta en intensidad y luego se desvanece. Puedes alinear las primitivas escaladas para evitar saltos repentinos en la amplitud entre ellas, lo que también funciona bien para extender la duración del efecto general. En términos de percepción, las personas siempre notan más la parte ascendente que la descendente, por lo que hacer que la parte ascendente sea más corta que la descendente puede usarse para desplazar el énfasis hacia la parte descendente.

A continuación, se muestra un ejemplo de la aplicación de esta composición para expandir y contraer un círculo. El efecto de elevación puede mejorar la sensación de expansión durante la animación. La combinación de los efectos de elevación y caída ayuda a enfatizar el colapso al final de la animación.

Animación de un círculo que se expande.
Gráfico de la forma de onda de vibración de entrada.

Figura 2: Esta forma de onda representa la aceleración de salida de la vibración en un dispositivo.

Kotlin

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)

    Screen() {
        Column(
        Modifier
            .clickable(
            {
                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)
        }
    }
}

Java

class ClickListener implements View.OnClickListener {
    private final Animation expandAnimation;
    private final Animation collapseAnimation;
    private boolean isExpanded;

    ClickListener(Context context) {
        expandAnimation = AnimationUtils.loadAnimation(context, R.anim.expand);
        expandAnimation.setAnimationListener(new Animation.AnimationListener() {

        @Override
        public void onAnimationStart(Animation animation) {
            vibrator.vibrate(
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 0.3f)
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 0.3f)
                .compose());
        }
        });

        collapseAnimation = AnimationUtils
                .loadAnimation(context, R.anim.collapse);
        collapseAnimation.setAnimationListener(new Animation.AnimationListener() {

            @Override
            public void onAnimationStart(Animation animation) {
                vibrator.vibrate(
                VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SLOW_RISE)
                    .compose());
            }
        });
    }

    @Override
    public void onClick(View view) {
        view.startAnimation(isExpanded ? collapseAnimation : expandAnimation);
        isExpanded = !isExpanded;
    }
}

Balancín (con giros)

Uno de los principales principios de la tecnología háptica es deleitar a los usuarios. Una forma divertida de introducir un efecto de vibración agradable e inesperado es usar PRIMITIVE_SPIN. Este elemento primitivo es más eficaz cuando se llama más de una vez. Varios giros concatenados pueden crear un efecto de bamboleo y de inestabilidad, que se puede mejorar aún más aplicando un ajuste de escala algo aleatorio en cada primitiva. También puedes experimentar con el intervalo entre las primitivas de giro sucesivas. Dos giros sin ningún espacio (0 ms entre ellos) crean una sensación de giro ajustada. Aumentar el intervalo entre giros de 10 a 50 ms genera una sensación de giro más suelta y se puede usar para que coincida con la duración de un video o una animación.

No uses un espacio mayor a 100 ms, ya que los giros sucesivos ya no se integran bien y comienzan a sentirse como efectos individuales.

Este es un ejemplo de una forma elástica que rebota después de arrastrarse hacia abajo y, luego, soltarse. La animación se mejora con un par de efectos de rotación que se reproducen con intensidades variables proporcionales al desplazamiento del rebote.

Animación de una forma elástica que rebota
Gráfico de la forma de onda de vibración de entrada

Figura 3: Esta forma de onda representa la aceleración de salida de la vibración en un dispositivo.

Kotlin

@Composable
fun WobbleScreen() {
    // Control variables for the dragging and animating state of the elastic.
    var dragDistance by remember { mutableStateOf(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
        ),
    )

    if (isWobbling) {
        LaunchedEffect(Unit) {
            while (true) {
                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 = {
                    isWobbling = true
                    dragDistance = 0f
                },
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    isWobbling = false
                    dragDistance += delta
                }
            )
    ) {
        // Draw the wobbling shape using the animated spring-like value.
        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)
}

Java

class AnimationListener implements DynamicAnimation.OnAnimationUpdateListener {
    private final Random vibrationRandom = new Random(seed);
    private final long lastVibrationUptime;

    @Override
    public void onAnimationUpdate(
        DynamicAnimation animation, float value, float velocity) {
        // 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.
        if (SystemClock.uptimeMillis() - lastVibrationUptime < VIBRATION_DURATION) {
            return;
        }

        float displacement = calculateRelativeDisplacement(value);

        // Use some sort of minimum displacement so the final few frames
        // of animation don't generate a vibration.
        if (displacement < SPIN_MIN_DISPLACEMENT) {
            return;
        }

        lastVibrationUptime = SystemClock.uptimeMillis();
        vibrator.vibrate(
        VibrationEffect.startComposition()
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .compose());
    }

    // Calculate a random scale for each spin to vary the full effect.
    float nextSpinScale(float displacement) {
        // 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.
        float randomOffset = vibrationRandom.nextFloat() * 0.2f - 0.1f
        return MathUtils.clamp(displacement + randomOffset, 0f, 1f)
    }
}

Rebote (con golpes)

Otra aplicación avanzada de los efectos de vibración es la simulación de interacciones físicas. PRIMITIVE_THUD puede crear un efecto fuerte y reverberante, que se puede combinar con la visualización de un impacto, por ejemplo, en un video o una animación, para mejorar la experiencia general.

A continuación, se muestra un ejemplo de una animación de caída de una pelota mejorada con un efecto de golpe que se reproduce cada vez que la pelota rebota en la parte inferior de la pantalla:

Animación de una pelota que rebota en la parte inferior de la pantalla después de caer.
Gráfico de la forma de onda de vibración de entrada.

Figura 4: Esta forma de onda representa la aceleración de salida de la vibración en un dispositivo.

Kotlin

enum class BallPosition {
    Start,
    End
}

@Composable
fun BounceScreen() {
    // Control variable for the state of the ball.
    var ballPosition by remember { mutableStateOf(BallPosition.Start) }
    var bounceCount by remember { mutableStateOf(0) }

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

    // Ball is about to contact floor, only vibrating once per collision.
    var hasVibratedForBallContact by remember { mutableStateOf(false) }
    if (collisionData.collisionWithFloor) {
        if (!hasVibratedForBallContact) {
        val vibrationScale = 0.7.pow(bounceCount++).toFloat()
        vibrator.vibrate(
            VibrationEffect.startComposition().addPrimitive(
            VibrationEffect.Composition.PRIMITIVE_THUD,
            vibrationScale
            ).compose()
        )
        hasVibratedForBallContact = true
        }
    } else {
        // Reset for next contact with floor.
        hasVibratedForBallContact = false
    }

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

Java

class ClickListener implements View.OnClickListener {
    @Override
    public void onClick(View view) {
        view.animate()
        .translationY(targetY)
        .setDuration(3000)
        .setInterpolator(new BounceInterpolator())
        .setUpdateListener(new AnimatorUpdateListener() {

            boolean hasVibratedForBallContact = false;
            int bounceCount = 0;

            @Override
            public void onAnimationUpdate(ValueAnimator animator) {
            boolean valueBeyondThreshold = (float) animator.getAnimatedValue() > 0.98;
            if (valueBeyondThreshold) {
                if (!hasVibratedForBallContact) {
                float vibrationScale = (float) Math.pow(0.7, bounceCount++);
                vibrator.vibrate(
                    VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_THUD,
                        vibrationScale)
                    .compose());
                hasVibratedForBallContact = true;
                }
            } else {
                // Reset for next contact with floor.
                hasVibratedForBallContact = false;
            }
            }
        });
    }
}

Forma de onda de vibración con envolventes

El proceso para crear patrones de vibración personalizados te permite controlar la amplitud de la vibración para crear efectos suaves de aumento y disminución. En esta sección, se explica cómo crear efectos hápticos dinámicos con envolventes de forma de onda que permiten un control preciso de la amplitud y la frecuencia de la vibración a lo largo del tiempo. Esto te permite crear experiencias hápticas más ricas y matizadas.

A partir de Android 16 (nivel de API 36), el sistema proporciona las siguientes APIs para crear una envolvente de forma de onda de vibración definiendo una secuencia de puntos de control:

  • BasicEnvelopeBuilder: Un enfoque accesible para crear efectos hápticos independientes del hardware.
  • WaveformEnvelopeBuilder: Es un enfoque más avanzado para crear efectos hápticos y requiere familiaridad con el hardware háptico.

Android no proporciona alternativas para los efectos de envolvente. Si necesitas esta asistencia, completa los siguientes pasos:

  1. Verifica si un dispositivo determinado admite efectos de envolvente con Vibrator.areEnvelopeEffectsSupported().
  2. Inhabilita el conjunto coherente de experiencias que no son compatibles o usa patrones de vibración personalizados o composiciones como alternativas de resguardo.

Para crear efectos de envolvente más básicos, usa BasicEnvelopeBuilder con estos parámetros:

  • Es un valor de intensidad en el rango \( [0, 1] \), que representa la intensidad percibida de la vibración. Por ejemplo, un valor de \( 0.5 \)se percibe como la mitad de la intensidad máxima global que puede alcanzar el dispositivo.
  • Un valor de nitidez en el rango \( [0, 1] \), que representa la nitidez de la vibración. Los valores más bajos se traducen en vibraciones más suaves, mientras que los valores más altos crean una sensación más nítida.

  • Un valor de duración, que representa el tiempo, en milisegundos, que se tarda en realizar la transición desde el último punto de control (es decir, un par de intensidad y nitidez) al nuevo.

A continuación, se muestra un ejemplo de forma de onda que aumenta la intensidad de una vibración de tono bajo a una de tono alto y máxima potencia durante 500 ms y, luego, vuelve a disminuir a\( 0 \) (apagado) durante 100 ms.

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

Si tienes conocimientos más avanzados sobre la háptica, puedes definir efectos de envolvente con WaveformEnvelopeBuilder. Cuando usas este objeto, puedes acceder a la asignación de frecuencia a aceleración de salida (FOAM) a través de VibratorFrequencyProfile.

  • Es un valor de amplitud en el rango \( [0, 1] \), que representa la intensidad de vibración alcanzable en una frecuencia determinada, según lo determina la FOAM del dispositivo. Por ejemplo, un valor de \( 0.5 \) genera la mitad de la aceleración de salida máxima que se puede lograr en la frecuencia determinada.
  • Es un valor de frecuencia, especificado en hercios.

  • Un valor de duración, que representa el tiempo, en milisegundos, que se tarda en pasar del último punto de control al nuevo.

El siguiente código muestra un ejemplo de forma de onda que define un efecto de vibración de 400 ms. Comienza con una rampa de amplitud de 50 ms, de apagado a completo, a una frecuencia constante de 60 Hz. Luego, la frecuencia aumenta a 120 Hz durante los siguientes 100 ms y permanece en ese nivel durante 200 ms. Por último, la amplitud disminuye a \( 0 \)y la frecuencia vuelve a 60 Hz durante los ú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()
)

En las siguientes secciones, se proporcionan varios ejemplos de formas de onda de vibración con envolventes.

Resorte con rebote

En una muestra anterior, se usó PRIMITIVE_THUD para simular interacciones de rebote físico. La API de sobre básico ofrece un control mucho más preciso, lo que te permite personalizar con exactitud la intensidad y la nitidez de la vibración. Esto genera una respuesta háptica que sigue con mayor precisión los eventos animados.

A continuación, se muestra un ejemplo de un resorte en caída libre con la animación mejorada con un efecto de envolvente básico que se reproduce cada vez que el resorte rebota en la parte inferior de la pantalla:

Animación de un resorte que cae y rebota en la parte inferior de la pantalla.
Gráfico de la forma de onda de vibración de entrada.

Figura 5: Gráfico de forma de onda de aceleración de salida para una vibración que simula un resorte que rebota.

@Composable
fun BouncingSpringAnimation() {
  var springX by remember { mutableStateOf(SPRING_WIDTH) }
  var springY by remember { mutableStateOf(SPRING_HEIGHT) }
  var velocityX by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
  var velocityY by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
  var sharpness by remember { mutableFloatStateOf(INITIAL_SHARPNESS) }
  var intensity by remember { mutableFloatStateOf(INITIAL_INTENSITY) }
  var multiplier by remember { mutableFloatStateOf(INITIAL_MULTIPLIER) }
  var bottomBounceCount by remember { mutableIntStateOf(0) }
  var animationStartTime by remember { mutableLongStateOf(0L) }
  var isAnimating by remember { mutableStateOf(false) }

  val (screenHeight, screenWidth) = getScreenDimensions(context)

  LaunchedEffect(isAnimating) {
    animationStartTime = System.currentTimeMillis()
    isAnimating = true

    while (isAnimating) {
      velocityY += GRAVITY
      springX += velocityX.dp
      springY += velocityY.dp

      // Handle bottom collision
      if (springY > screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2) {
        // Set the spring's y-position to the bottom bounce point, to keep it
        // above the floor.
        springY = screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2

        // Reverse the vertical velocity and apply damping to simulate a bounce.
        velocityY *= -BOUNCE_DAMPING
        bottomBounceCount++

        // Calculate the fade-out duration of the vibration based on the
        // vertical velocity.
        val fadeOutDuration =
            ((abs(velocityY) / GRAVITY) * FRAME_DELAY_MS).toLong()

        // Create a "boing" envelope vibration effect that fades out.
        vibrator.vibrate(
            VibrationEffect.BasicEnvelopeBuilder()
                // Starting from zero sharpness here, will simulate a smoother
                // "boing" effect.
                .setInitialSharpness(0f)

                // Add a control point to reach the desired intensity and
                // sharpness very quickly.
                .addControlPoint(intensity, sharpness, 20L)

                // Add a control point to fade out the vibration intensity while
                // maintaining sharpness.
                .addControlPoint(0f, sharpness, fadeOutDuration)
                .build()
        )

        // Decrease the intensity and sharpness of the vibration for subsequent
        // bounces, and reduce the multiplier to create a fading effect.
        intensity *= multiplier
        sharpness *= multiplier
        multiplier -= 0.1f
      }

      if (springX > screenWidth - SPRING_WIDTH / 2) {
        // Prevent the spring from moving beyond the right edge of the screen.
        springX = screenWidth - SPRING_WIDTH / 2
      }

      // Check for 3 bottom bounces and then slow down.
      if (bottomBounceCount >= MAX_BOTTOM_BOUNCE &&
            System.currentTimeMillis() - animationStartTime > 1000) {
        velocityX *= 0.9f
        velocityY *= 0.9f
      }

      delay(FRAME_DELAY_MS) // Control animation speed.

      // Determine if the animation should continue based on the spring's
      // position and velocity.
      isAnimating = (springY < screenHeight + SPRING_HEIGHT ||
            springX < screenWidth + SPRING_WIDTH)
        && (velocityX >= 0.1f || velocityY >= 0.1f)
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        if (!isAnimating) {
          resetAnimation()
        }
      }
      .width(screenWidth)
      .height(screenHeight)
  ) {
    DrawSpring(mutableStateOf(springX), mutableStateOf(springY))
    DrawFloor()
    if (!isAnimating) {
      DrawText("Tap to restart")
    }
  }
}

Lanzamiento de un cohete

En un ejemplo anterior, se muestra cómo usar la API de envolvente básica para simular una reacción de resorte elástica. El WaveformEnvelopeBuilder desbloquea el control preciso sobre el rango de frecuencia completo del dispositivo, lo que permite efectos hápticos altamente personalizados. Si combinas esta información con los datos de FOAM, puedes adaptar las vibraciones a capacidades de frecuencia específicas.

Este es un ejemplo que muestra una simulación de lanzamiento de cohete con un patrón de vibración dinámico. El efecto va desde la salida de aceleración de frecuencia mínima admitida, 0.1 G, hasta la frecuencia de resonancia, y siempre mantiene una entrada de amplitud del 10%. Esto permite que el efecto comience con una salida razonablemente fuerte y aumente la intensidad y la nitidez percibidas, aunque la amplitud de conducción sea la misma. Cuando se alcanza la resonancia, la frecuencia del efecto desciende hasta el mínimo, lo que se percibe como una disminución de la intensidad y la nitidez. Esto crea una sensación de resistencia inicial seguida de una liberación, que imita un lanzamiento al espacio.

Este efecto no es posible con la API de envolvente básica, ya que abstrae la información específica del dispositivo sobre su frecuencia de resonancia y la curva de aceleración de salida. Aumentar la nitidez puede llevar la frecuencia equivalente más allá de la resonancia, lo que podría causar una caída no deseada en la aceleración.

Animación de un cohete que despega desde la parte inferior de la pantalla.
Gráfico de la forma de onda de vibración de entrada.

Figura 6: Gráfico de forma de onda de aceleración de salida para una vibración que simula el lanzamiento de un cohete.

@Composable
fun RocketLaunchAnimation() {
  val context = LocalContext.current
  val screenHeight = remember { mutableFloatStateOf(0f) }
  var rocketPositionY by remember { mutableFloatStateOf(0f) }
  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)
        )
      ) {
        rocketPositionY = screenHeight.floatValue * value
      }
      animation.snapTo(0f)
      rocketPositionY = 0f;
      isLaunched = false;
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        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, 0.7f)
          isLaunched = true
        }
      }
      .background(Color(context.getColor(R.color.background)))
      .onSizeChanged { screenHeight.floatValue = it.height.toFloat() }
  ) {
    drawRocket(rocketPositionY)
  }
}

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 - rampUpDuration - minDurationMs

  vibrator.vibrate(
    VibrationEffect.WaveformEnvelopeBuilder()
      // Quickly reach the desired 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()
  )
}