Créer des effets haptiques personnalisés

Cette page présente des exemples d'utilisation de différentes API haptiques pour créer des effets personnalisés au-delà des formes d'onde de vibration standards dans une application Android.

Cette page inclut les exemples suivants :

Pour obtenir d'autres exemples, consultez Ajouter un retour haptique aux événements et suivez toujours les principes de conception haptique.

Utiliser des solutions de remplacement pour gérer la compatibilité des appareils

Lorsque vous implémentez un effet personnalisé, tenez compte des points suivants :

  • Fonctionnalités de l'appareil requises pour l'effet
  • Que faire lorsque l'appareil n'est pas capable de lire l'effet

La documentation de référence de l'API haptique Android explique comment vérifier la compatibilité des composants impliqués dans vos retours haptiques, afin que votre application puisse offrir une expérience globale cohérente.

Selon votre cas d'utilisation, vous pouvez désactiver les effets personnalisés ou en proposer d'autres en fonction des différentes capacités potentielles.

Planifiez les classes générales de capacités des appareils suivantes :

  • Si vous utilisez des primitives haptiques : les appareils compatibles avec les primitives requises par les effets personnalisés. (Pour en savoir plus sur les primitives, consultez la section suivante.)

  • Appareils dotés d'un contrôle de l'amplitude.

  • Appareils avec prise en charge des vibrations de base (activées/désactivées), c'est-à-dire ceux qui ne permettent pas de contrôler l'amplitude.

Si le choix de l'effet haptique de votre application tient compte de ces catégories, l'expérience utilisateur haptique devrait rester prévisible pour n'importe quel appareil.

Utilisation de primitives haptiques

Android inclut plusieurs primitives haptiques dont l'amplitude et la fréquence varient. Vous pouvez utiliser une seule primitive ou plusieurs primitives combinées pour obtenir des effets haptiques riches.

  • Utilisez des délais de 50 ms ou plus pour créer des espaces visibles entre deux primitives, en tenant également compte de la durée de la primitive si possible.
  • Utilisez des échelles dont le rapport est d'au moins 1,4 afin que la différence d'intensité soit mieux perçue.
  • Utilisez des échelles de 0,5, 0,7 et 1,0 pour créer une version à faible, moyenne et haute intensité d'une primitive.

Créer des schémas de vibration personnalisés

Les schémas de vibration sont souvent utilisés dans les haptiques attentionnelles, comme les notifications et les sonneries. Le service Vibrator peut lire de longs schémas de vibration qui modifient l'amplitude de la vibration au fil du temps. Ces effets sont appelés formes d'onde.

Les effets de forme d'onde sont généralement perceptibles, mais de longues vibrations soudaines peuvent surprendre l'utilisateur si elles sont jouées dans un environnement calme. Si vous augmentez trop rapidement l'amplitude cible, vous risquez également de produire des bourdonnements audibles. Concevez des formes d'onde pour lisser les transitions d'amplitude et créer des effets de montée et de descente.

Exemples de types de vibration

Les sections suivantes fournissent plusieurs exemples de schémas de vibration :

Modèle d'activation progressive

Les formes d'onde sont représentées sous la forme VibrationEffect avec trois paramètres :

  1. Timings : tableau des durées, en millisecondes, de chaque segment de forme d'onde.
  2. Amplitudes : amplitude de vibration souhaitée pour chaque durée spécifiée dans le premier argument, représentée par une valeur entière comprise entre 0 et 255, où 0 représente l'état "vibreur désactivé" et 255 l'amplitude maximale de l'appareil.
  3. Index de répétition : index du tableau spécifié dans le premier argument pour commencer à répéter la forme d'onde, ou -1 si le modèle ne doit être lu qu'une seule fois.

Voici un exemple de forme d'onde qui émet deux impulsions avec une pause de 350 ms entre les deux. La première impulsion est une rampe douce jusqu'à l'amplitude maximale, et la seconde est une rampe rapide pour maintenir l'amplitude maximale. L'arrêt à la fin est défini par la valeur d'index de répétition négative.

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

