Cómo crear efectos táctiles personalizados

En esta página, se incluyen ejemplos de cómo usar diferentes APIs de tecnología táctil 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 táctil.

Usa resguardos 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 la tecnología táctil de Android proporciona detalles sobre cómo verificar la compatibilidad con los componentes involucrados en la tecnología táctil para que tu app pueda proporcionar una experiencia general coherente.

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

Planifica las siguientes clases de alto nivel de capacidad del dispositivo:

  • Si usas primitivas táctiles, dispositivos compatibles con esas 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 básica con vibración (encendido/apagado); en otras palabras, aquellos que no tienen control de amplitud

Si la elección de efectos táctiles de tu app tiene en cuenta estas categorías, su experiencia táctil del usuario debería seguir siendo predecible para cualquier dispositivo individual.

Uso de primitivas táctiles

Android incluye varias primitivas táctiles que varían en amplitud y frecuencia. Puedes usar una primitiva sola o varias en combinación para lograr efectos táctiles enriquecidos.

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

Crea patrones de vibración personalizados

Los patrones de vibración suelen usarse en la tecnología táctil de atención, como las notificaciones y los tonos de llamada. El servicio Vibrator puede reproducir patrones de vibración largos que cambian la amplitud de vibración con el tiempo. Estos efectos se llaman formas de onda.

Los efectos de forma de onda suelen ser perceptibles, pero las vibraciones largas y repentinas pueden asustar al usuario si se reproducen en un entorno tranquilo. El aumento demasiado rápido a una amplitud objetivo también puede producir ruidos audibles. Diseña patrones de forma de onda para suavizar las transiciones de amplitud y crear efectos de rampa ascendente y descendente.

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. Tiempo: Es un array de duraciones, en milisegundos, para cada segmento de forma de onda.
  2. Amplitudes: Es 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 en el array especificado en el primer argumento para comenzar a repetir la forma de onda, o -1 si debe reproducir el patrón solo una vez.

Este es un ejemplo de una forma de onda que emite pulsos dos veces con una pausa de 350 ms entre los pulsos. El primer pulso es una rampa suave hasta la amplitud máxima, y el segundo es una rampa rápida para mantener la amplitud máxima. La detención al final se define por el valor negativo del índice de repetición.

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. La forma de crear una forma de onda repetida es establecer un parámetro repeat no negativo. Cuando reproduces una forma de onda repetida, 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 eventos intermitentes que requieren una acción del usuario para confirmarlos. Algunos ejemplos de estos eventos incluyen llamadas telefónicas entrantes y alarmas activadas.

Patrón con resguardo

El control de la amplitud de una vibración es una función que depende del hardware. Reproducir una forma de onda en un dispositivo de gama baja sin esta función hace que el dispositivo vibre a la amplitud máxima para cada entrada positiva en el array de amplitud. Si tu app necesita adaptarse a esos 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 va más allá para explorar la tecnología táctil enriquecida con capacidades de hardware más avanzadas. Puedes usar combinaciones de efectos que varían la amplitud y la frecuencia para crear efectos táctiles más complejos en dispositivos con actuadores táctiles que tienen un ancho de banda de frecuencia más amplio.

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

El fabricante del dispositivo implementa las primitivas 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 táctil para lograr una tecnología táctil clara. Para obtener más detalles sobre estas funciones y cómo funcionan, consulta el Instructivo sobre actuadores de vibración.

Android no proporciona resguardos para composiciones con primitivas no admitidas. Por lo tanto, sigue estos pasos:

  1. Antes de activar la tecnología táctil avanzada, verifica que un dispositivo determinado admita todas las primitivas que usas.

  2. Inhabilita el conjunto coherente de experiencias que no se admiten, 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.

Crea efectos de vibración compuestos

Puedes crear efectos de vibración compuestos con VibrationEffect.Composition. Este es un ejemplo de un efecto de aumento lento seguido de un efecto de clic nítido:

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

Para crear una composición, se agregan primitivas 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, en el que 0 se asigna a una amplitud mínima a la que el usuario puede sentir (apenas) esta primitiva.

Crea variantes en primitivas de vibración

Si deseas crear una versión débil y una fuerte de la misma primitiva, crea proporciones de intensidad de 1.4 o más, de modo que se pueda percibir fácilmente la diferencia de intensidad. No intentes crear más de tres niveles de intensidad de la misma primitiva, ya que no son distinguibles de forma perceptiva. 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 primitivas de vibración

La composición también puede especificar demoras que se agregarán entre primitivas consecutivas. Esta demora se expresa en milisegundos desde el final de la primitiva anterior. En general, una brecha de 5 a 10 ms entre dos primitivas es demasiado corta para ser detectable. Usa una brecha de alrededor de 50 ms o más si deseas crear una brecha discernible entre dos primitivas. Este es 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é primitivas son compatibles

Las siguientes APIs se pueden usar para verificar la compatibilidad del dispositivo con primitivas específicas:

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 varias primitivas y, luego, decidir cuáles compilar 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, tomados de la app de ejemplo de tecnología táctil en GitHub.

