Özel dokunsal efektler oluştur

Bu sayfada, Android uygulamasında standart titreşim dalga biçimlerinin ötesinde özel efektler oluşturmak için farklı dokunma teknolojisi API'lerinin nasıl kullanılacağına dair örnekler verilmektedir.

Bu sayfada aşağıdaki örnekler yer almaktadır:

Ek örnekler için Etkinliklere dokunsal geribildirim ekleme başlıklı makaleyi inceleyin ve her zaman dokunma teknolojisi tasarım ilkelerine uyun.

Cihaz uyumluluğunu yönetmek için yedekleri kullanma

Özel efektleri uygularken aşağıdakileri göz önünde bulundurun:

  • Efekt için hangi cihaz özellikleri gerekir?
  • Cihaz efekti oynatamadığında ne yapmanız gerekir?

Android dokunma teknolojisi API referansı, uygulamanızın tutarlı bir genel deneyim sunabilmesi için dokunma teknolojinizde yer alan bileşenlerin desteklenip desteklenmediğini kontrol etme hakkında ayrıntılı bilgi sağlar.

Kullanım alanınıza bağlı olarak özel efektleri devre dışı bırakmak veya farklı potansiyel özelliklere göre alternatif özel efektler sağlamak isteyebilirsiniz.

Aşağıdaki üst düzey cihaz özelliği sınıflarını planlayın:

  • Haptik ilkel öğeler kullanıyorsanız: Özel efektler için gereken bu ilkel öğeleri destekleyen cihazlar. (Temel öğelerle ilgili ayrıntılar için sonraki bölüme bakın.)

  • Genlik kontrolü olan cihazlar.

  • Temel titreşim desteği (açma/kapama) olan cihazlar, yani genlik kontrolü olmayanlar.

Uygulamanızın dokunsal efekt seçimi bu kategorileri hesaba katıyorsa dokunsal kullanıcı deneyimi, her bir cihaz için tahmin edilebilir olmaya devam etmelidir.

Dokunsal temel öğelerin kullanımı

Android'de hem genlik hem de frekans açısından farklılık gösteren çeşitli dokunma teknolojisi temel öğeleri bulunur. Zengin dokunsal efektler elde etmek için tek bir temel öğe kullanabilir veya birden fazla temel öğeyi birleştirebilirsiniz.

  • İki öğe arasında fark edilebilir boşluklar için 50 ms veya daha uzun gecikmeler kullanın. Mümkünse öğe süresini de göz önünde bulundurun.
  • Yoğunluk farkının daha iyi algılanması için 1,4 veya daha fazla oranda farklılık gösteren ölçekler kullanın.
  • Bir öğenin düşük, orta ve yüksek yoğunluklu versiyonunu oluşturmak için 0,5, 0,7 ve 1,0 ölçeklerini kullanın.

Özel titreşim modelleri oluşturma

Titreşim kalıpları genellikle bildirimler ve zil sesleri gibi dikkat çekici dokunma teknolojilerinde kullanılır. Vibrator hizmeti, titreşim genliğini zaman içinde değiştiren uzun titreşim desenleri oynatabilir. Bu tür efektlere dalga biçimi denir.

Dalga formu efektleri genellikle fark edilebilir ancak sessiz bir ortamda oynatıldığında ani ve uzun titreşimler kullanıcıyı korkutabilir. Hedef genliğe çok hızlı bir şekilde ulaşmak da duyulabilir vızıltı seslerine neden olabilir. Yükselme ve alçalma efektleri oluşturmak için genlik geçişlerini yumuşatacak dalga biçimi desenleri tasarlayın.

Titreşim deseni örnekleri

Aşağıdaki bölümlerde çeşitli titreşim kalıpları örnekleri verilmiştir:

Artış şekli

Dalga biçimleri, üç parametreyle VibrationEffect olarak gösterilir:

  1. Zamanlamalar: Her dalga biçimi segmenti için milisaniye cinsinden süre dizisi.
  2. Genlikler: İlk bağımsız değişkende belirtilen her süre için istenen titreşim genliği. 0 ile 255 arasında bir tam sayı değeriyle gösterilir. 0, titreşim motorunun "kapalı durumunu", 255 ise cihazın maksimum genliğini ifade eder.
  3. Tekrar dizini: Dalga formunun tekrar etmeye başlayacağı, ilk bağımsız değişkende belirtilen dizideki dizin veya kalıbın yalnızca bir kez oynatılması gerekiyorsa -1.

