Membuat efek haptic kustom

Halaman ini membahas contoh cara menggunakan berbagai API haptik untuk membuat efek kustom di luar bentuk gelombang getaran standar di 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 haptik kustom, kompatibilitas perangkat dan perilaku penggantian bergantung pada platform API yang Anda pilih:

  • VibrationEffect.Builder (Direkomendasikan): Mulai Android 16 (26Q4), efek yang dibuat menggunakan VibrationEffect.Builder menyertakan penggantian otomatis tingkat framework. Jika perangkat tidak mendukung Preset atau Envelope dasar yang diminta secara native, framework akan otomatis menerjemahkannya ke alternatif yang sesuai pada waktu pemutaran dengan upaya terbaik. Anda tidak perlu memeriksa kemampuan perangkat per primitif secara manual sebelum memutar efek yang disusun dengan VibrationEffect.Builder.
    • Pengecualian: Envelope bentuk gelombang lanjutan yang dibuat dengan WaveformEnvelopeBuilder tidak mendukung penggantian otomatis karena bergantung pada pemetaan frekuensi hardware tertentu (FOAM). Jika tidak didukung, video tidak akan diputar.
  • VibrationEffect.Composition: Komposisi yang dibuat dengan startComposition() API tidak memiliki penggantian otomatis. Jika komposisi berisi satu primitif yang tidak didukung, seluruh getaran akan gagal diputar. Anda harus memeriksa kemampuan secara manual menggunakan vibrator.arePrimitivesSupported().
  • Bentuk gelombang dengan kontrol amplitudo: Amplitudo bukan nol dibulatkan ke atas menjadi 100% pada perangkat yang tidak memiliki kontrol amplitudo. Periksa vibrator.hasAmplitudeControl() dan kembali ke pola AKTIF/NONAKTIF yang dirancang secara eksplisit jika diperlukan.

Penggunaan primitif haptik

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

  • Gunakan penundaan 50 md atau lebih lama untuk jeda yang terlihat antara dua primitif, dengan mempertimbangkan juga durasi primitif jika memungkinkan.
  • Gunakan skala yang berbeda dengan rasio 1,4 atau lebih agar perbedaan intensitas lebih terlihat.
  • 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 perhatian, 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 dengung yang terdengar. Rancang pola bentuk gelombang untuk memperlancar transisi amplitudo guna menciptakan efek peningkatan dan penurunan.

Contoh pola getaran

Bagian berikut memberikan beberapa contoh pola getaran:

Pola peningkatan jangkauan

Bentuk gelombang direpresentasikan sebagai VibrationEffect dengan tiga parameter:

  1. Timings: 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 mengulangi bentuk gelombang, atau -1 jika pola hanya boleh diputar sekali.

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

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

Pola berulang

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

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

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

Pola dengan penggantian

Mengontrol amplitudo getaran adalah kemampuan yang bergantung pada hardware. Memutar bentuk gelombang pada perangkat kelas bawah tanpa kemampuan ini akan 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 dengung saat diputar dalam kondisi tersebut, atau desain pola AKTIF/NONAKTIF yang lebih sederhana yang dapat diputar sebagai pengganti.

if (vibrator.hasAmplitudeControl()) {
    vibrator.vibrate(
        VibrationEffect.createWaveform(
            smoothTimings, amplitudes, smoothRepeatIdx
        )
    )
} else {
    vibrator.vibrate(
        VibrationEffect.createWaveform(
            onOffTimings, onOffRepeatIdx
        )
    )
}

Komposisi yang ditambatkan ke linimasa dengan VibrationEffect.Builder

Mulai Android 16 (26Q4), VibrationEffect.Builder adalah API pilihan untuk membuat efek dan komposisi getaran yang kompleks. Dengan API ini, Anda dapat membuat sensasi haptik ekspresif dengan mengurutkan elemen haptik diskret di sepanjang linimasa absolut dengan startTimeMillis.

VibrationEffect.Builder mendukung penggabungan beberapa jenis getaran:

Getaran yang dibuat dengan VibrationEffect.Builder memiliki penggantian otomatis bawaan di tingkat framework di seluruh elemennya (termasuk preset, envelope dasar, dan efek getaran gabungan), sehingga memastikan pengalaman pengguna yang konsisten di berbagai perangkat tanpa memerlukan pemeriksaan kemampuan manual. (Envelope bentuk gelombang lanjutan yang dibuat dengan WaveformEnvelopeBuilder memerlukan dukungan hardware dan tidak memiliki dukungan penggantian otomatis.)

