إنشاء تأثيرات ملموسة مخصَّصة

تتناول هذه الصفحة أمثلة على كيفية استخدام واجهات برمجة التطبيقات المختلفة للميزات اللمسية لإنشاء تأثيرات مخصّصة تتجاوز الأشكال الموجية القياسية للاهتزاز في تطبيق Android.

تتضمّن هذه الصفحة الأمثلة التالية:

للاطّلاع على أمثلة إضافية، يمكنك مراجعة مقالة إضافة تجاوب حسّي إلى الأحداث، واتّباع مبادئ تصميم التجاوب الحسّي دائمًا.

استخدام الإجراءات الاحتياطية للتعامل مع توافق الأجهزة

عند تنفيذ تأثيرات لمسية مخصّصة، يعتمد توافق الجهاز وسلوك الرجوع على واجهة برمجة التطبيقات التي تختارها:

  • VibrationEffect.Builder (يُنصح به): اعتبارًا من Android 16 (الربع الرابع من عام 2026)، تتضمّن التأثيرات التي يتم إنشاؤها باستخدام VibrationEffect.Builder إجراءً احتياطيًا تلقائيًا على مستوى إطار العمل. إذا كان الجهاز لا يتيح استخدام Preset المطلوبة أو Envelope الأساسية، يترجمها إطار العمل تلقائيًا إلى بديل مناسب عند التشغيل بأفضل جهد ممكن. ولست بحاجة إلى التحقّق يدويًا من إمكانات الجهاز لكلّ عنصر أساسي قبل تشغيل التأثيرات التي تم إنشاؤها باستخدام VibrationEffect.Builder.
    • استثناء: لا تتيح مغلّفات الأشكال الموجية المتقدّمة التي تم إنشاؤها باستخدام WaveformEnvelopeBuilder استخدام ميزة "الاحتياطي التلقائي" لأنّها تعتمد على عمليات ربط محدّدة لترددات الأجهزة (FOAM). وإذا لم تكن متوافقة، لن يتم تشغيلها.
  • VibrationEffect.Composition: لا تتوفّر إجراءات احتياطية تلقائية للملفات المجمّعة التي تم إنشاؤها باستخدام واجهة برمجة التطبيقات startComposition(). إذا كان التركيب يحتوي على عنصر أساسي واحد غير متوافق، سيتعذّر تشغيل الاهتزاز بالكامل. يجب التحقّق يدويًا من الإمكانات باستخدام vibrator.arePrimitivesSupported().
  • الأشكال الموجية مع التحكّم في السعة: يتم تقريب السعات غير الصفرية إلى% 100 على الأجهزة التي لا تتضمّن ميزة التحكّم في السعة. يُرجى التحقّق من vibrator.hasAmplitudeControl() والرجوع إلى نمط التشغيل/الإيقاف المصمّم بشكل صريح إذا لزم الأمر.

استخدام العناصر الأساسية للاهتزازات

يتضمّن Android العديد من الأساسيات اللمسية التي تختلف في كل من السعة والتردد. يمكنك استخدام عنصر أساسي واحد بمفرده أو عناصر أساسية متعددة معًا لتحقيق تأثيرات لمسية غنية.

  • استخدِم فترات تأخير تبلغ 50 ملي ثانية أو أكثر للفواصل الملحوظة بين نمطين أساسيين، مع مراعاة مدة النمط الأساسي إن أمكن.
  • استخدِم مقاييس تختلف بنسبة 1.4 أو أكثر حتى يتم إدراك الفرق في مستوى الجهد بشكل أفضل.
  • استخدِم المقاييس 0.5 و0.7 و1.0 لإنشاء إصدارات منخفضة ومتوسطة وعالية الكثافة من العناصر الأساسية.

إنشاء أنماط اهتزاز مخصّصة

غالبًا ما تُستخدم أنماط الاهتزاز في تقنيات اللمس التي تجذب الانتباه، مثل الإشعارات ونغمات الرنين. يمكن لخدمة Vibrator تشغيل أنماط اهتزاز طويلة تغيّر سعة الاهتزاز بمرور الوقت. تُسمّى هذه التأثيرات الأشكال الموجية.

