Membuat efek haptic kustom

Halaman ini mencakup contoh cara menggunakan berbagai API haptik untuk membuat efek kustom di luar bentuk gelombang getaran standar dalam aplikasi Android.

Halaman ini mencakup contoh berikut:

Untuk contoh tambahan, lihat Menambahkan respons haptic ke peristiwa, dan selalu ikuti prinsip desain haptic.

Menggunakan penggantian untuk menangani kompatibilitas perangkat

Saat menerapkan efek kustom, pertimbangkan hal berikut:

  • Kemampuan perangkat mana yang diperlukan untuk efek
  • Yang harus dilakukan saat perangkat tidak dapat memutar efek

Referensi API haptik Android memberikan detail tentang cara memeriksa dukungan untuk komponen yang terlibat dalam haptik Anda, sehingga aplikasi Anda dapat memberikan pengalaman keseluruhan yang konsisten.

Bergantung pada kasus penggunaan, Anda mungkin ingin menonaktifkan efek kustom atau menyediakan efek kustom alternatif berdasarkan potensi kemampuan yang berbeda.

Rencanakan kelas kemampuan perangkat tingkat tinggi berikut:

  • Jika Anda menggunakan primitif haptik: perangkat yang mendukung primitif tersebut diperlukan oleh efek kustom. (Lihat bagian berikutnya untuk mengetahui detail tentang primitif.)

  • Perangkat dengan kontrol amplitudo.

  • Perangkat dengan dukungan getaran dasar (aktif/nonaktif)—dengan kata lain, perangkat yang tidak memiliki kontrol amplitudo.

Jika pilihan efek haptik aplikasi Anda memperhitungkan kategori ini, maka pengalaman pengguna haptik harus tetap dapat diprediksi untuk setiap perangkat.

Penggunaan primitif haptik

Android menyertakan beberapa primitif haptik yang bervariasi dalam amplitudo dan frekuensi. Anda dapat menggunakan satu primitif saja atau beberapa primitif secara bersamaan untuk mencapai efek haptik yang kaya.

  • Gunakan penundaan 50 md atau lebih lama untuk celah yang terlihat di antara dua elemen dasar, dengan juga mempertimbangkan durasi elemen dasar jika memungkinkan.
  • Gunakan skala yang berbeda dengan rasio 1,4 atau lebih sehingga perbedaan intensitas lebih mudah dirasakan.
  • Gunakan skala 0,5, 0,7, dan 1,0 untuk membuat versi primitif dengan intensitas rendah, sedang, dan tinggi.

Membuat pola getaran kustom

Pola getaran sering digunakan dalam haptik atensi, seperti notifikasi dan nada dering. Layanan Vibrator dapat memutar pola getaran panjang yang mengubah amplitudo getaran dari waktu ke waktu. Efek tersebut disebut bentuk gelombang.

Efek bentuk gelombang biasanya dapat dirasakan, tetapi getaran panjang yang tiba-tiba dapat mengejutkan pengguna jika diputar di lingkungan yang tenang. Meningkatkan amplitudo target terlalu cepat juga dapat menghasilkan suara dengungan yang terdengar. Desain pola bentuk gelombang untuk memperlancar transisi amplitudo guna menciptakan efek naik dan turun.

Contoh pola getaran

Bagian berikut memberikan beberapa contoh pola getaran:

Pola peningkatan

Bentuk gelombang ditampilkan sebagai VibrationEffect dengan tiga parameter:

  1. Pengaturan waktu: array durasi, dalam milidetik, untuk setiap segmen bentuk gelombang.
  2. Amplitudo: amplitudo getaran yang diinginkan untuk setiap durasi yang ditentukan dalam argumen pertama, yang diwakili oleh nilai bilangan bulat dari 0 hingga 255, dengan 0 mewakili "status nonaktif" vibrator dan 255 adalah amplitudo maksimum perangkat.
  3. Indeks pengulangan: indeks dalam array yang ditentukan dalam argumen pertama untuk mulai mengulang bentuk gelombang, atau -1 jika pola hanya boleh diputar satu kali.

Berikut adalah contoh bentuk gelombang yang berdenyut dua kali dengan jeda 350 md di antara denyutan. Pulsa pertama adalah peningkatan yang lancar hingga amplitudo maksimum, dan pulsa kedua adalah peningkatan cepat untuk mempertahankan amplitudo maksimum. Berhenti di akhir ditentukan oleh nilai indeks pengulangan negatif.

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

Pola berulang