Tulis dengan preset

Gunakan VibrationEffect.Preset untuk menambahkan denyut haptik singkat yang umum dan telah ditentukan sebelumnya (seperti PRESET_CLICK, PRESET_TICK, atau PRESET_LOW_TICK) ke komposisi. Preset diterapkan oleh produsen perangkat untuk memberikan getaran yang tajam, singkat, dan menyenangkan yang selaras dengan prinsip haptik untuk haptik yang jelas. Untuk mengetahui detail selengkapnya tentang kemampuan ini dan cara kerjanya, lihat Primer aktuator getaran.

Preset menggantikan primitif singkat dari VibrationEffect.Composition API. Untuk sensasi haptik yang lebih lama atau berkelanjutan—seperti meningkatkan dan menurunkan—gunakan bentuk gelombang envelope (PWLE).

Setiap preset dapat diberi skala opsional antara 0.0f dan 1.0f dan ditempatkan pada waktu mulai eksplisit (dalam milidetik) dari awal komposisi.

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

Membuat komposisi dengan envelope dan preset

Anda dapat menggabungkan instance VibrationEffect.Envelope (yang dibuat menggunakan BasicEnvelopeBuilder atau WaveformEnvelopeBuilder) dengan preset untuk membuat pola haptik multisegmen yang beragam.

Berikut adalah contoh yang memutar envelope fade-out dan ramp-up yang halus, diikuti dengan preset klik yang tajam:

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

Menggunakan kembali dan menggeser acara yang ada

Untuk menggunakan kembali atau menggabungkan VibrationEffect yang ada (termasuk VibrationEffect.Composition), ambil daftar objek VibrationEffect.Event menggunakan getEvents() dan tambahkan dengan offset menggunakan addEvents(startTimeShiftMillis, events) (atau teruskan efek langsung ke konstruktor VibrationEffect.Builder(effect)). Saat mengimpor instance VibrationEffect.Composition dengan cara ini, framework akan otomatis mengonversi primitifnya menjadi preset, sehingga memungkinkan dukungan penggantian 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);

Membuat komposisi berulang

Gunakan setRepeatingEffect(startTimeMillis, repeatingEffect, durationMillis) untuk menambahkan pola berulang ke komposisi:

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

Pengelolaan waktu, validasi, dan penyimpangan

Saat mendesain komposisi dengan VibrationEffect.Builder, perhatikan aturan pengaturan waktu dan validasi berikut:

  • Waktu mulai yang meningkat secara ketat: Setiap elemen yang ditambahkan ke builder harus memiliki startTimeMillis yang benar-benar lebih besar dari atau sama dengan waktu mulai elemen sebelumnya.
  • Validasi waktu build: Builder melakukan validasi upaya terbaik pada waktu build() menggunakan durasi elemen yang diketahui (atau minimum 1 md untuk preset). Jika tumpang-tindih yang tidak mungkin terdeteksi, error IllegalArgumentException akan ditampilkan.
  • Pergeseran berurutan pemutaran: Jika elemen getaran sebelumnya masih diputar secara fisik saat waktu mulai elemen berikutnya tiba, framework akan otomatis menggeser elemen berikutnya ke slot waktu paling awal yang tersedia. Hal ini memastikan bahwa peristiwa tidak tumpang-tindih dan tidak ada getaran yang hilang, meskipun dapat menyebabkan sedikit pergeseran waktu jika peristiwa dijadwalkan terlalu berdekatan. Untuk meminimalkan pergeseran, berikan waktu yang cukup (misalnya, 50 md atau lebih) di antara peristiwa haptik yang berurutan.

Membuat komposisi primitif getaran

Bagian ini menjelaskan cara menyusun getaran menggunakan VibrationEffect.Composition. Primitif komposisi, yang dijelaskan sebelumnya di halaman ini, diimplementasikan oleh produsen perangkat. Getaran ini memberikan getaran yang tajam, singkat, dan menyenangkan yang selaras dengan prinsip haptik untuk haptik yang jelas. Untuk mengetahui detail selengkapnya tentang kemampuan ini dan cara kerjanya, lihat Primer aktuator getaran.