يمكن عادةً ملاحظة تأثيرات الشكل الموجي، ولكنّ الاهتزازات الطويلة المفاجئة قد تثير ذعر المستخدم إذا تم تشغيلها في بيئة هادئة. وقد يؤدي أيضًا رفع السعة المستهدَفة بسرعة كبيرة إلى صدور أصوات طنين مسموعة. صمِّم أنماطًا للشكل الموجي لتسهيل الانتقال بين السعات من أجل إنشاء تأثيرات تصاعدية وتنازلية.

أمثلة على أنماط الاهتزاز

تقدّم الأقسام التالية عدة أمثلة على أنماط الاهتزاز:

نمط الزيادة التدريجية

يتم تمثيل الأشكال الموجية على النحو التالي: VibrationEffect مع ثلاث مَعلمات:

  1. Timings: مصفوفة من المدد الزمنية، بالمللي ثانية، لكل جزء من الشكل الموجي
  2. السعات: سعة الاهتزاز المطلوبة لكل مدة محدّدة في الوسيطة الأولى، ويتم تمثيلها بقيمة عدد صحيح من 0 إلى 255، حيث يمثّل الرقم 0 "حالة الإيقاف" للهزاز ويمثّل الرقم 255 الحد الأقصى لسعة الجهاز.
  3. مؤشر التكرار: هو المؤشر في الصفيف المحدّد في الوسيطة الأولى لبدء تكرار الشكل الموجي، أو ‎-1 إذا كان يجب تشغيل النمط مرة واحدة فقط.

في ما يلي مثال على شكل موجي ينبض مرتين مع توقف مؤقت لمدة 350 مللي ثانية بين النبضات. النبضة الأولى هي زيادة سلسة إلى أقصى سعة، والثانية هي زيادة سريعة للحفاظ على أقصى سعة. يتم تحديد التوقف في النهاية من خلال قيمة فهرس التكرار السالبة.

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

نمط متكرّر

ويمكن أيضًا تشغيل الأشكال الموجية بشكل متكرر إلى أن يتم إلغاؤها. لإنشاء شكل موجي متكرر، يجب ضبط مَعلمة repeat غير سالبة. عند تشغيل شكل موجي متكرّر، يستمر الاهتزاز إلى أن يتم إلغاؤه صراحةً في الخدمة:

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

هذا مفيد جدًا للأحداث المتقطعة التي تتطلب إجراءً من المستخدم للإقرار بها. وتشمل الأمثلة على هذه الأحداث المكالمات الهاتفية الواردة والمنبّهات التي يتم تشغيلها.

نمط مع إجراء احتياطي

يُعدّ التحكّم في سعة الاهتزاز ميزة تعتمد على الأجهزة. يؤدي تشغيل شكل موجي على جهاز منخفض المواصفات لا يتضمّن هذه الإمكانية إلى اهتزاز الجهاز بأقصى سعة لكل إدخال موجب في مصفوفة السعة. إذا كان تطبيقك يحتاج إلى التوافق مع هذه الأجهزة، يمكنك استخدام نمط لا يُصدر صوت طنين عند تشغيله في هذه الحالة، أو تصميم نمط تشغيل/إيقاف أبسط يمكن تشغيله كبديل.

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

محتوى مركّب ومثبَّت في المخطط الزمني باستخدام "VibrationEffect.Builder"

اعتبارًا من الإصدار Android 16‏ (26Q4)، أصبحت واجهة برمجة التطبيقات VibrationEffect.Builder هي المفضّلة لإنشاء تأثيرات اهتزاز معقّدة ومجموعات منها. تتيح لك هذه الواجهة إنشاء أحاسيس لمسية معبّرة من خلال تسلسل عناصر لمسية منفصلة على طول مخطط زمني مطلق باستخدام startTimeMillis.

يتيح VibrationEffect.Builder الجمع بين أنواع اهتزاز متعددة:

  • الإعدادات المُسبَقة: نبضات لمسية محدّدة مسبقًا (VibrationEffect.Preset) مثل النقرات والاهتزازات
  • الأغلفة: أشكال موجية مستمرة ديناميكية، بما في ذلك الأغلفة الأساسية غير المرتبطة بالأجهزة (BasicEnvelopeBuilder) والأغلفة المتقدّمة المعدَّلة التردد (WaveformEnvelopeBuilder).
  • تأثيرات الاهتزاز الحالية: تركيبات أولية (VibrationEffect.Composition)، وأشكال موجية متدرجة (VibrationEffect.createWaveform)، وتأثيرات محدّدة مسبقًا (VibrationEffect.createPredefined)، وتأثيرات لمرة واحدة (VibrationEffect.createOneShot) يتم استيرادها باستخدام addEvents() أو نسخ الدالة الإنشائية.
  • التسلسلات المتكرّرة: هي أنماط مستمرة تم ضبطها باستخدام setRepeatingEffect().