Schéma répétitif

Les formes d'onde peuvent également être lues en boucle jusqu'à ce que vous les annuliez. Pour créer une forme d'onde répétée, définissez un paramètre repeat non négatif. Lorsque vous lisez une forme d'onde répétée, la vibration se poursuit jusqu'à ce qu'elle soit explicitement annulée dans le service :

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

Cela est très utile pour les événements intermittents qui nécessitent une action de l'utilisateur pour être reconnus. Il peut s'agir, par exemple, d'appels téléphoniques entrants ou d'alarmes déclenchées.

Modèle avec création de remplacement

Le contrôle de l'amplitude d'une vibration est une fonctionnalité qui dépend du matériel. La lecture d'une forme d'onde sur un appareil d'entrée de gamme sans cette fonctionnalité entraîne la vibration de l'appareil à l'amplitude maximale pour chaque entrée positive du tableau d'amplitude. Si votre application doit s'adapter à de tels appareils, utilisez un schéma qui ne génère pas d'effet de bourdonnement lorsqu'il est lu dans ces conditions, ou concevez un schéma ON/OFF plus simple qui peut être lu en remplacement.

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

Créer des compositions de vibrations

Cette section présente des méthodes pour composer des vibrations en effets personnalisés plus longs et plus complexes, et va au-delà pour explorer les haptiques riches à l'aide de fonctionnalités matérielles plus avancées. Vous pouvez combiner des effets qui font varier l'amplitude et la fréquence pour créer des effets haptiques plus complexes sur les appareils dotés d'actionneurs haptiques ayant une bande passante de fréquence plus large.

La procédure de création de schémas de vibration personnalisés, décrite précédemment sur cette page, explique comment contrôler l'amplitude des vibrations pour créer des effets de montée et de descente en douceur. Les retours haptiques riches améliorent ce concept en explorant la plage de fréquences plus large du vibreur de l'appareil pour rendre l'effet encore plus fluide. Ces formes d'onde sont particulièrement efficaces pour créer un effet crescendo ou diminuendo.

Les primitives de composition, décrites plus haut sur cette page, sont implémentées par le fabricant de l'appareil. Elles fournissent une vibration nette, courte et agréable qui s'aligne sur les principes haptiques pour des retours haptiques clairs. Pour en savoir plus sur ces fonctionnalités et leur fonctionnement, consultez Principes de base des actionneurs de vibration.

Android ne fournit pas de solutions de remplacement pour les compositions avec des primitives non compatibles. Par conséquent, procédez comme suit :

  1. Avant d'activer les retours haptiques avancés, vérifiez qu'un appareil donné est compatible avec toutes les primitives que vous utilisez.

  2. Désactivez l'ensemble cohérent d'expériences non compatibles, et pas seulement les effets pour lesquels il manque une primitive.

Pour savoir comment vérifier la compatibilité de l'appareil, consultez les sections suivantes.

Créer des effets de vibration composés

Vous pouvez créer des effets de vibration composés avec VibrationEffect.Composition. Voici un exemple d'effet de montée lente suivi d'un effet de clic net :

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

Une composition est créée en ajoutant des primitives à lire en séquence. Chaque primitive est également évolutive, ce qui vous permet de contrôler l'amplitude de la vibration générée par chacune d'elles. L'échelle est définie comme une valeur comprise entre 0 et 1, où 0 correspond en fait à une amplitude minimale à laquelle cette primitive peut être (à peine) ressentie par l'utilisateur.

Créer des variantes dans les primitives de vibration

Si vous souhaitez créer une version faible et une version forte du même primitif, créez des ratios d'intensité de 1, 4 ou plus afin que la différence d'intensité soit facilement perceptible. N'essayez pas de créer plus de trois niveaux d'intensité pour la même primitive, car ils ne sont pas perceptuellement distincts. Par exemple, utilisez des facteurs de 0,5, 0,7 et 1,0 pour créer des versions à faible, moyenne et haute intensité d'une primitive.