Bentuk gelombang juga dapat diputar berulang kali hingga dibatalkan. Cara membuat bentuk gelombang berulang adalah dengan menyetel parameter repeat non-negatif. Saat Anda memutar bentuk gelombang berulang, getaran akan berlanjut hingga dibatalkan secara eksplisit di layanan:

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

Hal ini sangat berguna untuk peristiwa sesekali yang memerlukan tindakan pengguna untuk mengonfirmasinya. Contoh peristiwa tersebut mencakup panggilan telepon masuk dan alarm yang berbunyi.

Pola dengan penggantian

Mengontrol amplitudo getaran adalah kemampuan yang bergantung pada hardware. Memutar bentuk gelombang pada perangkat kelas bawah tanpa kemampuan ini menyebabkan perangkat bergetar pada amplitudo maksimum untuk setiap entri positif dalam array amplitudo. Jika aplikasi Anda perlu mengakomodasi perangkat tersebut, gunakan pola yang tidak menghasilkan efek dengungan saat diputar dalam kondisi tersebut, atau desain pola AKTIF/NONAKTIF yang lebih sederhana yang dapat diputar sebagai pengganti.

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

Membuat komposisi getaran

Bagian ini menyajikan cara menyusun getaran menjadi efek kustom yang lebih panjang dan lebih kompleks, serta melangkah lebih jauh untuk mempelajari haptik yang kaya menggunakan kemampuan hardware yang lebih canggih. Anda dapat menggunakan kombinasi efek yang memvariasikan amplitudo dan frekuensi untuk membuat efek haptik yang lebih kompleks pada perangkat dengan aktuator haptik yang memiliki bandwidth frekuensi yang lebih luas.

Proses membuat pola getaran kustom, yang dijelaskan sebelumnya di halaman ini, menjelaskan cara mengontrol amplitudo getaran untuk menciptakan efek peningkatan dan penurunan yang lancar. Haptik kaya meningkatkan konsep ini dengan mengeksplorasi rentang frekuensi yang lebih luas dari vibrator perangkat untuk membuat efeknya lebih lancar. Bentuk gelombang ini sangat efektif dalam menciptakan efek crescendo atau diminuendo.

Primitif komposisi, yang dijelaskan sebelumnya di halaman ini, diimplementasikan oleh produsen perangkat. Notifikasi ini memberikan getaran yang jelas, singkat, dan menyenangkan yang selaras dengan prinsip haptik untuk haptik yang jelas. Untuk mengetahui detail selengkapnya tentang kemampuan ini dan cara kerjanya, lihat Aktuator getaran primer.

Android tidak menyediakan penggantian untuk komposisi dengan elemen dasar yang tidak didukung. Oleh karena itu, lakukan langkah-langkah berikut:

  1. Sebelum mengaktifkan haptik lanjutan, periksa apakah perangkat tertentu mendukung semua primitif yang Anda gunakan.

  2. Nonaktifkan kumpulan pengalaman konsisten yang tidak didukung, bukan hanya efek yang tidak memiliki elemen dasar.

Informasi selengkapnya tentang cara memeriksa dukungan perangkat ditampilkan di bagian berikut.

Membuat efek getaran gabungan

Anda dapat membuat efek getaran gabungan dengan VibrationEffect.Composition. Berikut adalah contoh efek naik perlahan yang diikuti dengan efek klik tajam:

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

Komposisi dibuat dengan menambahkan elemen primitif yang akan diputar secara berurutan. Setiap primitif juga dapat diskalakan, sehingga Anda dapat mengontrol amplitudo getaran yang dihasilkan oleh masing-masing primitif. Skala ditentukan sebagai nilai antara 0 dan 1, dengan 0 sebenarnya dipetakan ke amplitudo minimum yang dapat (hampir) dirasakan oleh pengguna.

Membuat varian dalam primitif getaran

Jika Anda ingin membuat versi lemah dan kuat dari primitif yang sama, buat rasio kekuatan 1,4 atau lebih, sehingga perbedaan intensitas dapat dengan mudah dirasakan. Jangan mencoba membuat lebih dari tiga tingkat intensitas dari primitif yang sama, karena tidak dapat dibedakan secara perseptual. Misalnya, gunakan skala 0,5, 0,7, dan 1,0 untuk membuat versi intensitas rendah, sedang, dan tinggi dari primitif.

Menambahkan jeda di antara primitif getaran