تتضمّن الاهتزازات التي يتم إنشاؤها باستخدام VibrationEffect.Builder إجراءً احتياطيًا تلقائيًا مدمجًا على مستوى إطار العمل في جميع عناصرها (بما في ذلك الإعدادات المسبقة والمغلفات الأساسية وتأثيرات الاهتزاز المجمّعة)، ما يضمن تجربة مستخدم متّسقة على مختلف الأجهزة بدون الحاجة إلى إجراء عمليات تحقّق يدوية من الإمكانات. (تتطلّب الأغلفة الموجية المتقدمة التي تم إنشاؤها باستخدام WaveformEnvelopeBuilder دعمًا من الأجهزة ولا تتوفّر لها ميزة الاحتفاظ بالنسخة الاحتياطية تلقائيًا).

إنشاء محتوى باستخدام الإعدادات المسبقة

استخدِم VibrationEffect.Preset لإضافة نبضات لمسية قصيرة شائعة ومحدّدة مسبقًا (مثل PRESET_CLICK أو PRESET_TICK أو PRESET_LOW_TICK) إلى مقطوعة موسيقية. تنفّذ الشركة المصنّعة للجهاز الإعدادات المُسبَقة لتوفير اهتزاز واضح وقصير وممتع يتوافق مع مبادئ اللمس للحصول على لمسات واضحة. لمزيد من التفاصيل حول هذه الإمكانات وطريقة عملها، يُرجى الاطّلاع على مقدمة عن مشغّلات الاهتزاز.

تحلّ الإعدادات المُسبَقة محلّ العناصر الأساسية القصيرة من واجهة برمجة التطبيقات VibrationEffect.Composition. للحصول على أحاسيس لمسية أطول أو مستمرة، مثل زيادة الاهتزاز وخفضه، استخدِم الأشكال الموجية للغلاف (PWLE) بدلاً من ذلك.

يمكن تخصيص مقياس اختياري لكل إعداد مسبق بين 0.0f و1.0f، ويتم وضعه في وقت بدء صريح (بالمللي ثانية) من بداية المقطوعة الموسيقية.

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

التأليف باستخدام المغلفات والإعدادات المسبقة

يمكنك دمج مثيلات VibrationEffect.Envelope (التي تم إنشاؤها باستخدام BasicEnvelopeBuilder أو WaveformEnvelopeBuilder) بسلاسة مع الإعدادات المسبقة لإنشاء أنماط لمسية غنية ومتعددة الأجزاء.

في ما يلي مثال على تشغيل غلاف سلس للزيادة التدريجية والتلاشي متبوعًا بإعداد مسبق للنقرة الحادة:

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

إعادة استخدام الأحداث الحالية ونقلها

لإعادة استخدام VibrationEffect حالي أو دمجه (بما في ذلك VibrationEffect.Composition)، يمكنك استرداد قائمة عناصر VibrationEffect.Event باستخدام getEvents() وإلحاقها بإزاحة باستخدام addEvents(startTimeShiftMillis, events) (أو تمرير التأثير مباشرةً إلى دالة الإنشاء VibrationEffect.Builder(effect)). عند استيراد مثيلات VibrationEffect.Composition بهذه الطريقة، يحوّل إطار العمل تلقائيًا العناصر الأساسية إلى إعدادات مسبقة، ما يتيح استخدام الإعدادات الاحتياطية في وقت التشغيل.

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

إنشاء تركيبات متكررة

استخدِم setRepeatingEffect(startTimeMillis, repeatingEffect, durationMillis) لإضافة نمط متكرر إلى تركيبة:

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

التوقيت والتحقّق من الصحة وإدارة الانحراف