Ajouter des pauses entre les primitives de vibration

La composition peut également spécifier des délais à ajouter entre les primitives consécutives. Ce délai est exprimé en millisecondes depuis la fin de la primitive précédente. En général, un écart de 5 à 10 ms entre deux primitives est trop court pour être détectable. Utilisez un intervalle de 50 ms ou plus si vous souhaitez créer un intervalle perceptible entre deux primitives. Voici un exemple de composition avec des délais :

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

Vérifier les primitives compatibles

Les API suivantes peuvent être utilisées pour vérifier la compatibilité des appareils avec des primitives spécifiques :

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

Il est également possible de vérifier plusieurs primitives, puis de décider lesquelles composer en fonction du niveau de compatibilité de l'appareil :

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

Exemples de compositions de vibrations

Les sections suivantes fournissent plusieurs exemples de compositions de vibrations, tirés de l'application exemple haptique sur GitHub.

Résistance (avec peu de coches)

Vous pouvez contrôler l'amplitude de la vibration primitive pour transmettre un retour utile à une action en cours. Des valeurs de mise à l'échelle très rapprochées peuvent être utilisées pour créer un effet crescendo fluide d'une primitive. Le délai entre les primitives consécutives peut également être défini de manière dynamique en fonction de l'interaction de l'utilisateur. C'est ce qu'illustre l'exemple suivant d'animation de vue contrôlée par un geste de déplacement et augmentée avec des retours haptiques.

Animation d'un cercle qui est déplacé vers le bas.
Graphique de la forme d'onde de vibration d'entrée.

Figure 1. Cette forme d'onde représente l'accélération de sortie de la vibration sur un appareil.

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

Développer (avec montée et descente)

Il existe deux primitives pour augmenter l'intensité de vibration perçue : PRIMITIVE_QUICK_RISE et PRIMITIVE_SLOW_RISE. Elles atteignent toutes les deux la même cible, mais avec des durées différentes. Il n'existe qu'une seule primitive pour la réduction, PRIMITIVE_QUICK_FALL. Ces primitives fonctionnent mieux ensemble pour créer un segment de forme d'onde dont l'intensité augmente, puis diminue. Vous pouvez aligner les primitives mises à l'échelle pour éviter les sauts d'amplitude soudains entre elles, ce qui fonctionne également bien pour prolonger la durée globale de l'effet. Sur le plan perceptuel, les gens remarquent toujours davantage la partie ascendante que la partie descendante. Vous pouvez donc raccourcir la partie ascendante par rapport à la partie descendante pour mettre l'accent sur la partie descendante.

Voici un exemple d'application de cette composition pour développer et réduire un cercle. L'effet d'augmentation peut renforcer la sensation d'expansion pendant l'animation. La combinaison des effets de montée et de descente permet de mettre en évidence l'effondrement à la fin de l'animation.

Animation d'un cercle qui s'agrandit.
Graphique de la forme d'onde de vibration d'entrée.

Figure 2 : cette forme d'onde représente l'accélération de sortie de la vibration sur un appareil.

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

Ondulation (avec rotations)

L'un des principes clés de l'haptique est de ravir les utilisateurs. Pour introduire un effet de vibration agréable et inattendu, vous pouvez utiliser PRIMITIVE_SPIN. Cette primitive est plus efficace lorsqu'elle est appelée plusieurs fois. La concaténation de plusieurs rotations peut créer un effet de vacillement et d'instabilité, qui peut être renforcé en appliquant une mise à l'échelle quelque peu aléatoire à chaque primitive. Vous pouvez également tester l'écart entre les primitives de rotation successives. Deux tours sans aucun intervalle (0 ms entre les deux) créent une sensation de rotation intense. Si vous augmentez l'intervalle entre les rotations de 10 à 50 ms, la sensation de rotation sera plus lente. Vous pouvez utiliser cette option pour faire correspondre la durée d'une vidéo ou d'une animation.