Aşağıda, iki kez titreşen ve titreşimler arasında 350 ms'lik bir duraklama olan örnek bir dalga biçimi verilmiştir. Birinci darbe, maksimum genliğe kadar düzgün bir artış, ikinci darbe ise maksimum genliği korumak için hızlı bir artıştır. Sonunda durma, negatif tekrar dizini değeriyle tanımlanır.

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

Yinelenen desen

Dalga biçimleri, iptal edilene kadar tekrar tekrar da oynatılabilir. Tekrarlayan bir dalga formu oluşturmanın yolu, negatif olmayan bir repeat parametresi ayarlamaktır. Tekrarlayan bir dalga formu oynattığınızda titreşim, hizmette açıkça iptal edilene kadar devam eder:

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

Bu özellik, kullanıcının onaylamasını gerektiren aralıklı etkinlikler için çok yararlıdır. Gelen telefon aramaları ve tetiklenen alarmlar bu tür etkinliklere örnek olarak verilebilir.

Yedekli desen

Titreşimin genliğini kontrol etmek donanıma bağlı bir özelliktir. Bu özellik olmadan düşük seviyeli bir cihazda dalga formu oynatmak, cihazın genlik dizisindeki her pozitif giriş için maksimum genlikte titreşmesine neden olur. Uygulamanızın bu tür cihazları desteklemesi gerekiyorsa bu durumda çalındığında vızıltı efekti oluşturmayan bir desen kullanın veya bunun yerine yedek olarak çalınabilecek daha basit bir AÇMA/KAPAMA deseni tasarlayın.

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

Titreşim kompozisyonları oluşturma

Bu bölümde, titreşimleri daha uzun ve karmaşık özel efektler oluşturacak şekilde birleştirmenin yolları açıklanmakta ve daha gelişmiş donanım özelliklerini kullanarak zengin dokunma teknolojisi deneyimleri keşfedilmektedir. Genliği ve frekansı değişen efekt kombinasyonlarını kullanarak, daha geniş bir frekans bant genişliğine sahip dokunsal aktüatörler içeren cihazlarda daha karmaşık dokunsal efektler oluşturabilirsiniz.

Bu sayfada daha önce açıklanan özel titreşim modelleri oluşturma süreci, titreşim genliğini kontrol ederek kademeli olarak artan ve azalan titreşim efektleri oluşturmayı açıklar. Zengin dokunma teknolojisi, cihaz titreşim motorunun daha geniş frekans aralığını keşfederek bu kavramı geliştirir ve efekti daha da yumuşak hale getirir. Bu dalga biçimleri, özellikle crescendo veya diminuendo efekti oluşturmada etkilidir.

Bu sayfanın önceki bölümlerinde açıklanan kompozisyon temelleri, cihaz üreticisi tarafından uygulanır. Net dokunsal geri bildirim için dokunma teknolojisi ilkelerine uygun, net, kısa ve hoş bir titreşim sağlar. Bu özellikler ve işleyiş şekilleri hakkında daha fazla bilgi için Titreşim aktüatörleri hakkında temel bilgiler başlıklı makaleyi inceleyin.

Android, desteklenmeyen temel öğeler içeren kompozisyonlar için yedekler sağlamaz. Bu nedenle, aşağıdaki adımları uygulayın:

  1. Gelişmiş dokunma teknolojinizi etkinleştirmeden önce, belirli bir cihazın kullandığınız tüm temel öğeleri desteklediğini kontrol edin.

  2. Desteklenmeyen tutarlı deneyimler grubunu devre dışı bırakın. Yalnızca temel öğesi eksik olan efektleri devre dışı bırakmayın.

Cihazın desteğini kontrol etme hakkında daha fazla bilgiyi aşağıdaki bölümlerde bulabilirsiniz.

Birleşik titreşim efektleri oluşturma

VibrationEffect.Composition ile birleştirilmiş titreşim efektleri oluşturabilirsiniz. Aşağıda, yavaşça yükselen bir efektin ardından gelen keskin bir tıklama efektine ilişkin örnek verilmiştir:

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