عند تصميم تركيبات باستخدام VibrationEffect.Builder، يُرجى مراعاة قواعد التوقيت والتحقّق التالية:

  • أوقات بدء متزايدة بشكل صارم: يجب أن يكون لكل عنصر يُضاف إلى أداة الإنشاء startTimeMillis أكبر من أو يساوي وقت بدء العنصر السابق بشكل صارم.
  • التحقّق من الصحة في وقت الإنشاء: تُجري أداة الإنشاء عملية تحقّق من الصحة بأفضل جهد في وقت build() باستخدام مُدد العناصر المعروفة (أو مدة لا تقل عن 1 ملي ثانية للإعدادات المسبقة). في حال رصد تداخل مستحيل، يتم عرض IllegalArgumentException.
  • التبديل التسلسلي للتشغيل: إذا كان عنصر الاهتزاز السابق لا يزال يعمل فعليًا عند حلول وقت بدء العنصر التالي، فإن إطار العمل ينقل العنصر التالي تلقائيًا إلى أقرب خانة زمنية متاحة. يضمن ذلك عدم تداخل الأحداث وعدم تجاهل أي اهتزازات، على الرغم من أنّه قد يؤدي إلى انحراف طفيف في التوقيت إذا تمت جدولة الأحداث بشكل متقارب جدًا. لتقليل الانحراف، يجب إتاحة وقت كافٍ (مثلاً 50 مللي ثانية أو أكثر) بين أحداث اللمس المتتالية.

إنشاء تركيبات من أنماط الاهتزاز الأساسية

يوضّح هذا القسم كيفية إنشاء الاهتزازات باستخدام VibrationEffect.Composition. يتم تنفيذ العناصر الأساسية للتكوين، الموضّحة سابقًا في هذه الصفحة، من قِبل الشركة المصنّعة للجهاز. وتوفّر هذه الإشارات اهتزازًا واضحًا وقصيرًا وممتعًا يتوافق مع مبادئ اللمس للحصول على إشارات لمس واضحة. لمزيد من التفاصيل حول هذه الإمكانات وطريقة عملها، يُرجى الاطّلاع على دليل مشغّلات الاهتزاز.

على عكس VibrationEffect.Builder، لا تتضمّن واجهة برمجة التطبيقات VibrationEffect.Composition إجراءات احتياطية تلقائية للعناصر الأساسية غير المتوافقة. ولذلك:

  1. قبل تفعيل ميزة "الاهتزازات المتقدّمة"، تأكَّد من أنّ الجهاز المعنيّ يتيح جميع العناصر الأساسية التي تستخدمها.

  2. يجب إيقاف مجموعة التجارب المتّسقة غير المتوافقة، وليس فقط التأثيرات التي تفتقر إلى عنصر أساسي.

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

عند إنشاء الإطار باستخدام VibrationEffect.Builder، يحوّل الإطار العناصر الأساسية للتكوين إلى إعدادات مسبقة ويوفّر تلقائيًا خيارًا احتياطيًا لوقت التشغيل إذا كان أي عنصر أساسي غير متوافق مع جهاز المستخدم.

تأثيرات الاهتزاز المجمّعة

يمكنك إنشاء تأثيرات اهتزاز مركّبة باستخدام VibrationEffect.Composition. في ما يلي مثال على تأثير اهتزاز يرتفع ببطء يليه تأثير نقرة حادة:

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

يتم إنشاء المقطوعة الموسيقية من خلال إضافة عناصر أساسية ليتم تشغيلها بالتسلسل. ويمكن أيضًا تغيير حجم كل نمط أساسي، ما يتيح لك التحكّم في سعة الاهتزاز الذي يصدره كل نمط. يتم تحديد المقياس كقيمة بين 0 و1، حيث يتم ربط 0 فعليًا بالحد الأدنى من السعة التي يمكن للمستخدم الشعور بها (بالكاد).

إنشاء نُسخ متنوعة من أنماط الاهتزاز الأساسية

إذا أردت إنشاء نسخة ضعيفة وقوية من نفس النمط الأساسي، فأنشئ نسب قوة تبلغ 1.4 أو أكثر، بحيث يمكن إدراك الفرق في مستوى الجهد بسهولة. لا تحاول إنشاء أكثر من ثلاثة مستويات من الشدة لنفس النمط الأولي، لأنها ليست متميزة من الناحية الإدراكية. على سبيل المثال، استخدِم مقاييس 0.5 و0.7 و1.0 لإنشاء إصدارات منخفضة ومتوسطة وعالية الشدة من عنصر أساسي.