Komposisi juga dapat menentukan penundaan yang akan ditambahkan di antara primitif berurutan. Penundaan ini dinyatakan dalam milidetik sejak akhir primitif sebelumnya. Secara umum, selisih 5 hingga 10 md antara dua elemen terlalu singkat untuk dapat dideteksi. Gunakan jeda sekitar 50 md atau lebih lama jika Anda ingin membuat jeda yang terlihat jelas antara dua elemen. Berikut adalah contoh komposisi dengan penundaan:

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

Memeriksa primitif mana yang didukung

API berikut dapat digunakan untuk memverifikasi dukungan perangkat untuk primitif tertentu:

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

Anda juga dapat memeriksa beberapa elemen dasar, lalu memutuskan elemen mana yang akan dikomposisikan berdasarkan tingkat dukungan perangkat:

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

Contoh komposisi getaran

Bagian berikut memberikan beberapa contoh komposisi getaran, yang diambil dari aplikasi contoh haptik di GitHub.

Menolak (dengan tanda centang rendah)

Anda dapat mengontrol amplitudo getaran primitif untuk menyampaikan masukan yang berguna terhadap tindakan yang sedang berlangsung. Nilai skala yang berdekatan dapat digunakan untuk membuat efek crescendo halus dari primitif. Penundaan antara primitif yang berurutan juga dapat ditetapkan secara dinamis berdasarkan interaksi pengguna. Hal ini diilustrasikan dalam contoh animasi tampilan berikut yang dikontrol oleh gestur penarikan dan dilengkapi dengan haptik.

Animasi lingkaran yang ditarik ke bawah.
Plot bentuk gelombang getaran input.

Gambar 1. Bentuk gelombang ini merepresentasikan akselerasi output getaran pada perangkat.

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

Perluas (dengan naik dan turun)

Ada dua primitif untuk meningkatkan intensitas getaran yang dirasakan: PRIMITIVE_QUICK_RISE dan PRIMITIVE_SLOW_RISE. Keduanya mencapai target yang sama, tetapi dengan durasi yang berbeda. Hanya ada satu primitif untuk mengurangi kecepatan, PRIMITIVE_QUICK_FALL. Primitif ini bekerja lebih baik bersama-sama untuk membuat segmen bentuk gelombang yang meningkat intensitasnya lalu menghilang. Anda dapat menyelaraskan primitif yang diskalakan untuk mencegah lonjakan amplitudo yang tiba-tiba di antara keduanya, yang juga berfungsi dengan baik untuk memperpanjang durasi efek secara keseluruhan. Secara persepsi, orang selalu lebih memperhatikan bagian yang meningkat daripada bagian yang menurun, sehingga membuat bagian yang meningkat lebih pendek daripada bagian yang menurun dapat digunakan untuk mengalihkan penekanan ke bagian yang menurun.

Berikut adalah contoh penerapan komposisi ini untuk meluaskan dan menciutkan lingkaran. Efek naik dapat meningkatkan kesan perluasan selama animasi. Kombinasi efek naik dan turun membantu menekankan penyusutan di akhir animasi.

Animasi lingkaran yang meluas.
Plot bentuk gelombang getaran input.

Gambar 2.Bentuk gelombang ini merepresentasikan akselerasi output getaran pada perangkat.

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

Goyangan (dengan putaran)

Salah satu prinsip haptik utama adalah menyenangkan pengguna. Cara yang menyenangkan untuk memperkenalkan efek getaran tak terduga yang menyenangkan adalah dengan menggunakan PRIMITIVE_SPIN. Primitif ini paling efektif jika dipanggil lebih dari sekali. Beberapa putaran yang digabungkan dapat menciptakan efek yang bergetar dan tidak stabil, yang dapat ditingkatkan lebih lanjut dengan menerapkan penskalaan yang agak acak pada setiap elemen. Anda juga dapat bereksperimen dengan jarak antara primitif putaran berturut-turut. Dua putaran tanpa jeda (0 md di antaranya) akan menciptakan sensasi putaran yang kuat. Meningkatkan jeda antar-putaran dari 10 hingga 50 md akan menghasilkan sensasi putaran yang lebih longgar, dan dapat digunakan untuk mencocokkan durasi video atau animasi.

Jangan gunakan jeda yang lebih lama dari 100 md, karena putaran berturut-turut tidak lagi terintegrasi dengan baik dan mulai terasa seperti efek individual.

Berikut adalah contoh bentuk elastis yang memantul kembali setelah ditarik ke bawah lalu dilepaskan. Animasi ini ditingkatkan dengan sepasang efek putar, yang diputar dengan intensitas yang bervariasi dan proporsional dengan perpindahan pantulan.

Animasi bentuk elastis yang memantul
Plot bentuk gelombang getaran input