N'utilisez pas d'intervalle de plus de 100 ms, car les rotations successives ne s'intègrent plus bien et commencent à ressembler à des effets individuels.

Voici un exemple de forme élastique qui rebondit après avoir été déplacée vers le bas, puis relâchée. L'animation est améliorée par une paire d'effets de rotation, joués avec des intensités variables proportionnelles au déplacement du rebond.

Animation d'une forme élastique qui rebondit
Graphique de la forme d'onde de vibration d'entrée

Figure 3. Cette forme d'onde représente l'accélération de sortie de la vibration sur un appareil.

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

Rebond (avec des bruits sourds)

Une autre application avancée des effets de vibration consiste à simuler des interactions physiques. PRIMITIVE_THUD peut créer un effet fort et retentissant, qui peut être associé à la visualisation d'un impact, dans une vidéo ou une animation par exemple, pour améliorer l'expérience globale.

Voici un exemple d'animation de chute de balle améliorée avec un effet de bruit sourd joué chaque fois que la balle rebondit en bas de l'écran :

Animation d&#39;une balle qui rebondit sur le bas de l&#39;écran après être tombée.
Graphique de la forme d&#39;onde de vibration d&#39;entrée.

Figure 4. Cette forme d'onde représente l'accélération de sortie de la vibration sur un appareil.

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

Forme d'onde de vibration avec enveloppes

La procédure de création de schémas de vibration personnalisés vous permet de contrôler l'amplitude des vibrations pour créer des effets de montée et de descente en douceur. Cette section explique comment créer des effets haptiques dynamiques à l'aide d'enveloppes de forme d'onde qui permettent de contrôler précisément l'amplitude et la fréquence des vibrations au fil du temps. Cela vous permet de créer des expériences haptiques plus riches et plus nuancées.

À partir d'Android 16 (niveau d'API 36), le système fournit les API suivantes pour créer une enveloppe de forme d'onde de vibration en définissant une séquence de points de contrôle :

  • BasicEnvelopeBuilder : approche accessible pour créer des effets haptiques indépendants du matériel.
  • WaveformEnvelopeBuilder : approche plus avancée pour créer des effets haptiques. Nécessite de connaître le matériel haptique.

Android ne fournit pas de solutions de remplacement pour les effets d'enveloppe. Si vous avez besoin de cette assistance, procédez comme suit :

  1. Vérifiez si un appareil donné est compatible avec les effets d'enveloppe à l'aide de Vibrator.areEnvelopeEffectsSupported().
  2. Désactivez l'ensemble cohérent d'expériences non compatibles, ou utilisez des schémas de vibration personnalisés ou des compositions comme alternatives de secours.

