Crea effetti aptici personalizzati

Questa pagina illustra esempi di come utilizzare diverse API aptiche per creare effetti personalizzati oltre alle forme d'onda di vibrazione standard in un'app Android.

Questa pagina include i seguenti esempi:

Per altri esempi, vedi Aggiungere il feedback aptico agli eventi e segui sempre i principi di progettazione aptica.

Utilizzare i fallback per gestire la compatibilità dei dispositivi

Quando implementi effetti aptici personalizzati, la compatibilità del dispositivo e il comportamento di fallback dipendono dalla superficie API che scegli:

  • VibrationEffect.Builder (consigliato): a partire da Android 16 (26Q4), gli effetti creati utilizzando VibrationEffect.Builder includono il fallback automatico a livello di framework. Se un dispositivo non supporta in modo nativo un Preset o un Envelope di base richiesto, il framework lo traduce automaticamente in un'alternativa appropriata al momento della riproduzione con il massimo impegno. Non è necessario controllare manualmente le funzionalità di ogni dispositivo prima di riprodurre gli effetti composti con VibrationEffect.Builder.
    • Eccezione:le buste di forma d'onda avanzate create con WaveformEnvelopeBuilder non supportano il fallback automatico perché dipendono da mappature di frequenza hardware specifiche (FOAM). Se non sono supportati, non verranno riprodotti.
  • VibrationEffect.Composition: le composizioni create con l'API startComposition() non hanno fallback automatici. Se una composizione contiene anche un solo elemento primitivo non supportato, l'intera vibrazione non viene riprodotta. Devi controllare manualmente le funzionalità utilizzando vibrator.arePrimitivesSupported().
  • Forme d'onda con controllo dell'ampiezza:le ampiezze diverse da zero vengono arrotondate al 100% sui dispositivi che non dispongono del controllo dell'ampiezza. Controlla vibrator.hasAmplitudeControl() e torna a un pattern ON/OFF progettato in modo esplicito, se necessario.

Utilizzo di primitive aptiche

Android include diversi primitive feedback aptici che variano in ampiezza e frequenza. Puoi utilizzare una sola primitiva o più primitive in combinazione per ottenere effetti aptici avanzati.

  • Utilizza ritardi di 50 ms o più per creare intervalli distinguibili tra due primitive, tenendo conto anche della durata della primitiva, se possibile.
  • Utilizza scale che differiscono di un rapporto di 1,4 o più in modo che la differenza di intensità sia percepita meglio.
  • Utilizza le scale 0,5, 0,7 e 1,0 per creare una versione a bassa, media e alta intensità di una primitiva.

Creare sequenze di vibrazione personalizzate

I pattern di vibrazione vengono spesso utilizzati nell'aptica attentiva, ad esempio per notifiche e suonerie. Il servizio Vibrator può riprodurre pattern di vibrazione lunghi che cambiano l'ampiezza della vibrazione nel tempo. Questi effetti sono chiamati forme d'onda.

Gli effetti di forma d'onda sono generalmente percepibili, ma vibrazioni lunghe e improvvise possono spaventare l'utente se riprodotte in un ambiente silenzioso. L'aumento troppo rapido dell'ampiezza potrebbe anche produrre ronzii udibili. Progetta pattern di forme d'onda per uniformare le transizioni di ampiezza per creare effetti di aumento e diminuzione.

Esempi di pattern di vibrazione

Le sezioni seguenti forniscono diversi esempi di pattern di vibrazione:

Pattern di applicazione graduale

Le forme d'onda sono rappresentate come VibrationEffect con tre parametri:

  1. Timing:un array di durate, in millisecondi, per ogni segmento della forma d'onda.
  2. Ampiezze:l'ampiezza di vibrazione desiderata per ogni durata specificata nel primo argomento, rappresentata da un valore intero compreso tra 0 e 255, dove 0 rappresenta lo "stato di spegnimento" del vibratore e 255 l'ampiezza massima del dispositivo.
  3. Indice di ripetizione:l'indice nell'array specificato nel primo argomento per iniziare a ripetere la forma d'onda o -1 se il pattern deve essere riprodotto una sola volta.

Ecco un esempio di forma d'onda che pulsa due volte con una pausa di 350 ms tra le pulsazioni. Il primo impulso è un aumento graduale fino all'ampiezza massima, mentre il secondo è un aumento rapido per mantenere l'ampiezza massima. L'arresto alla fine è definito dal valore negativo dell'indice di ripetizione.

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