إضافة فواصل بين أنماط الاهتزاز الأساسية

يمكن أن يحدّد التركيب أيضًا فترات التأخير التي ستتم إضافتها بين العناصر الأساسية المتتالية. يتم التعبير عن هذا التأخير بالمللي ثانية منذ نهاية النموذج الأولي السابق. بشكل عام، تكون الفجوة التي تتراوح بين 5 و10 مللي ثانية بين وحدتَين أساسيتَين قصيرة جدًا بحيث لا يمكن رصدها. استخدِم فجوة تبلغ مدتها 50 ملي ثانية أو أكثر إذا أردت إنشاء فجوة ملحوظة بين نمطَين أساسيَين. في ما يلي مثال على تركيبة تتضمّن تأخيرات:

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

التحقّق من العناصر الأساسية المتوافقة

يمكن استخدام واجهات برمجة التطبيقات التالية للتحقّق من توافق الجهاز مع عناصر أساسية معيّنة:

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

يمكنك أيضًا التحقّق من عدة أنماط أساسية ثم تحديد الأنماط التي تريد إنشاؤها استنادًا إلى مستوى دعم الجهاز:

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

أمثلة على تركيبات الاهتزاز

تقدّم الأقسام التالية عدة أمثلة على تركيبات الاهتزاز، مأخوذة من نموذج تطبيق اللمس على GitHub.

مقاومة (مع علامات منخفضة)

يمكنك التحكّم في سعة الاهتزاز الأساسي لتقديم ملاحظات مفيدة حول إجراء قيد التنفيذ. يمكن استخدام قيم المقياس المتقاربة لإنشاء تأثير تصاعدي سلس للاهتزاز الأساسي. يمكن أيضًا ضبط التأخير بين العناصر الأساسية المتتالية ديناميكيًا استنادًا إلى تفاعل المستخدم. يتضح ذلك في المثال التالي لحركة عرض يتم التحكّم فيها من خلال إيماءة السحب ويتم تعزيزها باللمس.

صورة متحركة لدائرة يتم سحبها للأسفل
رسم بياني للشكل الموجي للاهتزاز المُدخل

الشكل 1: يمثّل هذا الشكل الموجي تسارع الاهتزاز الناتج على الجهاز.

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

توسيع (مع الارتفاع والانخفاض)

هناك عنصران أساسيان لزيادة مستوى الجهد الملحوظ للاهتزاز: PRIMITIVE_QUICK_RISE وPRIMITIVE_SLOW_RISE. كلاهما يصل إلى نفس الهدف، ولكن بمدد مختلفة. هناك عنصر أساسي واحد فقط لخفض السرعة، وهو PRIMITIVE_QUICK_FALL. تعمل هذه العناصر الأساسية معًا بشكل أفضل لإنشاء مقطع شكل موجي يزداد في مستوى الجهد ثم يتلاشى. يمكنك محاذاة العناصر الأساسية التي تم تغيير حجمها لمنع القفزات المفاجئة في السعة بينها، وهو ما يعمل أيضًا بشكل جيد لتمديد مدة التأثير الإجمالي. من الناحية الإدراكية، يلاحظ الأشخاص دائمًا الجزء الصاعد أكثر من الجزء الهابط، لذا فإن جعل الجزء الصاعد أقصر من الجزء الهابط يمكن استخدامه لتحويل التركيز نحو الجزء الهابط.

في ما يلي مثال على تطبيق هذه التركيبة لتوسيع دائرة وتصغيرها. يمكن أن يعزز تأثير الارتفاع الشعور بالتوسع أثناء الرسوم المتحركة. يساعد الجمع بين تأثيرات الارتفاع والسقوط في التأكيد على الانهيار في نهاية الرسوم المتحركة.

صورة متحركة لدائرة تتوسّع
رسم بياني للشكل الموجي للاهتزاز المُدخل

الشكل 2: يمثّل هذا الشكل الموجي تسارع الاهتزاز الناتج على الجهاز.

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

تأرجُح (مع دوران)

