Crea effetti aptici personalizzati

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

Questa pagina include i seguenti esempi:

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

Utilizza i fallback per gestire la compatibilità dei dispositivi

Quando implementi effetti tattili personalizzati, la compatibilità del dispositivo e il comportamento di fallback dipendono dalla piattaforma 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 richiesto o un Envelope di base, il framework lo traduce automaticamente in un'alternativa appropriata al momento della riproduzione con il criterio del "best effort". Non è necessario controllare manualmente le funzionalità del dispositivo per ogni primitiva prima di riprodurre gli effetti composti con VibrationEffect.Builder.
    • Eccezione: gli envelope di forme d'onda avanzate creati 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 una sola primitiva non supportata, 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 per eccesso al 100% sui dispositivi privi di controllo dell'ampiezza. Controlla vibrator.hasAmplitudeControl() e, se necessario, utilizza un pattern ON/OFF progettato in modo esplicito.

Utilizzo di primitive aptiche

Android include diverse primitive di feedback aptico che variano sia in ampiezza che in frequenza. Puoi utilizzare una sola primitiva o più primitive in combinazione per ottenere effetti tattili avanzati.

  • Utilizza ritardi di almeno 50 ms per 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 scale di 0,5, 0,7 e 1,0 per creare una versione a bassa, media e alta intensità di una primitiva.

Crea sequenze di vibrazione personalizzate

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

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

Esempi di sequenze di vibrazione

Le sezioni seguenti forniscono diversi esempi di schemi di vibrazione:

Pattern di applicazione graduale

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

  1. Timings: un array di durate, in millisecondi, per ogni segmento della forma d'onda.
  2. Amplitudes: 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 disattivazione" 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 gli impulsi. Il primo impulso è un aumento graduale fino all'ampiezza massima, mentre il secondo è un aumento rapido per mantenere l'ampiezza massima. L'interruzione alla fine è definita dal valore negativo dell'indice di ripetizione.

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

Pattern ripetuto

Le forme d'onda possono anche essere riprodotte ripetutamente fino a quando non vengono annullate. Per creare una forma d'onda ripetuta, imposta un parametro repeat non negativo. Quando riproduci una forma d'onda ripetuta, la vibrazione continua finché non viene annullata esplicitamente nel servizio:

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

    vibrator.vibrate(repeatingEffect)
}

fun stopVibrating() {
    vibrator.cancel()
}

Questa funzionalità è molto utile per gli eventi intermittenti che richiedono l'azione dell'utente per essere confermati. Esempi di questi eventi sono le chiamate in arrivo e le sveglie attivate.

Pattern con riserva

Il controllo dell'ampiezza di una vibrazione è una funzionalità che dipende dall'hardware. La riproduzione di una forma d'onda su un dispositivo di fascia bassa senza questa funzionalità fa vibrare il dispositivo con l'ampiezza massima 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 ronzio quando viene riprodotto in questa condizione o progetta un pattern ON/OFF più semplice che possa essere riprodotto come alternativa.

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 la creazione di composizioni ed effetti di vibrazione complessi. Ti consente di creare sensazioni aptiche espressive sequenziando elementi aptici discreti lungo una cronologia 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 per tutti i loro elementi (tra cui preset, inviluppi di base ed effetti di vibrazione combinati), garantendo un'esperienza utente coerente su diversi dispositivi senza richiedere controlli manuali delle funzionalità. (Gli envelope di forme d'onda avanzati creati con WaveformEnvelopeBuilder richiedono il supporto hardware e non hanno il supporto di fallback automatico.)

Componi con i preset

Utilizza VibrationEffect.Preset per aggiungere a una composizione brevi impulsi aptici comuni e predefiniti (come PRESET_CLICK, PRESET_TICK o PRESET_LOW_TICK). I preset vengono implementati dal produttore del dispositivo per fornire una vibrazione nitida, breve e piacevole che si allinea ai principi relativi alla tecnologia aptica per un'esperienza aptica chiara. Per maggiori dettagli su queste funzionalità e sul loro funzionamento, consulta la guida introduttiva agli attuatori di vibrazione.

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

A ogni preset può essere assegnata una scala facoltativa compresa tra 0.0f e 1.0f, che viene posizionata in 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);

Componi con inviluppi e preset

Puoi combinare facilmente le istanze VibrationEffect.Envelope (create utilizzando BasicEnvelopeBuilder o WaveformEnvelopeBuilder) con i preset per creare pattern tattili ricchi e multisegmento.

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

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

