יצירת אפקטים פיזיים בהתאמה אישית

בדף הזה מוסבר איך להשתמש בממשקי API שונים של משוב פיזי כדי ליצור אפקטים בהתאמה אישית, מעבר לצורות הגל הרגילות של הרטט באפליקציה ל-Android.

הדף הזה כולל את הדוגמאות הבאות:

דוגמאות נוספות זמינות במאמר הוספת משוב פיזי לאירועים, וחשוב תמיד לפעול לפי עקרונות העיצוב של משוב פיזי.

שימוש בערכי ברירת מחדל כדי לטפל בתאימות של מכשירים

כשמטמיעים אפקטים מותאמים אישית של משוב פיזי, התאימות למכשיר וההתנהגות החלופית תלויות בממשק ה-API שבוחרים:

  • ‫VibrationEffect.Builder (מומלץ): החל מ-Android 16 (רבעון 4 של 2026), אפקטים שנוצרו באמצעות VibrationEffect.Builder כוללים חלופה אוטומטית ברמת ה-framework. אם מכשיר לא תומך באופן מקורי ב-Preset המבוקש או ב-Envelope הבסיסי, המסגרת מתרגמת אותו באופן אוטומטי לחלופה מתאימה בזמן ההפעלה, כמיטב יכולתה. אין צורך לבדוק ידנית את יכולות המכשיר לכל פרימיטיב לפני הפעלת אפקטים שמורכבים מ-VibrationEffect.Builder.
    • חריג: מעטפות מתקדמות של צורות גל שנוצרו באמצעות WaveformEnvelopeBuilder לא תומכות באפשרות חלופית אוטומטית, כי הן תלויות במיפויים ספציפיים של תדרי חומרה (FOAM). אם הם לא נתמכים, הם לא יופעלו.
  • ‫VibrationEffect.Composition: לקומפוזיציות שנוצרו באמצעות ה-API של 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 הוא ה-API המועדף ליצירת אפקטים מורכבים של רטט וקומפוזיציות. הוא מאפשר ליצור תחושות פיזיות מגוונות באמצעות רצף של אלמנטים פיזיים נפרדים לאורך ציר זמן מוחלט עם startTimeMillis.

‫VibrationEffect.Builder תומך בשילוב של כמה סוגי רטט:

לרטטים שנוצרו באמצעות VibrationEffect.Builder יש חלופה אוטומטית מובנית ברמת המסגרת בכל הרכיבים שלהם (כולל הגדרות קבועות מראש, מעטפות בסיסיות ואפקטים משולבים של רטט). כך מובטחת חוויית משתמש עקבית במכשירים שונים בלי צורך בבדיקות יכולת ידניות. (מעטפות מתקדמות של צורות גל שנוצרו באמצעות WaveformEnvelopeBuilder דורשות תמיכה בחומרה ואין להן תמיכה אוטומטית חלופית).

יצירת תוכן בעזרת הגדרות קבועות מראש

אפשר להשתמש ב-VibrationEffect.Preset כדי להוסיף לקומפוזיציה פולסים קצרים ונפוצים של משוב פיזי שהוגדרו מראש (כמו PRESET_CLICK,‏ PRESET_TICK או PRESET_LOW_TICK). הגדרות קבועות מראש מיושמות על ידי יצרן המכשיר כדי לספק רטט חד, קצר ונעים שתואם לעקרונות של משוב פיזי למשוב פיזי ברור. לפרטים נוספים על היכולות האלה ועל אופן הפעולה שלהן, קראו את המדריך למתחילים בנושא מפעילים של רטט.