Pattern ripetitivo

Le forme d'onda possono anche essere riprodotte ripetutamente fino all'annullamento. Il modo per creare una forma d'onda ripetuta è impostare un parametro repeat non negativo. Quando riproduci un'onda ripetitiva, la vibrazione continua finché non viene annullata esplicitamente nel servizio:

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

Ciò è molto utile per gli eventi intermittenti che richiedono l'intervento dell'utente per confermarli. Esempi di questi eventi includono chiamate in arrivo e allarmi attivati.

Pattern con fallback

Il controllo dell'ampiezza di una vibrazione è una funzionalità dipendente dall'hardware. La riproduzione di una forma d'onda su un dispositivo di fascia bassa senza questa funzionalità fa vibrare il dispositivo alla massima ampiezza per ogni voce positiva nell'array di ampiezza. Se la tua app deve supportare questi dispositivi, utilizza un pattern che non generi un effetto di vibrazione quando viene riprodotto in queste condizioni oppure progetta un pattern ON/OFF più semplice che possa essere riprodotto come fallback.

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

Composizioni ancorate alla sequenza temporale con VibrationEffect.Builder

A partire da Android 16 (26Q4), VibrationEffect.Builder è l'API preferita per creare effetti e composizioni di vibrazione complessi. Consente di creare sensazioni aptiche espressive sequenziando elementi aptici discreti lungo una sequenza temporale assoluta con startTimeMillis.

VibrationEffect.Builder supporta la combinazione di più tipi di vibrazione:

Le vibrazioni create con VibrationEffect.Builder hanno un fallback automatico integrato a livello di framework in tutti i loro elementi (inclusi preset, inviluppi di base ed effetti di vibrazione combinati), garantendo un'esperienza utente coerente su diversi dispositivi senza richiedere controlli manuali delle funzionalità. (Le buste di forma d'onda avanzate create con WaveformEnvelopeBuilder richiedono il supporto hardware e non hanno il supporto di fallback automatico.)

Comporre con i preset

Utilizza VibrationEffect.Preset per aggiungere impulsi aptici brevi, comuni e predefiniti (come PRESET_CLICK, PRESET_TICK o PRESET_LOW_TICK) a una composizione. I preset vengono implementati dal produttore del dispositivo per fornire una vibrazione nitida, breve e piacevole in linea con i principi aptici per un'aptica chiara. Per ulteriori dettagli su queste funzionalità e sul loro funzionamento, consulta Introduzione agli attuatori di vibrazione.

I preset sostituiscono le primitive brevi dell'API VibrationEffect.Composition. Per sensazioni aptiche più lunghe o continue, come l'aumento e la diminuzione graduali, utilizza invece le forme d'onda envelope (PWLE).

A ogni preset può essere assegnata una scala facoltativa compresa tra 0.0f e 1.0f e viene posizionato a un'ora di inizio esplicita (in millisecondi) dall'inizio della composizione.

Kotlin

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

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

vibrator.vibrate(effect)

Java

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

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

vibrator.vibrate(effect);

Comporre con inviluppi e preset

Puoi combinare senza problemi le istanze VibrationEffect.Envelope (create utilizzando BasicEnvelopeBuilder o WaveformEnvelopeBuilder) con preset per creare pattern aptici ricchi e multi-segmento.

Ecco un esempio che riproduce un inviluppo di aumento e dissolvenza graduali seguito da un preset di clic nitido:

Kotlin

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

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

vibrator.vibrate(mixedEffect)

Java

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

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

vibrator.vibrate(mixedEffect);

Riutilizzare e spostare gli eventi esistenti

