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

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

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

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

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

כשאתם מטמיעים אפקט מותאם אישית, כדאי להביא בחשבון את הנקודות הבאות:

  • אילו יכולות נדרשות במכשיר כדי להשתמש באפקט
  • מה עושים אם המכשיר לא יכול להפעיל את האפקט

במאמר Android haptics API reference מוסבר איך לבדוק אם יש תמיכה ברכיבים שקשורים לחוויית המגע, כדי שהאפליקציה תוכל לספק חוויה כוללת עקבית.

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

כדאי לתכנן את היכולות הבאות ברמת העל של המכשיר:

  • אם אתם משתמשים בפרימיטיבים של משוב מגע: מכשירים שתומכים בפרימיטיבים האלה שנדרשים לאפקטים המותאמים אישית. (פרטים על פרימיטיבים מופיעים בקטע הבא).

  • מכשירים עם בקרת אמפליטודה.

  • מכשירים עם תמיכה בסיסית ברעידות (הפעלה/השבתה) – כלומר, מכשירים בלי בקרת עוצמה.

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

שימוש ברכיבים בסיסיים של משוב פיזי

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

  • כדאי להשתמש בעיכובים של 50 אלפיות שנייה או יותר כדי ליצור פערים בולטים בין שני פרימטיבים, תוך התחשבות במשך הזמן של הפרימיטיב, אם אפשר.
  • מומלץ להשתמש בסולמות שונים ביחס של 1.4 או יותר, כדי שההבדל בעוצמה יהיה בולט יותר.
  • אפשר להשתמש בסקאלות של 0.5, ‏ 0.7 ו-1.0 כדי ליצור גרסאות של רכיב פרימיטיבי בעוצמה נמוכה, בינונית וגבוהה.

יצירת דפוסי רטט מותאמים אישית

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

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

דוגמה: דפוס של הגדלת נפח החשיפה

צורות הגל מיוצגות כ-VibrationEffect עם שלושה פרמטרים:

  1. Timings: מערך של משכי זמן, באלפיות שנייה, לכל מקטע של צורת הגל.
  2. Amplitudes: עוצמת הרטט הרצויה לכל משך זמן שצוין בארגומנט הראשון, שמיוצג בערך שלם מ-0 עד 255, כאשר 0 מייצג את מצב הרטט 'כבוי' ו-255 מייצג את עוצמת הרטט המקסימלית של המכשיר.
  3. Repeat index: האינדקס במערך שצוין בארגומנט הראשון שבו מתחילים לחזור על צורת הגל, או -1 אם צריך להפעיל את התבנית רק פעם אחת.

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

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 // Do not 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; // Do not repeat.

vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, repeatIndex));

דוגמה: דפוס חוזר

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

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

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

דוגמה: דפוס עם חלופה

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

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

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

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

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

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

ב-Android אין חלופות ליצירות מוזיקליות עם רכיבים בסיסיים שלא נתמכים. מומלץ לבצע את השלבים הבאים:

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

  2. משביתים את כל החוויה של הרכיבים שלא נתמכים, ולא רק את האפקטים חסרי הרכיבים הבסיסיים. בהמשך מוסבר איך לבדוק את התמיכה במכשיר.

אפשר ליצור אפקטים מורכבים של רטט באמצעות VibrationEffect.Composition. דוגמה לאפקט של עלייה איטית ואחריו אפקט קליק חד:

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

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

אם רוצים ליצור גרסה חלשה וגרסה חזקה של אותו פרימיטיב, מומלץ שההבדלים בין המינונים יהיו ביחס של 1.4 או יותר, כדי שאפשר יהיה להבחין בקלות בהבדל בעוצמה. אל תנסחו ליצור יותר משלוש רמות עוצמה של אותו פרימיטיב, כי הן לא נבדלות זו מזו מבחינה תפיסתית. לדוגמה, אפשר להשתמש בסולמות של 0.5,‏ 0.7 ו-1.0 כדי ליצור גרסאות של רכיב פרימיטיבי בעוצמה נמוכה, בינונית וגבוהה.

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

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

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

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

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

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

דוגמה: Resist (עם טיקים נמוכים)

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

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

איור 1. צורת הגל הזו מייצגת את תאוצת הפלט של הרטט במכשיר.

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

דוגמה: הרחבה (עם עלייה וירידה)

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

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

אנימציה של מעגל שמתרחב
תרשים של צורת הגל של הרטט בקלט

איור 2. צורת הגל הזו מייצגת את תאוצת הפלט של הרטט במכשיר.

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

דוגמה: תנודות (עם ספינים)

אחד מעקרונות ההפעלה החישה העיקריים הוא לשמח את המשתמשים. אפשר להשתמש בPRIMITIVE_SPIN כדי להוסיף אפקט רטט לא צפוי ומהנה. הפונקציה הזו הכי יעילה כשקוראים לה יותר מפעם אחת. שרשור של כמה ספינים יכול ליצור אפקט של תנודות וחוסר יציבות, שאפשר לשפר עוד יותר על ידי החלת שינוי גודל אקראי למדי על כל פרימיטיב. אפשר גם להתנסות בפער בין פרימיטיבים של סיבוב רציף. שני ספינים ללא פער (0ms ביניהם) יוצרים תחושה של סיבוב מהיר. הגדלת המרווח בין הסיבובים מ-10 ל-50 אלפיות השנייה גורמת לתחושה של סיבוב רופף יותר, ואפשר להשתמש בה כדי להתאים את משך הסיבוב למשך הסרטון או האנימציה.

לא מומלץ להשתמש בפער ארוך מ-100 אלפיות השנייה, כי הספינים הבאים כבר לא משתלבים היטב והם מתחילים להיראות כמו אפקטים נפרדים.

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

אנימציה של צורה אלסטית קופצת
תרשים של צורת הגל של הרטט בקלט

איור 3. צורת הגל הזו מייצגת את תאוצת הפלט של הרטט במכשיר.

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

דוגמה: קפיצה (עם חבטה)

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

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

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

איור 4. צורת הגל הזו מייצגת את תאוצת הפלט של הרטט במכשיר.

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