ההגדרות הקבועות מראש מחליפות את הפרימיטיבים הקצרים מ-VibrationEffect.Composition API. כדי ליצור תחושות מגע ארוכות או רציפות, כמו הגברה והחלשה, משתמשים בצורות גל של מעטפת (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) (או מעבירים את האפקט ישירות ל-constructor של VibrationEffect.Builder(effect)). כשמייבאים מופעים של VibrationEffect.Composition בדרך הזו, ה-framework ממירה באופן אוטומטי את הפרימיטיבים שלהם להגדרות קבועות מראש, ומאפשרת תמיכה ב-fallback בזמן ריצה.

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, חשוב לזכור את כללי התזמון והאימות הבאים:

  • שעות התחלה בסדר עולה: כל רכיב שמוסיפים ל-builder חייב להיות עם startTimeMillis ששווה לשעת ההתחלה של הרכיב הקודם או מאוחרת ממנה.
  • אימות בזמן היצירה: הכלי ליצירת פלייליסטים מבצע אימות בזמן build() באמצעות משכי זמן ידועים של רכיבים (או מינימום של אלפית השנייה להגדרות קבועות מראש). אם מזוהה חפיפה בלתי אפשרית, נזרקת שגיאה מסוג IllegalArgumentException.
  • הזזה רציפה של ההפעלה: אם רכיב רטט קודם עדיין פועל פיזית כשמגיעה שעת ההתחלה של הרכיב הבא, המסגרת מעבירה באופן אוטומטי את הרכיב הבא למשבצת הזמן הזמינה המוקדמת ביותר. כך אפשר לוודא שהאירועים לא חופפים ושהרטט לא נפסק, אבל יכול להיות שיהיה שינוי קל בתזמון אם האירועים מתוזמנים קרוב מדי אחד לשני. כדי לצמצם את הסטייה, צריך להקפיד על מרווח זמן מספיק (למשל, 50 אלפיות השנייה או יותר) בין אירועים עוקבים של משוב פיזי.

יצירת קומפוזיציות של פרימיטיבים של רטט

בקטע הזה מוסבר איך ליצור רטט באמצעות VibrationEffect.Composition. הפרימיטיבים של הקומפוזיציה, שמתוארים למעלה בדף הזה, מוטמעים על ידי יצרן המכשיר. הם מספקים רטט קצר, נעים וחד שתואם לעקרונות של משוב פיזי, כדי שהמשוב הפיזי יהיה ברור. לפרטים נוספים על היכולות האלה ואיך הן פועלות, קראו את המדריך למתחילים בנושא מפעילים של רטט.

בניגוד ל-VibrationEffect.Builder, ל-API של 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, ה-framework ממירה את הפרימיטיבים של הקומפוזיציה להגדרות קבועות מראש, ומספקת באופן אוטומטי חלופה בזמן הריצה אם אחד מהפרימיטיבים לא נתמך במכשיר של המשתמש.

אפקטים מורכבים של רטט

אפשר ליצור אפקטים מורכבים של רטט באמצעות 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()
)

איך בודקים אילו פרימיטיבים נתמכים

אפשר להשתמש בממשקי ה-API הבאים כדי לוודא שהמכשיר תומך בפרימיטיבים ספציפיים:

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

צורת גל של רטט עם מעטפות

התהליך של יצירת תבניות רטט בהתאמה אישית מאפשר לכם לשלוט בעוצמת הרטט כדי ליצור אפקטים חלקים של הגברה והחלשה. בקטע הזה מוסבר איך ליצור אפקטים דינמיים של משוב פיזי באמצעות מעטפות של צורות גל, שמאפשרות שליטה מדויקת באמפליטודה ובתדירות של הרטט לאורך זמן. כך אפשר ליצור חוויות מגע עשירות ומגוונות יותר.

החל מ-Android 16 (רמת API‏ 36), המערכת מספקת את ממשקי ה-API הבאים כדי ליצור מעטפת של צורת גל של רטט על ידי הגדרת רצף של נקודות בקרה:

  • ‫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 \) יוצר חצי מהתאוצה המקסימלית שאפשר להשיג בתדר הנתון.
  • ערך תדירות, מצוין בהרץ.

  • ערך duration, שמייצג את הזמן, באלפיות השנייה, שנדרש כדי לעבור מנקודת הבקרה האחרונה לנקודה החדשה.

הקוד הבא מציג צורת גל לדוגמה שמגדירה אפקט רטט של 400 אלפיות השנייה. הוא מתחיל בעלייה של 50 אלפיות השנייה באמפליטודה, ממצב כבוי למצב מלא, בתדר קבוע של 60Hz. לאחר מכן, התדר עולה ל-120Hz במהלך 100 אלפיות השנייה הבאות ונשאר ברמה הזו למשך 200 אלפיות השנייה. לבסוף, האמפליטודה יורדת ל- \( 0 \), והתדר חוזר ל-60Hz במהלך 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 כדי לדמות אינטראקציות פיזיות של הקפצה. basic envelope API מאפשר שליטה מדויקת יותר, כך שתוכלו להתאים את העוצמה והחדות של הרטט. כך, המשוב הפיזי תואם בצורה מדויקת יותר לאירועים באנימציה.

הנה דוגמה לקפיץ בנפילה חופשית, כשהאנימציה משופרת באמצעות אפקט מעטפת בסיסי שמופעל בכל פעם שהקפיץ קופץ מהחלק התחתון של המסך:

אנימציה של קפיץ שנופל וקופץ מתחתית המסך.
תרשים של צורת הגל של הרטט.

איור 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))
        }
    }
}

שיגור טיל

בדוגמה קודמת הראינו איך להשתמש ב-API הבסיסי של המעטפת כדי לדמות תגובה של קפיץ קופצני. WaveformEnvelopeBuilder מאפשר שליטה מדויקת בכל טווח התדרים של המכשיר, וכך אפשר להתאים אישית את האפקטים של המשוב הפיזי. בשילוב עם נתוני FOAM, אפשר להתאים את הרטט ליכולות של תדרים ספציפיים.

הנה דוגמה שממחישה סימולציה של שיגור טיל באמצעות דפוס רטט דינמי. האפקט עובר מפלט התאוצה המינימלי של התדר הנתמך, ‎0.1 G, לתדר התהודה, תוך שמירה על קלט משרעת של 10%. כך האפקט מתחיל עם פלט חזק יחסית, והעוצמה והחדות הנתפסות גדלות, למרות שאמפליטודת ההפעלה זהה. כשהאפקט מגיע לתהודה, התדירות שלו יורדת בחזרה למינימום, וזה נתפס כירידה בעוצמה ובחדות. זה יוצר תחושה של התנגדות ראשונית ואז שחרור, בדומה לשיגור לחלל.

אי אפשר להשיג את האפקט הזה באמצעות ה-API הבסיסי של המעטפת, כי הוא מסתיר את המידע הספציפי למכשיר לגבי תדר התהודה ועקומת התאוצה של הפלט. הגברת החדות יכולה לדחוף את התדר המקביל מעבר לתהודה, ועלולה לגרום לירידה לא מכוונת בהאצה.

אנימציה של חללית שממריאה מתחתית המסך.
תרשים של צורת הגל של הרטט.

איור 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

כמו בדוגמה של שיגור הטיל, ה-API‏ WaveformEnvelopeBuilder מאפשר לעצב הרבה אפקטים מורכבים של משוב פיזי, באמצעות שליטה במקטעים של עוצמה ותדירות ברטט. דוגמה נוספת לעיצוב כזה היא הדמיה של תחושות פיזיות מופשטות יותר, כמו "חיוניות".

כדי לעשות את זה, אפשר לייצג סמנים ביולוגיים של אות אלקטרוקרדיוגרמה (ECG) טיפוסי באמצעות מקטעי רטט באמפליטודות ובתדירויות מסוימות. LavaBeats היא דוגמה שבה שני מקטעים אופייניים של הקלטת אק"ג מיוצגים כשני פולסים שמופרדים על ידי השהיית זמן. האות האופייני הראשון הוא קומפלקס QRS, שמוצג כשיא חד של משרעת גבוהה ומשך זמן קצר. הפעימה השנייה היא גל T, שיש לו אמפליטודה נמוכה יותר, משך זמן ארוך יותר וצורה חלקה יותר (ראו איור 7).

משתמשים ב-WaveformEnvelopeBuilder כדי ליצור חזרות שונות של שני הפולסים האלה, שמופרדים על ידי השהיה קבועה בין הפולס הראשון לשני. הפולס הראשון יכול להיות אות ציוץ שמתחיל בתדר נמוך ומסתיים בתדר גבוה יותר במשך זמן קצר. אפשר לייצג את הפולס השני כמחזור יחיד של גל סינוס בתדר נמוך. אנחנו יכולים לחבר את שני הפולסים לפעימה, ולחזור על החיבור כמה פעמים עם השהיה ביניהם, לפי קצב אופייני של פעימות לדקה (BPM). התוצאה היא אפקט של משוב פיזי שדומה לפעימות לב.

אתם יכולים לנסות את LavaBeats באפליקציה לדוגמה של משוב פיזי ב-GitHub, ולהרגיש את האפקט בליווי תצוגה חזותית של מנורת לבה שפועמת באותו קצב כמו האפקט הפיזי. אפשר גם לשנות את הגדרות האפקט כדי ליצור תחושות פעימה שונות, על ידי שינוי האמפליטודות, התדירויות, משכי הזמן וההשהיות של שתי הפעימות.

אנימציה של מנורת לבה שפועמת בתבנית של פעימות לב עם משוב פיזי.
תרשים של מקטע אק"ג וצורות הגל האופייניות שלו

איור 7. קטע מהקלטת אק"ג עם קומפלקס 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,
)