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

בדף הזה מפורטות דוגמאות לשימוש בממשקי 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 // Don't repeat.

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

Java

long[] timings = new long[] {
    50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200 };
int[] amplitudes = new int[] {
    33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255 };
int repeatIndex = -1; // Don't repeat.

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

דפוס חוזר

אפשר גם להפעיל צורות גל שוב ושוב עד שמבטלים אותן. כדי ליצור צורת גל חוזרת, צריך להגדיר פרמטר 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);

דוגמאות ליצירות מוזיקליות של רטט

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

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

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

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

איור 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 the range [-0.1, +0.1] to be added to the
    // vibration scale so the spin effects have slightly different values.
    val randomOffset: Float = Random.Default.nextFloat() * 0.2f - 0.1f
    return (displacement + randomOffset).absoluteValue.coerceIn(0f, 1f)
}

Java

class AnimationListener implements DynamicAnimation.OnAnimationUpdateListener {
    private final Random vibrationRandom = new Random(seed);
    private final long lastVibrationUptime;

    @Override
    public void onAnimationUpdate(
        DynamicAnimation animation, float value, float velocity) {
        // Delay the next check for a sufficient duration until the current
        // composition finishes. Note that you can use
        // Vibrator.getPrimitiveDurations API to calculcate the delay.
        if (SystemClock.uptimeMillis() - lastVibrationUptime < VIBRATION_DURATION) {
            return;
        }

        float displacement = calculateRelativeDisplacement(value);

        // Use some sort of minimum displacement so the final few frames
        // of animation don't generate a vibration.
        if (displacement < SPIN_MIN_DISPLACEMENT) {
            return;
        }

        lastVibrationUptime = SystemClock.uptimeMillis();
        vibrator.vibrate(
        VibrationEffect.startComposition()
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .compose());
    }

    // Calculate a random scale for each spin to vary the full effect.
    float nextSpinScale(float displacement) {
        // Generate a random offset in the range [-0.1,+0.1] to be added to
        // the vibration scale so the spin effects have slightly different
        // values.
        float randomOffset = vibrationRandom.nextFloat() * 0.2f - 0.1f
        return MathUtils.clamp(displacement + randomOffset, 0f, 1f)
    }
}

קפיצה (עם חבטה)

שימוש מתקדם נוסף באפקטים של רטט הוא סימולציה של אינטראקציות פיזיות. 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;
            }
            }
        });
    }
}