Sırayla oynatılacak temel öğeler eklenerek bir beste oluşturulur. Her birim ölçeklenebilir olduğundan her birinin oluşturduğu titreşimin genliğini kontrol edebilirsiniz. Ölçek, 0 ile 1 arasında bir değer olarak tanımlanır. 0, bu öğenin kullanıcı tarafından (zar zor) hissedilebileceği minimum genliğe karşılık gelir.

Titreşim temel öğelerinde varyant oluşturma

Aynı öğenin zayıf ve güçlü bir sürümünü oluşturmak istiyorsanız harcanan efor düzeyi farkının kolayca algılanabilmesi için 1,4 veya daha yüksek güç oranları oluşturun. Aynı öğenin üçten fazla yoğunluk seviyesini oluşturmaya çalışmayın. Çünkü bunlar algısal olarak farklı değildir. Örneğin, bir öğenin düşük, orta ve yüksek yoğunluklu sürümlerini oluşturmak için 0,5, 0,7 ve 1,0 ölçeklerini kullanın.

Titreşim öğeleri arasına boşluk ekleme

Kompozisyon, ardışık temel öğeler arasına eklenecek gecikmeleri de belirtebilir. Bu gecikme, önceki öğenin sonundan itibaren milisaniye cinsinden ifade edilir. Genel olarak, iki temel öğe arasındaki 5-10 ms'lik boşluk, algılanamayacak kadar kısadır. İki temel öğe arasında fark edilebilir bir boşluk oluşturmak istiyorsanız 50 ms veya daha uzun bir boşluk kullanın. Aşağıda, gecikmeler içeren bir beste örneği verilmiştir:

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

Hangi temel öğelerin desteklendiğini kontrol etme

Belirli temel öğeler için cihaz desteğini doğrulamak üzere aşağıdaki API'ler kullanılabilir:

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

Ayrıca, birden fazla temel öğeyi kontrol edip cihaz destek düzeyine göre hangi öğeleri oluşturacağınıza karar verebilirsiniz:

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

Titreşim kompozisyonu örnekleri

Aşağıdaki bölümlerde, GitHub'daki haptics örnek uygulamasından alınan titreşim kompozisyonlarıyla ilgili çeşitli örnekler verilmiştir.

Direnç (düşük işaretli)

Devam eden bir işlemle ilgili faydalı geri bildirimler vermek için temel titreşimin genliğini kontrol edebilirsiniz. Birbirine yakın ölçek değerleri, bir öğenin yumuşak bir crescendo efekti oluşturmak için kullanılabilir. Ardışık ilkel öğeler arasındaki gecikme de kullanıcı etkileşimine göre dinamik olarak ayarlanabilir. Bu durum, sürükleme hareketiyle kontrol edilen ve dokunsal geri bildirimlerle zenginleştirilmiş bir görünüm animasyonu örneğinde gösterilmektedir.

Aşağı sürüklenen daire animasyonu.
Giriş titreşimi dalga biçiminin grafiği.

Şekil 1. Bu dalga formu, cihazdaki titreşimin çıkış ivmesini gösterir.

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

Genişletme (yükselme ve düşmeyle)

Algılanan titreşim yoğunluğunu artırmak için iki ilkel vardır: PRIMITIVE_QUICK_RISE ve PRIMITIVE_SLOW_RISE. Her ikisi de aynı hedefe farklı sürelerde ulaşır. Yalnızca bir tane azaltma primi vardır: PRIMITIVE_QUICK_FALL. Bu temel öğeler, harcanan efor düzeyi artan ve sonra azalan bir dalga formu segmenti oluşturmak için birlikte daha iyi çalışır. Ölçeklendirilmiş temel öğeleri, aralarındaki genlikte ani sıçramaları önleyecek şekilde hizalayabilirsiniz. Bu, genel efekt süresini uzatmak için de iyi bir yöntemdir. İnsanlar, yükselen kısmı düşen kısımdan daha çok fark eder. Bu nedenle, yükselen kısmı düşen kısımdan daha kısa hale getirmek, vurguyu düşen kısma kaydırmak için kullanılabilir.

Bu kompozisyonun bir daireyi genişletme ve daraltma için kullanıldığı bir örneği aşağıda bulabilirsiniz. Yükselme efekti, animasyon sırasında genişleme hissini artırabilir. Yükselme ve düşme efektlerinin kombinasyonu, animasyonun sonunda daralmayı vurgulamaya yardımcı olur.