Gambar 3. Bentuk gelombang ini merepresentasikan akselerasi output getaran pada perangkat.

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

Memantul (dengan bunyi gedebuk)

Penerapan lanjutan lain dari efek getaran adalah untuk menyimulasikan interaksi fisik. PRIMITIVE_THUD dapat menciptakan efek yang kuat dan beresonansi, yang dapat dipadukan dengan visualisasi dampak, misalnya dalam video atau animasi, untuk meningkatkan keseluruhan pengalaman.

Berikut adalah contoh animasi bola jatuh yang ditingkatkan dengan efek bunyi gedebuk yang diputar setiap kali bola memantul dari bagian bawah layar:

Animasi bola yang jatuh memantul dari bagian bawah layar.
Plot bentuk gelombang getaran input.

Gambar 4. Bentuk gelombang ini merepresentasikan akselerasi output getaran pada perangkat.

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

Bentuk gelombang getaran dengan amplop

Proses membuat pola getaran kustom memungkinkan Anda mengontrol amplitudo getaran untuk membuat efek peningkatan dan penurunan yang lancar. Bagian ini menjelaskan cara membuat efek haptik dinamis menggunakan amplop bentuk gelombang yang memungkinkan kontrol yang presisi terhadap amplitudo dan frekuensi getaran dari waktu ke waktu. Dengan begitu, Anda dapat membuat pengalaman haptik yang lebih kaya dan bernuansa.

Mulai Android 16 (level API 36), sistem menyediakan API berikut untuk membuat amplop bentuk gelombang getaran dengan menentukan urutan titik kontrol:

  • BasicEnvelopeBuilder: Pendekatan yang mudah diakses untuk membuat efek haptik yang tidak bergantung pada hardware.
  • WaveformEnvelopeBuilder: Pendekatan yang lebih canggih untuk membuat efek haptik; memerlukan pemahaman tentang hardware haptik.

Android tidak menyediakan penggantian untuk efek amplop. Jika Anda memerlukan dukungan ini, selesaikan langkah-langkah berikut:

  1. Periksa apakah perangkat tertentu mendukung efek amplop menggunakan Vibrator.areEnvelopeEffectsSupported().
  2. Nonaktifkan rangkaian pengalaman yang konsisten dan tidak didukung, atau gunakan pola getaran kustom atau komposisi sebagai alternatif pengganti.

Untuk membuat efek amplop dasar lainnya, gunakan BasicEnvelopeBuilder dengan parameter berikut:

  • Nilai intensitas dalam rentang \( [0, 1] \), yang merepresentasikan kekuatan getaran yang dirasakan. Misalnya, nilai \( 0.5 \) dianggap sebagai setengah intensitas maksimum global yang dapat dicapai oleh perangkat.
  • Nilai ketajaman dalam rentang \( [0, 1] \), yang merepresentasikan kejelasan getaran. Nilai yang lebih rendah menghasilkan getaran yang lebih halus, sedangkan nilai yang lebih tinggi menghasilkan sensasi yang lebih tajam.

  • Nilai durasi, yang merepresentasikan waktu, dalam milidetik, yang diperlukan untuk bertransisi dari titik kontrol terakhir—yaitu, pasangan intensitas dan ketajaman—ke titik kontrol baru.

Berikut adalah contoh bentuk gelombang yang meningkatkan intensitas dari nada rendah ke nada tinggi, getaran berkekuatan maksimum selama 500 md, lalu menurun kembali ke\( 0 \) (nonaktif) selama 100 md.

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

Jika memiliki pengetahuan yang lebih mendalam tentang haptik, Anda dapat menentukan efek amplop menggunakan WaveformEnvelopeBuilder. Saat menggunakan objek ini, Anda dapat mengakses pemetaan akselerasi frekuensi ke output (FOAM) melalui VibratorFrequencyProfile.

  • Nilai amplitudo dalam rentang \( [0, 1] \), yang merepresentasikan kekuatan getaran yang dapat dicapai pada frekuensi tertentu, sebagaimana ditentukan oleh FOAM perangkat. Misalnya, nilai \( 0.5 \) menghasilkan setengah akselerasi output maksimum yang dapat dicapai pada frekuensi tertentu.
  • Nilai frekuensi, yang ditentukan dalam Hertz.

  • Nilai durasi, yang mewakili waktu, dalam milidetik, yang diperlukan untuk beralih dari titik kontrol terakhir ke titik kontrol baru.