من بين مبادئ اللمس الأساسية إرضاء المستخدمين. من الطرق الممتعة لإضافة تأثير اهتزاز لطيف وغير متوقّع استخدام PRIMITIVE_SPIN. يكون هذا المكوّن الأولي أكثر فعالية عند استدعائه أكثر من مرة. يمكن أن يؤدي دمج عدة دورات إلى إنشاء تأثير متذبذب وغير مستقر، ويمكن تحسينه بشكل أكبر من خلال تطبيق تغيير عشوائي في الحجم على كل عنصر أساسي. يمكنك أيضًا تجربة الفجوة بين العناصر الأساسية المتتالية لتدوير الصور. يؤدي تدوير الجهاز مرتين بدون أي فاصل زمني (0 ملي ثانية بينهما) إلى الشعور بدوران سريع. تؤدي زيادة الفاصل الزمني بين الدورات من 10 إلى 50 ملي ثانية إلى إحساس بالدوران أكثر مرونة، ويمكن استخدامها لمطابقة مدة فيديو أو صورة متحركة.

لا تستخدِم فاصلاً زمنيًا أطول من 100 ملي ثانية، لأنّ الدورات المتتالية لن تتكامل بشكل جيد وستبدأ في الظهور كتأثيرات فردية.

في ما يلي مثال على شكل مرن يرتد بعد سحبه لأسفل ثم تحريره. يتم تحسين الحركة من خلال تأثيرَي دوران يتم تشغيلهما بمستويات جهد متفاوتة تتناسب مع إزاحة الارتداد.

صورة متحركة لشكل مرن يقفز
رسم بياني للشكل الموجي للاهتزاز المُدخَل

الشكل 3: يمثّل هذا الشكل الموجي تسارع الاهتزاز الناتج على الجهاز.

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

ارتداد (مع أصوات ارتطام)

من التطبيقات المتقدّمة الأخرى لتأثيرات الاهتزاز محاكاة التفاعلات الجسدية. PRIMITIVE_THUD يمكن أن يُحدث تأثيرًا قويًا ومترددًا، ويمكن إقرانه بتصور التأثير، في فيديو أو رسوم متحركة على سبيل المثال، لتعزيز التجربة الكلية.

في ما يلي مثال على صورة متحركة لكرة تسقط، تم تحسينها من خلال تشغيل تأثير صوتي مكتوم في كل مرة ترتدّ فيها الكرة من أسفل الشاشة:

صورة متحركة لكرة تسقط وترتد من أسفل الشاشة
رسم بياني للشكل الموجي للاهتزاز المُدخل

الشكل 4. يمثّل هذا الشكل الموجي تسارع الاهتزاز الناتج على الجهاز.

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

شكل موجي للاهتزاز مع مغلفات

تتيح لك عملية إنشاء أنماط اهتزاز مخصّصة التحكّم في سعة الاهتزاز لإنشاء تأثيرات سلسة لزيادة الاهتزاز وخفضه. يشرح هذا القسم كيفية إنشاء تأثيرات لمسية ديناميكية باستخدام مغلفات الشكل الموجي التي تتيح التحكم الدقيق في سعة الاهتزاز وتردده بمرور الوقت. يتيح لك ذلك إنشاء تجارب لمسية أكثر ثراءً ودقة.

اعتبارًا من الإصدار 16 من نظام التشغيل Android (مستوى واجهة برمجة التطبيقات 36)، يوفّر النظام واجهات برمجة التطبيقات التالية لإنشاء غلاف شكل موجي للاهتزاز من خلال تحديد تسلسل من نقاط التحكّم:

  • BasicEnvelopeBuilder: طريقة سهلة لإنشاء تأثيرات لمسية لا تعتمد على الأجهزة
  • WaveformEnvelopeBuilder: طريقة أكثر تقدّمًا لإنشاء تأثيرات لمسية، وتتطلّب الإلمام بالأجهزة اللمسية.

لا يوفّر Android بدائل لتأثيرات المغلفات. إذا كنت بحاجة إلى هذا الدعم، فأكمل الخطوات التالية:

  1. يمكنك التحقّق من توافق جهاز معيّن مع تأثيرات المغلف باستخدام Vibrator.areEnvelopeEffectsSupported().
  2. أوقِف مجموعة التجارب المتّسقة غير المتوافقة، أو استخدِم أنماط الاهتزاز المخصّصة أو التركيبات كبدائل احتياطية.