Genişleyen daire animasyonu.
Giriş titreşimi dalga biçiminin grafiği.

Şekil 2. Bu dalga biçimi, bir cihazdaki titreşimin çıkış ivmesini gösterir.

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

Wobble (dönüşlerle)

Dokunma teknolojisi ilkelerinden biri, kullanıcıları memnun etmektir. Hoş bir beklenmedik titreşim efektini tanıtmanın eğlenceli bir yolu PRIMITIVE_SPIN kullanmaktır. Bu temel işlev, birden fazla kez çağrıldığında en etkili sonucu verir. Birbirine eklenen birden fazla döndürme işlemi, titrek ve dengesiz bir efekt oluşturabilir. Bu efekt, her bir öğeye rastgele bir ölçeklendirme uygulayarak daha da geliştirilebilir. Ayrıca, ardışık döndürme temelleri arasındaki boşlukla da denemeler yapabilirsiniz. Arada boşluk olmadan (0 ms) iki dönüş, sıkı bir dönüş hissi yaratır. Dönüşler arasındaki boşluğu 10 ms'den 50 ms'ye çıkarmak daha rahat bir dönüş hissi sağlar ve video ya da animasyon süresine uyacak şekilde kullanılabilir.

Ardışık dönüşümler artık iyi bir şekilde entegre olmadığından ve ayrı efektler gibi görünmeye başladığından 100 ms'den uzun bir boşluk kullanmayın.

Aşağıda, aşağı çekilip bırakıldıktan sonra geri seken esnek bir şekil örneği verilmiştir. Animasyon, zıplama yer değiştirmesiyle orantılı olarak değişen yoğunluklarda oynatılan bir çift döndürme efektiyle geliştirilir.

Zıplayan esnek şekil animasyonu
Giriş titreşim dalga biçiminin grafiği

Şekil 3. Bu dalga formu, cihazdaki titreşimin çıkış ivmesini gösterir.

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

Zıplama (gürültüyle)

Titreşim efektlerinin bir diğer gelişmiş uygulaması da fiziksel etkileşimleri simüle etmektir. PRIMITIVE_THUD, güçlü ve yankı uyandıran bir etki yaratabilir. Bu etki, genel deneyimi zenginleştirmek için örneğin bir video veya animasyonda etki görselleştirmesiyle birlikte kullanılabilir.

Aşağıda, topun ekranın alt kısmına her çarpışında bir çarpma efektiyle geliştirilmiş bir top düşürme animasyonu örneği verilmiştir:

Düşürülen bir topun ekranın alt kısmından sekmesi animasyonu.
Giriş titreşimi dalga biçiminin grafiği.

Şekil 4. Bu dalga formu, cihazdaki titreşimin çıkış ivmesini gösterir.

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

Zarflı titreşim dalga biçimi

Özel titreşim modelleri oluşturma işlemi, titreşim genliğini kontrol etmenize olanak tanıyarak titreşimi kademeli olarak artırıp azaltma gibi yumuşak efektler oluşturmanızı sağlar. Bu bölümde, titreşim genliği ve frekansının zaman içinde hassas bir şekilde kontrol edilmesini sağlayan dalga formu zarflarını kullanarak dinamik dokunsal efektlerin nasıl oluşturulacağı açıklanmaktadır. Bu sayede daha zengin ve ayrıntılı dokunsal deneyimler oluşturabilirsiniz.

Android 16'dan (API düzeyi 36) itibaren sistem, bir kontrol noktası dizisi tanımlayarak titreşim dalga biçimi zarfı oluşturmak için aşağıdaki API'leri sağlar:

  • BasicEnvelopeBuilder: Donanımdan bağımsız dokunsal efektler oluşturmaya yönelik erişilebilir bir yaklaşım.
  • WaveformEnvelopeBuilder: Dokunsal efekt oluşturmaya yönelik daha gelişmiş bir yaklaşımdır. Dokunma teknolojisi donanımı hakkında bilgi sahibi olmayı gerektirir.