Riutilizza e sposta 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 le istanze VibrationEffect.Composition in questo modo, il framework converte automaticamente le relative primitive in preset, abilitando il supporto del fallback 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);

Crea 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 deviazione

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

  • Orari di inizio in ordine rigorosamente crescente:ogni elemento aggiunto al builder deve avere un startTimeMillis rigorosamente maggiore o uguale all'orario di inizio dell'elemento precedente.
  • Convalida in fase di creazione:il builder esegue la convalida best-effort al momento della creazione di build() utilizzando le durate degli elementi note (o un minimo di 1 ms per i preset). Se viene rilevata una sovrapposizione impossibile, viene generato un errore IllegalArgumentException.
  • Spostamento sequenziale della riproduzione:se un elemento di vibrazione precedente è ancora in fase di riproduzione fisica quando arriva l'ora di inizio dell'elemento successivo, il framework sposta automaticamente l'elemento successivo nella prima fascia oraria disponibile. In questo modo, gli eventi non si sovrappongono e non vengono perse vibrazioni, anche se potrebbe verificarsi una leggera deviazione temporale se gli eventi sono programmati troppo vicini tra loro. Per ridurre al minimo la deviazione, lascia un tempo sufficiente (ad es. 50 ms o più) tra eventi aptici consecutivi.

Crea composizioni di primitive di vibrazione

Questa sezione illustra come comporre le vibrazioni utilizzando VibrationEffect.Composition. Le primitive di composizione, descritte in precedenza in questa pagina, sono implementate dal produttore del dispositivo. Forniscono una vibrazione nitida, breve e piacevole che si allinea ai principi relativi alla tecnologia aptica per un'esperienza aptica chiara. Per maggiori dettagli su queste funzionalità e sul loro funzionamento, consulta la guida introduttiva agli attuatori di vibrazione.

A differenza di VibrationEffect.Builder, l'API VibrationEffect.Composition non dispone di fallback automatici per le primitive non supportate. Quindi:

  1. Prima di attivare l'aptica avanzata, verifica che un determinato dispositivo supporti tutte le primitive che stai utilizzando.

  2. Disattiva l'insieme coerente di esperienze non supportate, non solo gli effetti privi di 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()))

Quando viene creato con VibrationEffect.Builder, il framework converte le primitive 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 un effetto di aumento lento seguito da un effetto di clic netto:

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 in realtà a un'ampiezza minima alla quale questa primitiva può essere (appena) percepita dall'utente.

Crea varianti nelle primitive di vibrazione

Se vuoi creare una versione debole e una forte della stessa primitiva, crea rapporti di intensità pari o superiori a 1,4, in modo che la differenza di intensità possa essere percepita facilmente. Non provare a 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.

Aggiungi intervalli tra le primitive di vibrazione

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

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

Controlla quali primitive sono supportate

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

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

È anche possibile controllare più primitive e poi decidere quali comporre in base al livello di supporto del dispositivo:

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

Esempi di composizioni di vibrazioni

Le sezioni seguenti forniscono diversi esempi di composizioni di vibrazione, tratti dall'app di esempio di feedback aptico su GitHub.

Resistenza (con tick bassi)

Puoi controllare l'ampiezza della vibrazione primitiva per trasmettere 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 di una visualizzazione controllata da un gesto di trascinamento e migliorata con l'aptica.

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

Figura 1. Questa forma d'onda rappresenta l'accelerazione di output della vibrazione su un dispositivo.

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

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

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

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

Espandi (con aumento e diminuzione)

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, PRIMITIVE_QUICK_FALL. Queste primitive funzionano meglio insieme per creare un segmento di forma d'onda che aumenta di intensità e poi si attenua. Puoi allineare le primitive scalate per evitare sbalzi improvvisi di ampiezza tra di loro, il che funziona bene anche per estendere la durata complessiva dell'effetto. Dal punto di vista percettivo, le persone notano sempre di più la parte ascendente rispetto a quella discendente, quindi rendere la parte ascendente più breve di quella discendente può essere utilizzato per spostare l'enfasi verso la parte discendente.

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 di effetti di ascesa e discesa aiuta 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 di output della vibrazione su un dispositivo.

enum class ExpandShapeState {
    Collapsed,
    Expanded
}

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

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

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

Wobble (con rotazioni)