لإنشاء تأثيرات غلاف أساسية أكثر، استخدِم BasicEnvelopeBuilder مع هذه المَعلمات:

  • قيمة مستوى الجهد في النطاق \( [0, 1] \)، والتي تمثّل القوة الملحوظة للاهتزاز على سبيل المثال، يتم اعتبار القيمة \( 0.5 \) نصف الحد الأقصى العام لمستوى الجهد الذي يمكن للجهاز تحقيقه.
  • قيمة الحدّة في النطاق \( [0, 1] \)، والتي تمثّل وضوح الاهتزاز. وتعني القيم المنخفضة اهتزازات أكثر سلاسة، بينما تؤدي القيم الأعلى إلى إحساس أكثر حدة.

  • قيمة المدة، والتي تمثّل الوقت بالملي ثانية المستغرَق للانتقال من نقطة التحكّم الأخيرة (أي زوج من مستوى الجهد والحدّة) إلى النقطة الجديدة.

في ما يلي مثال على شكل موجي يزيد من مستوى الجهد من اهتزاز منخفض الحدة إلى اهتزاز عالي الحدة وبأقصى قوة خلال 500 مللي ثانية، ثم ينخفض مرة أخرى إلى\( 0 \) (إيقاف) خلال 100 مللي ثانية.

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

إذا كانت لديك معرفة أكثر تقدّمًا باللمس، يمكنك تحديد تأثيرات الغلاف باستخدام WaveformEnvelopeBuilder. عند استخدام هذا العنصر، يمكنك الوصول إلى الربط بين التردد والتسارع الناتج (FOAM) من خلال VibratorFrequencyProfile.

  • قيمة سعة في النطاق \( [0, 1] \)، تمثّل قوة الاهتزاز التي يمكن تحقيقها عند تردد معيّن، على النحو الذي يحدّده نموذج FOAM للجهاز. على سبيل المثال، تؤدي القيمة \( 0.5 \) إلى إنشاء نصف الحد الأقصى لتسارع الإخراج الذي يمكن تحقيقه عند التردد المحدد.
  • قيمة التردد، المحدّدة بالهرتز.

  • قيمة مدة تمثّل الوقت بالملي ثانية المستغرَق للانتقال من نقطة التحكّم الأخيرة إلى النقطة الجديدة.

يوضّح الرمز التالي مثالاً على شكل موجي يحدّد تأثير اهتزاز مدته 400 ملي ثانية. يبدأ بنبضة سعة 50 ملي ثانية، من الإيقاف إلى التشغيل الكامل، بتردد ثابت يبلغ 60 هرتز. بعد ذلك، يرتفع التردد إلى 120 هرتز خلال الـ 100 ملي ثانية التالية ويظل عند هذا المستوى لمدة 200 ملي ثانية. أخيرًا، تنخفض السعة إلى \( 0 \)، ويعود التردد إلى 60 هرتز خلال آخر 50 ملي ثانية:

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

تقدّم الأقسام التالية عدة أمثلة على الأشكال الموجية للاهتزاز مع الأغلفة.

نابض يرتد

يستخدم نموذج سابق PRIMITIVE_THUD لمحاكاة تفاعلات الارتداد المادي. توفّر واجهة برمجة التطبيقات الأساسية تحكّمًا أكثر دقة، ما يتيح لك تخصيص مستوى جهد الاهتزاز وحدّته بدقة. يؤدي ذلك إلى تجاوب حسّي يتّبع الأحداث المتحركة بدقة أكبر.

في ما يلي مثال على زنبرك يسقط سقوطًا حرًا مع تحسين الحركة باستخدام تأثير مغلف أساسي يتم تشغيله في كل مرة يرتد فيها الزنبرك من أسفل الشاشة:

صورة متحركة لنابض يسقط ويرتد من أسفل الشاشة
رسم بياني للشكل الموجي للاهتزاز المُدخل

الشكل 5: رسم بياني للشكل الموجي لتسارع المخرجات لاهتزاز يحاكي نابضًا مرتدًا.

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

إطلاق صاروخ

يوضّح نموذج سابق كيفية استخدام واجهة برمجة التطبيقات الأساسية للمغلّف لمحاكاة ردّ فعل نابض مرن. يتيح WaveformEnvelopeBuilder التحكّم الدقيق في نطاق التردّد الكامل للجهاز، ما يتيح تخصيص تأثيرات اللمس بشكل كبير. من خلال دمج هذه البيانات مع بيانات FOAM، يمكنك تخصيص الاهتزازات لتناسب قدرات ترددات معيّنة.