Android, zarf efektleri için geri dönüşüm sağlamaz. Bu destekten yararlanmak istiyorsanız aşağıdaki adımları tamamlayın:

  1. Belirli bir cihazın zarf efektlerini destekleyip desteklemediğini kontrol etmek için Vibrator.areEnvelopeEffectsSupported() simgesini kullanın.
  2. Desteklenmeyen tutarlı deneyimler grubunu devre dışı bırakın veya yedek alternatif olarak özel titreşim desenlerini ya da kompozisyonları kullanın.

Daha fazla temel zarf efekti oluşturmak için aşağıdaki parametrelerle BasicEnvelopeBuilder kullanın:

  • Titreşimin algılanan gücünü temsil eden \( [0, 1] \)aralığında bir yoğunluk değeri. Örneğin, \( 0.5 \)değeri, cihazın ulaşabileceği küresel maksimum yoğunluğun yarısı olarak algılanır.
  • Titreşimin netliğini temsil eden \( [0, 1] \)aralığında bir keskinlik değeri. Düşük değerler daha yumuşak titreşimlere, yüksek değerler ise daha keskin bir hisse neden olur.

  • Son kontrol noktasından (yani yoğunluk ve keskinlik çifti) yenisine geçişin milisaniye cinsinden süresini temsil eden bir süre değeri.

Aşağıda, 500 ms boyunca düşük perdeden yüksek perdeye, maksimum güce kadar titreşimi artıran ve ardından 100 ms boyunca tekrar\( 0 \) (kapalı) konumuna getiren bir dalga biçimi örneği verilmiştir.

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

Dokunma teknolojisi hakkında daha ileri düzeyde bilgiye sahipseniz WaveformEnvelopeBuilder kullanarak zarf efektlerini tanımlayabilirsiniz. Bu nesneyi kullanırken VibratorFrequencyProfile aracılığıyla frekans-çıkış hızlandırma eşlemesine (FOAM) erişebilirsiniz.

  • Cihazın FOAM'ı tarafından belirlenen, belirli bir frekansta elde edilebilecek titreşim gücünü temsil eden \( [0, 1] \)aralığında bir genlik değeri. Örneğin, \( 0.5 \) değeri, belirli bir sıklıkta elde edilebilecek maksimum çıkış hızının yarısını oluşturur.
  • Hertz cinsinden belirtilen bir frekans değeri.

  • Son kontrol noktasından yeni kontrol noktasına geçişin ne kadar sürdüğünü milisaniye cinsinden gösteren bir süre değeri.

Aşağıdaki kodda, 400 ms'lik bir titreşim efektini tanımlayan örnek bir dalga formu gösterilmektedir. Bu işlem, 60 Hz'lik sabit bir frekansta, kapalıdan tam parlaklığa 50 ms'lik bir genlik artışıyla başlar. Ardından, frekans sonraki 100 ms içinde 120 Hz'e yükselir ve 200 ms boyunca bu seviyede kalır. Son olarak, genlik \( 0 \)'ya düşer ve frekans son 50 ms içinde 60 Hz'e geri döner:

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

Aşağıdaki bölümlerde, zarflı titreşim dalga biçimleriyle ilgili çeşitli örnekler verilmiştir.

Geri seken yay

Önceki bir örnekte, fiziksel sıçrama etkileşimlerini simüle etmek için PRIMITIVE_THUD kullanılıyor. Temel zarf API'si, titreşim yoğunluğunu ve keskinliğini hassas bir şekilde ayarlamanıza olanak tanıyarak çok daha ayrıntılı kontrol sunar. Bu sayede, animasyonlu etkinlikleri daha doğru şekilde takip eden dokunsal geribildirimler elde edilir.

Aşağıda, animasyonu geliştirilmiş serbest düşen bir yay örneği verilmiştir. Yay ekranın alt kısmından her sektiğinde temel bir zarf efekti oynatılır:

Düşen bir yayın ekranın alt kısmından zıpladığı animasyonu.
Giriş titreşimi dalga biçiminin grafiği.

Şekil 5. Zıplayan bir yayı simüle eden titreşim için çıkış hızlanma dalga biçimi grafiği.

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

  val (screenHeight, screenWidth) = getScreenDimensions(context)

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

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

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

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

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

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

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

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

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

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

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

      delay(FRAME_DELAY_MS) // Control animation speed.

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

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

Roket kalkışı