Uno dei principi chiave della tecnologia aptica è soddisfare gli utenti. Un modo divertente per introdurre un piacevole effetto di vibrazione inaspettato è utilizzare PRIMITIVE_SPIN. Questa primitiva è più efficace quando viene chiamata più di una volta. Più rotazioni concatenate possono creare un effetto oscillante e instabile, che può essere ulteriormente migliorato applicando una scalabilità un po' casuale su ogni primitiva. Puoi anche sperimentare con l'intervallo tra le primitive di rotazione successive. Due rotazioni senza intervallo (0 ms tra l'una 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, poiché le rotazioni successive non si integrano più bene e iniziano a sembrare effetti singoli.

Ecco un esempio di forma elastica che rimbalza dopo essere stata trascinata verso il basso e poi rilasciata. L'animazione è migliorata con un paio 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 della vibrazione di input

Figura 3. Questa forma d'onda rappresenta l'accelerazione di output della vibrazione su un dispositivo.

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

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

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

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

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

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 sonoro di un tonfo riprodotto ogni volta che la palla rimbalza sulla parte inferiore dello schermo:

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

Figura 4. Questa forma d'onda rappresenta l'accelerazione di output della vibrazione su un dispositivo.

enum class BallPosition {
    Start,
    End
}

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

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

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

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

Forma d'onda della vibrazione con envelope

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 tattili dinamici utilizzando gli inviluppi delle forme d'onda che consentono un controllo preciso dell'ampiezza e della frequenza della vibrazione nel tempo. In questo modo puoi creare esperienze tattili 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 della vibrazione definendo una sequenza di punti di controllo:

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

Android non fornisce opzioni di riserva per gli effetti di inviluppo. Se hai bisogno di questa assistenza, completa i seguenti passaggi:

  1. Verifica che un determinato dispositivo supporti gli effetti di inviluppo utilizzando Vibrator.areEnvelopeEffectsSupported().
  2. Disattiva l'insieme 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ù netta.

  • Un valore duration, che rappresenta il tempo, in millisecondi, impiegato per passare 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 una vibrazione a bassa frequenza a una vibrazione ad alta frequenza e massima intensità in 500 ms, per poi tornare a\( 0 \) (off) in 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 della tecnologia aptica, puoi definire gli effetti di inviluppo utilizzando WaveformEnvelopeBuilder. Quando utilizzi questo oggetto, puoi accedere alla mappatura della frequenza all'accelerazione di output (FOAM) tramite VibratorFrequencyProfile.

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

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

Il seguente codice mostra una forma d'onda di esempio 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 sale a 120 Hz nei successivi 100 ms e rimane a quel livello per 200 ms. Infine, l'ampiezza scende 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 di base per l'inviluppo offre un controllo notevolmente più preciso, consentendoti di personalizzare con precisione l'intensità e la nitidezza della vibrazione. In questo modo, il feedback aptico segue più accuratamente gli eventi animati.

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

Animazione di una molla che cade e rimbalza dalla 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() {
    // The spring's height above the floor: 1f is the drop height, 0f is the floor.
    val springHeight = remember { Animatable(1f) }
    var isAnimating by remember { mutableStateOf(true) }

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

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

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

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

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

Lancio di un razzo

Un esempio precedente mostra come utilizzare l'API di inviluppo di base per simulare una reazione di molla rimbalzante. WaveformEnvelopeBuilder consente un controllo preciso sull'intera gamma di frequenze del dispositivo, permettendo di creare effetti aptici altamente personalizzati. Combinando questi dati con quelli di FOAM, puoi personalizzare le vibrazioni in base a funzionalità 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'output di accelerazione della frequenza minima supportata, 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 torna al minimo, il che viene percepito come un'intensità e una nitidezza decrescenti. Questo crea una sensazione di resistenza iniziale seguita da un rilascio, imitando un lancio nello spazio.

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

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 uscita per una vibrazione che simula il lancio di un razzo.

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

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

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

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

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

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

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

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

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

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

LavaBeats

Come nell'esempio Lancio del razzo, l'API WaveformEnvelopeBuilder consente di progettare molti effetti tattili 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 di 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. Il primo impulso caratteristico è il complesso QRS, che si presenta come un picco netto di ampiezza elevata e breve durata. Il secondo impulso è l'onda T, che ha un'ampiezza inferiore, una durata maggiore e una forma più uniforme (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 chirp che inizia a una frequenza bassa e termina a una frequenza più alta per 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 tattile che ricorda un battito cardiaco.

Puoi provare LavaBeats nella nostra app di esempio di tecnologia aptica su GitHub e sentire l'effetto accompagnato da una visualizzazione di una lampada lava che pulsa 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 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

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

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

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

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