Kode berikut menunjukkan contoh bentuk gelombang yang menentukan efek getaran 400 md. Proses ini dimulai dengan ramp amplitudo 50 md, dari nonaktif ke penuh, pada 60 Hz yang konstan. Kemudian, frekuensi meningkat hingga 120 Hz selama 100 md berikutnya dan tetap pada level tersebut selama 200 md. Terakhir, amplitudo menurun hingga \( 0 \), dan frekuensi kembali ke 60 Hz selama 50 md terakhir:

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

Bagian berikut memberikan beberapa contoh bentuk gelombang getaran dengan amplop.

Pegas memantul

Contoh sebelumnya menggunakan PRIMITIVE_THUD untuk menyimulasikan interaksi pantulan fisik. Basic envelope API menawarkan kontrol yang jauh lebih baik, sehingga Anda dapat menyesuaikan intensitas dan ketajaman getaran secara presisi. Hal ini menghasilkan umpan balik haptik yang lebih akurat mengikuti peristiwa animasi.

Berikut contoh pegas yang jatuh bebas dengan animasi yang ditingkatkan dengan efek amplop dasar yang diputar setiap kali pegas memantul dari bagian bawah layar:

Animasi pegas yang jatuh memantul dari bagian bawah layar.
Plot bentuk gelombang getaran input.

Gambar 5. Grafik bentuk gelombang akselerasi output untuk getaran yang mensimulasikan pegas yang memantul.

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

  val (screenHeight, screenWidth) = getScreenDimensions(context)

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

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

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

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

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

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

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

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

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

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

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

      delay(FRAME_DELAY_MS) // Control animation speed.

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

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

Peluncuran roket

Contoh sebelumnya menunjukkan cara menggunakan API amplop dasar untuk mensimulasikan reaksi pegas yang memantul. WaveformEnvelopeBuilder membuka kontrol yang presisi atas rentang frekuensi penuh perangkat, sehingga memungkinkan efek haptik yang sangat dapat disesuaikan. Dengan menggabungkannya dengan data FOAM, Anda dapat menyesuaikan getaran dengan kemampuan frekuensi tertentu.

Berikut adalah contoh yang menunjukkan simulasi peluncuran roket menggunakan pola getaran dinamis. Efeknya bervariasi dari output akselerasi frekuensi minimum yang didukung, 0,1 G, hingga frekuensi resonansi, dengan selalu mempertahankan input amplitudo 10%. Hal ini memungkinkan efek dimulai dengan output yang cukup kuat dan meningkatkan intensitas dan ketajaman yang dirasakan, meskipun amplitudo penggeraknya sama. Setelah mencapai resonansi, frekuensi efek turun kembali ke minimum, yang dirasakan sebagai penurunan intensitas dan ketajaman. Tindakan ini menciptakan sensasi hambatan awal yang diikuti dengan pelepasan, meniru peluncuran ke luar angkasa.

Efek ini tidak mungkin dilakukan dengan API envelope dasar, karena mengabstraksi informasi khusus perangkat tentang frekuensi resonansi dan kurva akselerasi outputnya. Meningkatkan ketajaman dapat mendorong frekuensi yang setara melampaui resonansi, yang berpotensi menyebabkan penurunan akselerasi yang tidak diinginkan.

Animasi roket yang lepas landas dari bagian bawah layar.
Plot bentuk gelombang getaran input.

Gambar 6. Grafik bentuk gelombang akselerasi output untuk getaran yang mensimulasikan peluncuran roket.

@Composable
fun RocketLaunchAnimation() {
  val context = LocalContext.current
  val screenHeight = remember { mutableFloatStateOf(0f) }
  var rocketPositionY by remember { mutableFloatStateOf(0f) }
  var isLaunched by remember { mutableStateOf(false) }
  val animation = remember { Animatable(0f) }

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

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        if (!isLaunched) {
          // Play vibration with same duration as the animation, using 70% of
          // the time for the rise of the vibration, to match the easing curve
          // defined previously.
          playVibration(vibrator, animationDuration, 0.7f)
          isLaunched = true
        }
      }
      .background(Color(context.getColor(R.color.background)))
      .onSizeChanged { screenHeight.floatValue = it.height.toFloat() }
  ) {
    drawRocket(rocketPositionY)
  }
}

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

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

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

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

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

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

  vibrator.vibrate(
    VibrationEffect.WaveformEnvelopeBuilder()
      // Quickly reach the desired output at the start frequency
      .addControlPoint(0.1f, startFrequency, minDurationMs)
      .addControlPoint(0.1f, resonantFrequency, rampUpDurationMs)
      .addControlPoint(0.1f, startFrequency, rampDownDurationMs)

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