Pour créer des effets d'enveloppe plus basiques, utilisez BasicEnvelopeBuilder avec les paramètres suivants :

  • Valeur intensity dans la plage \( [0, 1] \), qui représente l'intensité perçue de la vibration. Par exemple, une valeur de \( 0.5 \)est perçue comme la moitié de l'intensité maximale globale pouvant être atteinte par l'appareil.
  • Valeur sharpness comprise dans la plage \( [0, 1] \), qui représente la netteté de la vibration. Les valeurs faibles correspondent à des vibrations plus douces, tandis que les valeurs élevées créent une sensation plus vive.

  • Valeur duration, qui représente le temps, en millisecondes, nécessaire pour passer du dernier point de contrôle (c'est-à-dire une paire intensité/netteté) au nouveau.

Voici un exemple de forme d'onde qui augmente l'intensité d'une vibration de basse à haute fréquence et à intensité maximale sur 500 ms, puis la diminue jusqu'à\( 0 \) (désactivée) sur 100 ms.

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

Si vous avez des connaissances plus avancées sur le haptique, vous pouvez définir des effets d'enveloppe à l'aide de WaveformEnvelopeBuilder. Lorsque vous utilisez cet objet, vous pouvez accéder au mappage de la fréquence à l'accélération de sortie (FOAM) via VibratorFrequencyProfile.

  • Valeur d'amplitude comprise dans la plage \( [0, 1] \), qui représente l'intensité de vibration réalisable à une fréquence donnée, telle que déterminée par le FOAM de l'appareil. Par exemple, une valeur de \( 0.5 \) génère la moitié de l'accélération de sortie maximale pouvant être atteinte à la fréquence donnée.
  • Valeur de fréquence, spécifiée en Hertz.

  • Valeur duration, qui représente le temps, en millisecondes, nécessaire pour passer du dernier point de contrôle au nouveau.

Le code suivant montre un exemple de forme d'onde qui définit un effet de vibration de 400 ms. Elle commence par une rampe d'amplitude de 50 ms, de l'arrêt à la pleine puissance, à une fréquence constante de 60 Hz. Ensuite, la fréquence passe à 120 Hz au cours des 100 ms suivantes et reste à ce niveau pendant 200 ms. Enfin, l'amplitude diminue jusqu'à \( 0 \)et la fréquence revient à 60 Hz au cours des 50 ms suivantes :

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

Les sections suivantes fournissent plusieurs exemples de formes d'onde de vibration avec des enveloppes.

Ressort rebondissant

Un exemple précédent utilise PRIMITIVE_THUD pour simuler des interactions de rebond physique. L'API d'enveloppe de base offre un contrôle beaucoup plus précis, ce qui vous permet d'adapter précisément l'intensité et la netteté des vibrations. Cela permet d'obtenir un retour haptique qui suit plus précisément les événements animés.

Voici un exemple de ressort en chute libre avec une animation améliorée par un effet d'enveloppe de base joué chaque fois que le ressort rebondit en bas de l'écran :

Animation d&#39;un ressort qui rebondit sur le bas de l&#39;écran.
Graphique de la forme d&#39;onde de vibration d&#39;entrée.

Figure 5. Graphique de forme d'onde d'accélération de sortie pour une vibration simulant un ressort rebondissant.

@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")
    }
  }
}

Lancement de fusée

Un exemple précédent montre comment utiliser l'API d'enveloppe de base pour simuler une réaction de ressort rebondissant. Le WaveformEnvelopeBuilder permet de contrôler précisément toute la gamme de fréquences de l'appareil, ce qui permet de créer des effets haptiques très personnalisés. En combinant ces données avec les données FOAM, vous pouvez adapter les vibrations à des capacités de fréquence spécifiques.

Voici un exemple de simulation de lancement de fusée utilisant un schéma de vibration dynamique. L'effet passe de la sortie d'accélération de fréquence minimale prise en charge, 0,1 G, à la fréquence de résonance, en conservant toujours une entrée d'amplitude de 10 %. Cela permet à l'effet de commencer avec une sortie raisonnablement forte et d'augmenter l'intensité et la netteté perçues, même si l'amplitude d'entraînement est la même. Une fois la résonance atteinte, la fréquence de l'effet redescend au minimum, ce qui est perçu comme une diminution de l'intensité et de la netteté. Cela crée une sensation de résistance initiale suivie d'un relâchement, imitant un lancement dans l'espace.

Cet effet n'est pas possible avec l'API d'enveloppe de base, car elle abstrait les informations spécifiques à l'appareil concernant sa fréquence de résonance et sa courbe d'accélération de sortie. Si vous augmentez la netteté, la fréquence équivalente peut dépasser la résonance, ce qui peut entraîner une baisse d'accélération involontaire.

Animation d&#39;une fusée qui décolle depuis le bas de l&#39;écran.
Graphique de la forme d&#39;onde de vibration d&#39;entrée.

Figure 6. Graphique de forme d'onde d'accélération de sortie pour une vibration simulant le lancement d'une fusée.

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