Önceki bir örnekte, zıplayan yay tepkimesini simüle etmek için temel zarf API'sinin nasıl kullanılacağı gösterilmektedir. WaveformEnvelopeBuilder, cihazın tam frekans aralığı üzerinde hassas kontrol sağlar ve son derece özelleştirilmiş dokunsal efektlere olanak tanır. Bu verileri FOAM verileriyle birleştirerek titreşimleri belirli frekans özelliklerine göre ayarlayabilirsiniz.

Dinamik titreşim deseni kullanılarak yapılan bir roket fırlatma simülasyonunu gösteren örneği aşağıda bulabilirsiniz. Efekt, desteklenen minimum frekans hızlanma çıkışı olan 0,1 G'den rezonans frekansına kadar gider ve her zaman %10 genlik girişi korunur. Bu sayede, sürüş genliği aynı olsa da efekt makul ölçüde güçlü bir çıkışla başlayabilir ve algılanan yoğunluk ile keskinlik artırılabilir. Rezonansa ulaşıldığında, efekt sıklığı minimuma geri döner. Bu durum, harcanan efor düzeyi ve Keskinliğin azalması olarak algılanır. Bu, uzaya fırlatılmayı taklit ederek önce direnç, ardından serbest bırakılma hissi yaratır.

Temel zarf API'si, rezonans frekansı ve çıkış ivmesi eğrisiyle ilgili cihaza özgü bilgileri soyutladığı için bu efektle kullanılamaz. Keskinliği artırmak, eşdeğer frekansı rezonansın ötesine iterek istenmeyen bir hızlanma düşüşüne neden olabilir.

Ekranın alt kısmından havalanan roket gemi animasyonu.
Giriş titreşimi dalga biçiminin grafiği.

Şekil 6. Bir roket fırlatmasını simüle eden titreşim için çıkış hızlanma dalga biçimi grafiği.

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

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

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

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

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

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

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

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

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

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

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

LavaBeats

Roket fırlatma örneğinde olduğu gibi, WaveformEnvelopeBuilder API, titreşimdeki genlik ve frekans segmentlerinin kontrolünü sağlayarak birçok karmaşık dokunsal efektin tasarlanmasına olanak tanır. Bu tür bir tasarımın bir başka örneği de "canlılık" gibi daha soyut fiziksel duyumların taklit edilmesidir.

Bu, tipik bir elektrokardiyogram (EKG) sinyalinin biyolojik belirteçlerini belirli genlik ve frekanslardaki titreşim segmentleriyle temsil ederek yapılabilir. LavaBeats, bir EKG kaydının iki karakteristik segmentinin zaman gecikmesiyle ayrılmış iki darbe olarak gösterildiği bir örnektir. İlk karakteristik nabız, yüksek genlikli ve kısa süreli keskin bir tepe olarak görünen QRS kompleksidir. İkinci nabız, daha düşük genlikli, daha uzun süreli ve daha düzgün şekilli olan T dalgasıdır (Şekil 7'ye bakın).

WaveformEnvelopeBuilder simgesini kullanarak, sabit bir birinci-ikinci darbe gecikmesiyle ayrılmış bu iki darbenin çeşitli tekrarlarını oluşturun. İlk darbe, kısa bir süre içinde düşük frekansta başlayıp daha yüksek bir frekansta sona eren bir cıvıltı sinyali olabilir. İkinci darbe, düşük frekanslı bir sinüs dalgasının tek bir periyodu olarak gösterilebilir. İki vuruşu bir ritim hâline getirebilir ve aralarında gecikme olacak şekilde, dakikadaki vuruş (bpm) oranıyla birkaç kez tekrarlayabiliriz. Sonuç, atan bir kalbi andıran bir dokunsal efekt olur.

GitHub'daki dokunsal örnek uygulamamızda LavaBeats özelliğini deneyebilir ve dokunsal efektle aynı ritimde atan bir lav lambası görselleştirmesiyle birlikte efekti hissedebilirsiniz. Ayrıca, iki darbenin genliklerini, frekanslarını, sürelerini ve gecikmelerini değiştirerek farklı kalp atışı hisleri oluşturmak için efektin ayarlarını da değiştirebilirsiniz.

Dokunsal kalp atışı deseniyle atan lav lambası animasyonu.
EKG segmentinin ve karakteristik dalga biçimlerinin grafiği

Şekil 7. QRS kompleksi ve T dalgası içeren bir EKG kaydı segmenti

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

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

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

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