Tidak seperti VibrationEffect.Builder, VibrationEffect.Composition API tidak memiliki penggantian otomatis untuk primitif yang tidak didukung. Oleh karena itu:

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

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

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

Saat dibuat dengan VibrationEffect.Builder, framework akan mengonversi primitif komposisi menjadi preset dan otomatis menyediakan penggantian runtime jika ada primitif yang tidak didukung di perangkat pengguna.

Efek getaran gabungan

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

vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
    ).addPrimitive(
        VibrationEffect.Composition.PRIMITIVE_CLICK
    ).compose()
)

Komposisi dibuat dengan menambahkan primitif untuk diputar secara berurutan. Setiap primitif juga dapat diskalakan, sehingga Anda dapat mengontrol amplitudo getaran yang dihasilkan oleh masing-masing primitif. Skala didefinisikan sebagai nilai antara 0 dan 1, dengan 0 sebenarnya dipetakan ke amplitudo minimum yang memungkinkan primitif ini (hampir tidak) 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 primitif yang sama, karena tidak berbeda secara persepsi. Misalnya, gunakan skala 0,5, 0,7, dan 1,0 untuk membuat versi primitif dengan intensitas rendah, sedang, dan tinggi.

Menambahkan jeda di antara primitif getaran

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

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

Memeriksa primitif yang didukung

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

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

Anda juga dapat memeriksa beberapa primitif, lalu memutuskan primitif mana yang akan disusun berdasarkan tingkat dukungan perangkat:

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

Contoh komposisi getaran

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

Resist (dengan tick rendah)

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

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

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

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