Per riutilizzare o concatenare un VibrationEffect esistente (incluso VibrationEffect.Composition), recupera il relativo elenco di oggetti VibrationEffect.Event utilizzando getEvents() e aggiungili con un offset utilizzando addEvents(startTimeShiftMillis, events) (o passa l'effetto direttamente al costruttore VibrationEffect.Builder(effect)). Quando importi VibrationEffect.Composition istanze in questo modo, il framework converte automaticamente le relative primitive in preset, consentendo il supporto di fallback in fase di runtime.

Kotlin

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

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

vibrator.vibrate(combinedEffect)

Java

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

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

vibrator.vibrate(combinedEffect);

Creare composizioni ripetute

Utilizza setRepeatingEffect(startTimeMillis, repeatingEffect, durationMillis) per aggiungere un pattern ripetuto a una composizione:

Kotlin

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

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

vibrator.vibrate(repeatingEffect)

Java

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

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

vibrator.vibrate(repeatingEffect);

Tempistica, convalida e gestione della deriva

Quando progetti composizioni con VibrationEffect.Builder, tieni presente le seguenti regole di temporizzazione e convalida:

  • Orari di inizio strettamente crescenti:ogni elemento aggiunto allo strumento di creazione deve avere un startTimeMillis strettamente maggiore o uguale all'orario di inizio dell'elemento precedente.
  • Convalida in fase di compilazione:il builder esegue la convalida al meglio in fase di build() utilizzando durate degli elementi note (o un minimo di 1 ms per i preset). Se viene rilevata una sovrapposizione impossibile, viene generato un IllegalArgumentException.
  • Spostamento sequenziale della riproduzione:se un elemento di vibrazione precedente è ancora in riproduzione fisica quando arriva l'ora di inizio dell'elemento successivo, il framework sposta automaticamente l'elemento successivo nel primo intervallo di tempo disponibile. In questo modo, gli eventi non si sovrappongono e non vengono eliminate vibrazioni, anche se potrebbe verificarsi una leggera deriva della sincronizzazione se gli eventi sono programmati troppo vicini tra loro. Per ridurre al minimo la deriva, lascia un tempo sufficiente (ad es. 50 ms o più) tra eventi aptici consecutivi.

Crea composizioni di primitive di vibrazione

Questa sezione mostra come comporre vibrazioni utilizzando VibrationEffect.Composition. Le primitive di composizione, descritte in precedenza in questa pagina, vengono implementate dal produttore del dispositivo. Forniscono una vibrazione nitida, breve e piacevole in linea con i principi aptici per un feedback aptico chiaro. Per maggiori dettagli su queste funzionalità e sul loro funzionamento, consulta Introduzione agli attuatori di vibrazione.

A differenza di VibrationEffect.Builder, l'API VibrationEffect.Composition non dispone di fallback automatici per i primitivi non supportati. Quindi:

  1. Prima di attivare il feedback aptico avanzato, verifica che un determinato dispositivo supporti tutti i primitivi che stai utilizzando.

  2. Disattiva il set coerente di esperienze non supportate, non solo gli effetti a cui manca una primitiva.

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

Se costruito con VibrationEffect.Builder, il framework converte i primitivi di composizione in preset e fornisce automaticamente un fallback di runtime se una primitiva non è supportata sul dispositivo dell'utente.

Effetti di vibrazione composti

Puoi creare effetti di vibrazione composti con VibrationEffect.Composition. Ecco un esempio di effetto in lento aumento seguito da un effetto di clic netto:

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 composizione viene creata aggiungendo primitive da riprodurre in sequenza. Ogni primitiva è anche scalabile, quindi puoi controllare l'ampiezza della vibrazione generata da ciascuna. La scala è definita come un valore compreso tra 0 e 1, dove 0 corrisponde a un'ampiezza minima alla quale questa primitiva può essere percepita (appena) dall'utente.

Creare varianti nelle primitive di vibrazione

Se vuoi creare una versione debole e una forte dello stesso esercizio primitivo, crea rapporti di forza pari o superiori a 1,4, in modo che la differenza di intensità possa essere facilmente percepita. Non tentare di creare più di tre livelli di intensità della stessa primitiva, perché non sono percettivamente distinti. Ad esempio, utilizza scale di 0,5, 0,7 e 1,0 per creare versioni a bassa, media e alta intensità di una primitiva.

Aggiungere spazi tra le primitive di vibrazione

La composizione può anche specificare ritardi da aggiungere tra primitive consecutive. Questo ritardo è espresso in millisecondi dalla fine della primitiva precedente. In generale, un intervallo di 5-10 ms tra due primitive è troppo breve per essere rilevabile. Utilizza un intervallo di 50 ms o più se vuoi creare un intervallo distinguibile tra due primitive. Ecco un esempio di composizione con ritardi:

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

Controlla quali primitive sono supportate

Le seguenti API possono essere utilizzate per verificare il supporto del dispositivo per primitive specifiche:

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

È anche possibile controllare più primitive e poi decidere quali comporre in base al livello di supporto 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);

Esempi di composizioni di vibrazione

Le sezioni seguenti forniscono diversi esempi di composizioni di vibrazioni, tratti dall'app di esempio Haptics su GitHub.

Resistenza (con pochi segni)

