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

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

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

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

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

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

ב-Android אין חלופות לאפקטים של מעטפות. אם אתם זקוקים לתמיכה הזו, עליכם לבצע את השלבים הבאים:

  1. אפשר לבדוק אם מכשיר מסוים תומך באפקטים של מעטפת באמצעות Vibrator.areEnvelopeEffectsSupported().
  2. משביתים את הקבוצה העקבית של חוויות השימוש שלא נתמכות, או משתמשים בדפוסי רטט מותאמים אישית או בלחנים כחלופות חלופיות.

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

  • ערך עוצמה בטווח \( [0, 1] \), שמייצג את עוצמת הרטט שחושבים שהיא קיימת. לדוגמה, ערך של \( 0.5 \) נתפס כמחצית מהעוצמה המקסימלית הגלובלית שניתן להשיג באמצעות המכשיר.
  • ערך sharpness בטווח \( [0, 1] \), שמייצג את החדות של הרטט. ערכים נמוכים יותר יוצרים רטט חלק יותר, ואילו ערכים גבוהים יותר יוצרים תחושה חדה יותר.

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

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

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

הקוד הבא מציג צורת גל לדוגמה שמגדירה אפקט רטט של 400 אלפיות שנייה. האות מתחיל בעלייה הדרגתית של האמפליטודה במשך 50 אלפיות השנייה, מ-0 ל-100%, בתדר קבוע של 60 הרץ. לאחר מכן, התדר עולה בהדרגה ל-120 הרץ במהלך 100 אלפיות השנייה הבאות, ונשאר ברמה הזו במשך 200 אלפיות השנייה. לבסוף, האמפליטודה יורדת בהדרגה ל- \( 0 \), והתדר חוזר ל-60 הרץ במהלך 50 אלפיות השנייה האחרונות:

vibrator.vibrate(VibrationEffect.WaveformEnvelopeBuilder()
    .addControlPoint(1.0f, 60f, 50)
    .addControlPoint(1.0f, 120f, 100)
    .addControlPoint(1.0f, 120f, 200)
    .addControlPoint(0.0f, 60f, 50)
    .build()
)

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

קפיץ מתנדנד

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

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

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

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

  val (screenHeight, screenWidth) = getScreenDimensions(context)

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

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

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

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

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

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

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

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

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

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

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

      delay(FRAME_DELAY_MS) // Control animation speed.

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

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

שיגור טיל

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

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

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

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

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

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

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

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

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

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

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

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

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

  vibrator.vibrate(
    VibrationEffect.WaveformEnvelopeBuilder()
      // Quickly reach the desired 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()
  )
}