Resistencia (con marcas bajas)

Puedes controlar la amplitud de la vibración primitiva para transmitir comentarios útiles a una acción en curso. Se pueden usar valores de escala espaciados para crear un efecto crescendo suave de una primitiva. La demora entre primitivos consecutivos también se puede establecer de forma dinámica en función de 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 mejorada con tecnología táctil.

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 primitivos para aumentar la intensidad de vibración percibida: PRIMITIVE_QUICK_RISE y PRIMITIVE_SLOW_RISE. Ambos 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 crece en intensidad y, luego, desaparece. Puedes alinear primitivas ajustadas para evitar saltos repentinos en la amplitud entre ellas, lo que también funciona bien para extender la duración general del efecto. Perceptualmente, las personas siempre notan la parte ascendente más que la descendente, por lo que se puede usar la parte ascendente más corta que la descendente para cambiar el énfasis hacia la parte descendente.

Este es un ejemplo de una aplicación de esta composición para expandir y contraer un círculo. El efecto de aumento puede mejorar la sensación de expansión durante la animación. La combinación de efectos de aumento y disminución 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 principios táctiles clave es deleitar a los usuarios. Una forma divertida de introducir un efecto de vibración inesperado y agradable es usar PRIMITIVE_SPIN. Esta primitiva es más eficaz cuando se llama más de una vez. La concatenación de varias rotaciones puede crear un efecto inestable y tambaleante, que se puede mejorar aún más aplicando un escalamiento algo aleatorio en cada primitiva. También puedes experimentar con la brecha entre primitivas de giro sucesivas. Dos giros sin ningún espacio (0 ms de intervalo) crean una sensación de giro firme. 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 intervalo superior a 100 ms, ya que los giros sucesivos ya no se integran bien y comienzan a parecer 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 giro, que se reproducen con diferentes intensidades 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 simular 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.

Este es un ejemplo de una animación de caída de bola mejorada con un efecto de golpe que se reproduce cada vez que la bola rebota en la parte inferior de la pantalla:

Animación de una pelota que se deja caer y rebota en la parte inferior de la pantalla.
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 vibración para crear efectos suaves de aumento y disminución. En esta sección, se explica cómo crear efectos táctiles dinámicos con envolventes de forma de onda que permiten un control preciso de la amplitud y la frecuencia de vibración a lo largo del tiempo. Esto te permite crear experiencias táctiles 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: Es un enfoque accesible para crear efectos táctiles independientes del hardware.
  • WaveformEnvelopeBuilder: Es un enfoque más avanzado para crear efectos táctiles. Requiere familiaridad con el hardware táctil.

Android no proporciona resguardos 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:

  • Un valor de intensity 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 del último punto de control (es decir, un par de intensidad y nitidez) al nuevo.

Este es un ejemplo de forma de onda que aumenta la intensidad de una vibración de tono bajo a una de tono alto y de máxima intensidad en 500 ms y, luego, vuelve a disminuir a\( 0 \) (desactivada) en 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 tecnología táctil, puedes definir los 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.

  • Un valor de amplitud en el rango \( [0, 1] \), que representa la intensidad de vibración alcanzable a una frecuencia determinada, según lo determine la FOAM del dispositivo. Por ejemplo, un valor de \( 0.5 \) genera la mitad de la aceleración máxima de salida que se puede lograr a 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 realizar la transición del último punto de control al nuevo.

En el siguiente código, se muestra una forma de onda de ejemplo que define un efecto de vibración de 400 ms. Comienza con una rampa de amplitud de 50 ms, de apagado a completo, a 60 Hz constantes. Luego, la frecuencia aumenta hasta 120 Hz en los próximos 100 ms y permanece en ese nivel durante 200 ms. Por último, la amplitud disminuye hasta \( 0 \)y la frecuencia vuelve a 60 Hz en 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 un ejemplo anterior, se usa PRIMITIVE_THUD para simular interacciones de rebote físico. La API de envolvente básica ofrece un control mucho más preciso, lo que te permite ajustar con precisión la intensidad y la nitidez de la vibración. Esto genera una respuesta táctil que sigue con mayor precisión los eventos animados.

Este es 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:

Un 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. WaveformEnvelopeBuilder desbloquea un control preciso sobre el rango de frecuencia completo del dispositivo, lo que permite efectos táctiles altamente personalizados. Si combinas esto 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 cohetes 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 resonante, siempre manteniendo una entrada de amplitud del 10%. Esto permite que el efecto comience con una salida bastante potente y incremente la intensidad y la nitidez percibidas, aunque la amplitud de la pulsación sea la misma. Cuando se alcanza la resonancia, la frecuencia del efecto vuelve a descender al mínimo, lo que se percibe como una intensidad y una nitidez descendentes. Esto crea una sensación de resistencia inicial seguida de una liberación, lo 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 resonante y la curva de aceleración de salida. El aumento de la nitidez puede llevar la frecuencia equivalente más allá de la resonancia, lo que podría causar una disminución de la aceleración no deseada.

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