Puoi controllare l'ampiezza della vibrazione primitiva per fornire un feedback utile a un'azione in corso. I valori di scala ravvicinati possono essere utilizzati per creare un effetto di crescendo uniforme di una primitiva. Il ritardo tra primitive consecutive può anche essere impostato dinamicamente in base all'interazione dell'utente. Questo è illustrato nel seguente esempio di animazione della visualizzazione controllata da un gesto di trascinamento e arricchita con feedback aptico.

Animazione di un cerchio che viene trascinato verso il basso.
Grafico della forma d'onda della vibrazione di input.

Figura 1. Questa forma d'onda rappresenta l'accelerazione in uscita della vibrazione su 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);
    }
}

Espandi (con salita e discesa)

Esistono due primitive per aumentare l'intensità della vibrazione percepita: PRIMITIVE_QUICK_RISE e PRIMITIVE_SLOW_RISE. Entrambi raggiungono lo stesso target, ma con durate diverse. Esiste una sola primitiva per la riduzione graduale, PRIMITIVE_QUICK_FALL. Queste primitive funzionano meglio insieme per creare un segmento di forma d'onda che cresce di intensità e poi si attenua. Puoi allineare le primitive scalate per evitare salti improvvisi di ampiezza tra loro, il che funziona bene anche per estendere la durata complessiva dell'effetto. A livello percettivo, le persone notano sempre di più la parte in aumento rispetto a quella in diminuzione, quindi rendere la parte in aumento più breve di quella in diminuzione può essere utilizzato per spostare l'enfasi sulla parte in diminuzione.

Ecco un esempio di applicazione di questa composizione per espandere e comprimere un cerchio. L'effetto di sollevamento può migliorare la sensazione di espansione durante l'animazione. La combinazione degli effetti di salita e discesa contribuisce a enfatizzare il collasso alla fine dell'animazione.

Animazione di un cerchio che si espande.
Grafico della forma d'onda della vibrazione di input.

Figura 2. Questa forma d'onda rappresenta l'accelerazione in uscita della vibrazione su 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;
    }
}

Oscillazione (con rotazioni)

Uno dei principi fondamentali dell'aptica è quello di deliziare gli utenti. Un modo divertente per introdurre un piacevole effetto di vibrazione inatteso è utilizzare PRIMITIVE_SPIN. Questa primitiva è più efficace quando viene chiamata più di una volta. La concatenazione di più rotazioni può creare un effetto di oscillazione e instabilità, che può essere ulteriormente migliorato applicando una scalatura un po' casuale a ogni primitiva. Puoi anche sperimentare con lo spazio tra le primitive di rotazione successive. Due rotazioni senza intervallo (0 ms tra una rotazione e l'altra) creano una sensazione di rotazione intensa. Aumentando l'intervallo tra le rotazioni da 10 a 50 ms si ottiene una sensazione di rotazione più lenta, che può essere utilizzata per adattarsi alla durata di un video o di un'animazione.

Non utilizzare un intervallo superiore a 100 ms, in quanto le rotazioni successive non si integrano più bene e iniziano a sembrare effetti individuali.

Ecco un esempio di forma elastica che rimbalza dopo essere stata trascinata verso il basso e poi rilasciata. L'animazione è migliorata con una coppia di effetti di rotazione, riprodotti con intensità variabili proporzionali allo spostamento del rimbalzo.

Animazione di una forma elastica che rimbalza
Grafico della forma d'onda di vibrazione di input

Figura 3. Questa forma d'onda rappresenta l'accelerazione in uscita della vibrazione su 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)
    }
}

Rimbalzo (con tonfi)

Un'altra applicazione avanzata degli effetti di vibrazione è la simulazione di interazioni fisiche. PRIMITIVE_THUD può creare un effetto forte e riverberante, che può essere abbinato alla visualizzazione di un impatto, ad esempio in un video o in un'animazione, per migliorare l'esperienza complessiva.

Ecco un esempio di animazione di una palla che cade migliorata con un effetto sordo riprodotto ogni volta che la palla rimbalza sul fondo dello schermo:

Animazione di una palla che cade e rimbalza sulla parte inferiore dello schermo.
Grafico della forma d'onda della vibrazione di input.

Figura 4. Questa forma d'onda rappresenta l'accelerazione in uscita della vibrazione su 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 d'onda di vibrazione con inviluppi