إليك مثال يوضّح محاكاة إطلاق صاروخ باستخدام نمط اهتزاز ديناميكي. يبدأ التأثير من الحد الأدنى لناتج تسارع التردد المتوافق، وهو 0.1 G، إلى تردد الرنين، مع الحفاظ دائمًا على إدخال سعة بنسبة %10. يتيح ذلك بدء التأثير بإخراج قوي بشكل معقول وزيادة مستوى الجهد والحدة الملحوظة، على الرغم من أنّ سعة التشغيل هي نفسها. وعند الوصول إلى الرنين، ينخفض تردد التأثير إلى الحد الأدنى، وهو ما يُنظر إليه على أنه انخفاض في مستوى الجهد والحدة. يخلق هذا إحساسًا بالمقاومة الأولية يتبعه إطلاق، يحاكي الإطلاق في الفضاء.

لا يمكن تحقيق هذا التأثير باستخدام واجهة برمجة التطبيقات الأساسية للاهتزاز، لأنّها تستبعد المعلومات الخاصة بالجهاز حول تردّد الرنين ومنحنى تسارع الإخراج. قد تؤدي زيادة الحدة إلى دفع التردد المكافئ إلى ما بعد الرنين، ما قد يؤدي إلى انخفاض غير مقصود في التسارع.

صورة متحركة لصاروخ ينطلق من أسفل الشاشة
رسم بياني للشكل الموجي للاهتزاز المُدخل

الشكل 6: رسم بياني للشكل الموجي لتسارع الاهتزاز الذي يحاكي إطلاق صاروخ.

@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

كما هو الحال في مثال إطلاق الصاروخ، تتيح واجهة برمجة التطبيقات WaveformEnvelopeBuilder تصميم العديد من التأثيرات اللمسية المعقّدة من خلال التحكّم في أجزاء السعة والتردد في الاهتزاز. مثال آخر على هذا التصميم هو محاكاة الأحاسيس الجسدية الأكثر تجريدًا، مثل "الحيوية".

ويمكن تحقيق ذلك من خلال تمثيل المؤشرات الحيوية لإشارة مخطَّط كهربية القلب (ECG) النموذجية بأجزاء اهتزاز ذات سعات وترددات معيّنة. LavaBeats هو مثال يتم فيه تمثيل جزأين مميزين من تسجيل مخطط كهربية القلب على شكل نبضتين يفصل بينهما تأخير زمني. النبضة المميزة الأولى هي مركب QRS، والذي يظهر كذروة حادة ذات سعة عالية ومدة قصيرة. النبضة الثانية هي الموجة T، والتي تتميز بسعة أقل ومدة أطول وشكل أكثر سلاسة (انظر الشكل 7).

استخدِم WaveformEnvelopeBuilder لإنشاء تكرارات مختلفة لهاتين النبضتين مفصولة بتأخير ثابت بين النبضة الأولى والثانية. يمكن أن تكون النبضة الأولى إشارة زقزقة تبدأ بتردد منخفض وتنتهي بتردد أعلى خلال مدة قصيرة. يمكن تمثيل النبضة الثانية كفترة واحدة من جيب التردد المنخفض. يمكننا دمج النبضتين في نبضة واحدة، وتكرار التركيب عدة مرات مع تأخير بينهما، باتباع معدل نبضات في الدقيقة (bpm) نموذجي. والنتيجة هي تأثير لمسي يشبه نبضات القلب.

يمكنك تجربة LavaBeats في نموذج تطبيق اللمس على GitHub والشعور بالتأثير المصاحب لتصوّر مصباح الحمم البركانية الذي ينبض بنفس إيقاع تأثير اللمس. يمكنك أيضًا تغيير إعدادات التأثير لإنشاء أحاسيس نبض مختلفة عن طريق تعديل السعات والترددات والمدد والتأخيرات للنبضتين.

صورة متحركة لمصباح لافا ينبض بنمط نبضات قلب لمسية
رسم بياني لشريحة من مخطَّط كهربية القلب (ECG) وأشكاله الموجية المميّزة

الشكل 7: جزء من تسجيل مخطَّط كهربية القلب (ECG) مع مركّب QRS وموجة 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,
)