Luaskan (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 penurunan, PRIMITIVE_QUICK_FALL. Primitif ini bekerja lebih baik bersama-sama untuk membuat segmen bentuk gelombang yang intensitasnya meningkat, lalu menurun. Anda dapat menyelaraskan primitif yang diskalakan untuk mencegah lompatan amplitudo yang tiba-tiba di antara primitif tersebut, yang juga berfungsi dengan baik untuk memperpanjang durasi efek secara keseluruhan. Secara persepsi, orang selalu lebih memperhatikan bagian yang naik daripada bagian yang turun, sehingga membuat bagian yang naik lebih pendek daripada bagian yang turun dapat digunakan untuk mengalihkan penekanan ke bagian yang turun.

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 keruntuhan di akhir animasi.

Animasi lingkaran yang meluas.
Plot bentuk gelombang getaran input.

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

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

Bergoyang (dengan putaran)

Salah satu prinsip haptik utama adalah memberikan kepuasan kepada 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 goyah dan tidak stabil, yang dapat ditingkatkan lebih lanjut dengan menerapkan penskalaan yang agak acak pada setiap primitif. Anda juga dapat bereksperimen dengan celah antara primitif spin yang berurutan. Dua putaran tanpa jeda (0 md di antaranya) menciptakan sensasi putaran yang kencang. Meningkatkan jeda antar-putaran dari 10 menjadi 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 putaran, yang diputar dengan intensitas bervariasi yang sebanding dengan pergeseran pantulan.

Animasi bentuk elastis yang memantul
Plot bentuk gelombang getaran input

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

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

Pantulan (dengan suara berdebum)

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

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

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

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

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

Bentuk gelombang getaran dengan envelope

Proses untuk membuat pola getaran kustom memungkinkan Anda mengontrol amplitudo getaran untuk menciptakan efek peningkatan dan penurunan yang halus. Bagian ini menjelaskan cara membuat efek haptik dinamis menggunakan envelope bentuk gelombang yang memungkinkan kontrol yang akurat 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 envelope 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 envelope. Jika Anda memerlukan dukungan ini, selesaikan langkah-langkah berikut:

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

Untuk membuat efek envelope yang lebih dasar, gunakan BasicEnvelopeBuilder dengan parameter berikut:

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

  • Nilai durasi, yang mewakili waktu, dalam milidetik, yang diperlukan untuk beralih dari titik kontrol terakhir (yaitu, pasangan intensitas dan ketajaman) ke titik kontrol baru.

Berikut adalah contoh bentuk gelombang yang meningkatkan intensitas dari getaran bernada rendah ke bernada tinggi dengan kekuatan maksimum selama 500 md, lalu menurunkannya 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 lanjut tentang haptik, Anda dapat menentukan efek envelope menggunakan WaveformEnvelopeBuilder. Saat menggunakan objek ini, Anda dapat mengakses pemetaan frekuensi ke akselerasi output (FOAM) melalui VibratorFrequencyProfile.

  • Nilai amplitudo dalam rentang \( [0, 1] \), yang mewakili kekuatan getaran yang dapat dicapai pada frekuensi tertentu, seperti yang ditentukan oleh FOAM perangkat. Misalnya, nilai \( 0.5 \) menghasilkan setengah dari 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. Efek ini dimulai dengan peningkatan amplitudo 50 md, dari nonaktif hingga penuh, pada frekuensi konstan 60 Hz. 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 envelope.

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 akurat. Hal ini menghasilkan respons haptik yang lebih akurat mengikuti peristiwa animasi.

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

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

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

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

Peluncuran roket

Contoh sebelumnya menunjukkan cara menggunakan API envelope dasar untuk menyimulasikan reaksi pegas yang memantul. WaveformEnvelopeBuilder memberikan kontrol yang akurat atas rentang frekuensi penuh perangkat, sehingga memungkinkan efek haptik yang sangat 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 beralih dari output akselerasi frekuensi minimum yang didukung, 0,1 G, ke frekuensi resonansi, yang 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 akan turun kembali ke minimum, yang dianggap sebagai penurunan intensitas dan ketajaman. Hal ini menciptakan sensasi hambatan awal yang diikuti dengan pelepasan, yang meniru peluncuran ke luar angkasa.

Efek ini tidak dapat dilakukan dengan API envelope dasar, karena efek ini mengabstraksi informasi spesifik per perangkat tentang frekuensi resonansi dan kurva akselerasi outputnya. Meningkatkan ketajaman dapat mendorong frekuensi yang setara melampaui resonansi, sehingga 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 menyimulasikan peluncuran roket.

@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

Seperti dalam contoh Peluncuran Roket, API WaveformEnvelopeBuilder memungkinkan desain banyak efek haptik yang kompleks dengan mengaktifkan kontrol segmen amplitudo dan frekuensi dalam getaran. Contoh lain dari desain semacam ini adalah emulasi sensasi fisik yang lebih abstrak, seperti "keaktifan".

Hal ini dapat dilakukan dengan merepresentasikan biomarker sinyal elektrokardiogram (EKG) standar dengan segmen getaran amplitudo dan frekuensi tertentu. LavaBeats adalah contoh di mana dua segmen karakteristik dari rekaman ECG direpresentasikan sebagai dua pulsa yang dipisahkan oleh penundaan waktu. Pulsa karakteristik pertama adalah kompleks QRS, yang ditampilkan sebagai puncak tajam dengan amplitudo tinggi dan durasi singkat. Pulsa kedua adalah gelombang T, yang memiliki amplitudo lebih rendah, durasi lebih lama, dan bentuk yang lebih halus (lihat Gambar 7).

Gunakan WaveformEnvelopeBuilder untuk membuat berbagai pengulangan dari dua pulsa ini yang dipisahkan oleh penundaan pulsa pertama ke kedua yang tetap. Pulsa pertama dapat berupa sinyal chirp yang dimulai pada frekuensi rendah dan berakhir pada frekuensi yang lebih tinggi dalam durasi singkat. Pulsa kedua dapat direpresentasikan sebagai satu periode sinusoid frekuensi rendah. Kita dapat menyusun dua denyut menjadi satu detak, dan mengulangi komposisi tersebut beberapa kali dengan jeda di antaranya, mengikuti kecepatan detak per menit (bpm) yang umum. Hasilnya adalah efek haptik yang menyerupai detak jantung.

Anda dapat mencoba LavaBeats di aplikasi contoh haptik kami di GitHub dan merasakan efeknya disertai dengan visualisasi lampu lava yang berdetak dengan ritme yang sama dengan efek haptik. Anda juga dapat mengubah setelan efek untuk menciptakan sensasi denyut yang berbeda dengan mengubah amplitudo, frekuensi, durasi, dan penundaan kedua denyut.

Animasi lampu lava yang berdenyut dengan pola detak jantung haptik.
Plot segmen EKG dan bentuk gelombang karakteristiknya

Gambar 7. Segmen rekaman EKG dengan kompleks QRS dan gelombang 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,
)