La procedura per creare sequenze di vibrazione personalizzate ti consente di controllare l'ampiezza della vibrazione per creare effetti fluidi di aumento e diminuzione. Questa sezione spiega come creare effetti aptici dinamici utilizzando inviluppi di forma d'onda che consentono un controllo preciso dell'ampiezza e della frequenza della vibrazione nel tempo. In questo modo puoi creare esperienze aptiche più ricche e sfumate.

A partire da Android 16 (livello API 36), il sistema fornisce le seguenti API per creare un inviluppo della forma d'onda di vibrazione definendo una sequenza di punti di controllo:

  • BasicEnvelopeBuilder: un approccio accessibile per la creazione di effetti aptici indipendenti dall'hardware.
  • WaveformEnvelopeBuilder: un approccio più avanzato alla creazione di effetti aptici; richiede familiarità con l'hardware aptico.

Android non fornisce fallback per gli effetti di inviluppo. Se hai bisogno di questo supporto, completa i seguenti passaggi:

  1. Controlla se un determinato dispositivo supporta gli effetti busta utilizzando Vibrator.areEnvelopeEffectsSupported().
  2. Disattiva il set coerente di esperienze non supportate o utilizza pattern di vibrazione personalizzati o composizioni come alternative di riserva.

Per creare effetti di inviluppo più semplici, utilizza BasicEnvelopeBuilder con questi parametri:

  • Un valore di intensità nell'intervallo \( [0, 1] \), che rappresenta la forza percepita della vibrazione. Ad esempio, un valore pari a \( 0.5 \) viene percepito come la metà dell'intensità massima globale che può essere raggiunta dal dispositivo.
  • Un valore di nitidezza nell'intervallo \( [0, 1] \), che rappresenta la nitidezza della vibrazione. Valori più bassi si traducono in vibrazioni più fluide, mentre valori più alti creano una sensazione più nitida.

  • Un valore di durata, che rappresenta il tempo, in millisecondi, impiegato per la transizione dall'ultimo punto di controllo, ovvero una coppia di intensità e nitidezza, a quello nuovo.

Ecco un esempio di forma d'onda che aumenta l'intensità da un tono basso a un tono alto, con vibrazione alla massima intensità per 500 ms, per poi diminuire di nuovo fino a\( 0 \) (off) per 100 ms.

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

Se hai una conoscenza più avanzata dell'aptica, puoi definire gli effetti di inviluppo utilizzando WaveformEnvelopeBuilder. Quando utilizzi questo oggetto, puoi accedere alla mappatura dell'accelerazione frequenza-output (FOAM) tramite VibratorFrequencyProfile.

  • Un valore di ampiezza nell'intervallo \( [0, 1] \), che rappresenta la forza di vibrazione raggiungibile a una determinata frequenza, come determinato dalla schiuma del dispositivo. Ad esempio, un valore di \( 0.5 \) genera la metà dell'accelerazione dell'output massima che può essere raggiunta alla frequenza specificata.
  • Un valore di frequenza, specificato in Hertz.

  • Un valore di durata, che rappresenta il tempo, in millisecondi, impiegato per la transizione dall'ultimo punto di controllo a quello nuovo.

Il seguente codice mostra un esempio di forma d'onda che definisce un effetto di vibrazione di 400 ms. Inizia con una rampa di ampiezza di 50 ms, da spento a pieno, a una frequenza costante di 60 Hz. Poi, la frequenza aumenta fino a 120 Hz nei successivi 100 ms e rimane a questo livello per 200 ms. Infine, l'ampiezza diminuisce fino a \( 0 \)e la frequenza torna a 60 Hz negli ultimi 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()
)

Le sezioni seguenti forniscono diversi esempi di forme d'onda di vibrazione con inviluppi.

Molla che rimbalza

Un esempio precedente utilizza PRIMITIVE_THUD per simulare interazioni di rimbalzo fisico. L'API Basic Envelope offre un controllo molto più preciso, consentendoti di personalizzare con esattezza l'intensità e la nitidezza della vibrazione. In questo modo, il feedback aptico segue in modo più accurato gli eventi animati.

Ecco un esempio di molla in caduta libera con l'animazione migliorata con un effetto di inviluppo di base riprodotto ogni volta che la molla rimbalza sul fondo dello schermo:

Animazione di una molla caduta che rimbalza sulla parte inferiore dello schermo.
Grafico della forma d'onda della vibrazione di input.

Figura 5. Un grafico della forma d'onda dell'accelerazione di output per una vibrazione che simula una molla che rimbalza.

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

Lancio di un razzo

Un esempio precedente mostra come utilizzare l'API envelope di base per simulare la reazione di una molla elastica. Il WaveformEnvelopeBuilder sblocca il controllo preciso dell'intera gamma di frequenze del dispositivo, consentendo effetti aptici altamente personalizzati. Combinando questi dati con quelli di FOAM, puoi personalizzare le vibrazioni in base a capacità di frequenza specifiche.

Ecco un esempio che mostra una simulazione di lancio di un razzo utilizzando un pattern di vibrazione dinamico. L'effetto va dall'accelerazione minima supportata della frequenza di uscita, 0,1 G, alla frequenza di risonanza, mantenendo sempre un input di ampiezza del 10%. In questo modo, l'effetto inizia con un output ragionevolmente forte e aumenta l'intensità e la nitidezza percepite, anche se l'ampiezza di guida è la stessa. Una volta raggiunta la risonanza, la frequenza dell'effetto scende di nuovo al minimo, che viene percepito come intensità e nitidezza decrescenti. In questo modo si crea una sensazione di resistenza iniziale seguita da un rilascio, che simula un lancio nello spazio.

Questo effetto non è possibile con l'API envelope di base, in quanto astrae le informazioni specifiche del dispositivo sulla sua frequenza di risonanza e sulla curva di accelerazione di uscita. L'aumento della nitidezza può spingere la frequenza equivalente oltre la risonanza, causando potenzialmente un calo di accelerazione involontario.

Animazione di un'astronave che decolla dalla parte inferiore dello schermo.
Grafico della forma d'onda della vibrazione di input.

Figura 6. Un grafico della forma d'onda dell'accelerazione di output per una vibrazione che simula il lancio di un razzo.

@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 target output at the start frequency
      .addControlPoint(0.1f, startFrequency, minDurationMs)
      .addControlPoint(0.1f, resonantFrequency, rampUpDurationMs)
      .addControlPoint(0.1f, startFrequency, rampDownDurationMs)

      // Controlled ramp down to zero to avoid ringing after the vibration.
      .addControlPoint(0.0f, startFrequency, minDurationMs)
      .build()
  )
}

LavaBeats

Come nell'esempio di Lancio di un razzo, l'API WaveformEnvelopeBuilder consente di progettare molti effetti aptici complessi consentendo il controllo dei segmenti di ampiezza e frequenza in una vibrazione. Un altro esempio di questo tipo di design è l'emulazione di sensazioni fisiche più astratte, come la "vivacità".

Ciò può essere ottenuto rappresentando i biomarcatori di un tipico segnale elettrocardiogramma (ECG) con segmenti di vibrazione di determinate ampiezze e frequenze. LavaBeats è un esempio in cui due segmenti caratteristici di una registrazione ECG sono rappresentati come due impulsi separati da un ritardo temporale. Il primo impulso caratteristico è il complesso QRS, che si presenta come un picco acuto di ampiezza elevata e breve durata. Il secondo impulso è l'onda T, che ha un'ampiezza inferiore, una durata maggiore e una forma più regolare (vedi Figura 7).

Utilizza WaveformEnvelopeBuilder per creare varie ripetizioni di questi due impulsi separati da un ritardo fisso tra il primo e il secondo impulso. Il primo impulso può essere un segnale acustico che inizia a una frequenza bassa e termina a una frequenza più alta in un breve periodo di tempo. Il secondo impulso può essere rappresentato come un singolo periodo di una sinusoide a bassa frequenza. Possiamo comporre i due impulsi in un battito e ripetere la composizione più volte con un ritardo tra un battito e l'altro, seguendo una frequenza tipica di battiti al minuto (bpm). Il risultato è un effetto aptico che assomiglia a un battito cardiaco.

Puoi provare LavaBeats nella nostra app di esempio di feedback aptico su GitHub e sentire l'effetto accompagnato da una visualizzazione di una lampada di lava che batte allo stesso ritmo dell'effetto aptico. Puoi anche modificare le impostazioni dell'effetto per creare sensazioni di battito diverse modificando le ampiezze, le frequenze, le durate e i ritardi dei due impulsi.

Animazione di una lampada di lava che pulsa con un pattern di battito cardiaco aptico.
Grafico di un segmento ECG e delle sue forme d'onda caratteristiche

Figura 7. Un segmento di una registrazione ECG con il complesso QRS e l'onda T

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

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

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

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