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

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

בדף הזה מופיעות הדוגמאות הבאות:

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

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

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

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

במסמכי העזר של Android Haptics API מוסבר איך לבדוק אם יש תמיכה ברכיבים שקשורים ל-Haptics, כדי שהאפליקציה תוכל לספק חוויה עקבית באופן כללי.

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

תכנון בהתאם לסוגים הבאים של יכולות המכשיר:

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

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

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

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

שימוש ברכיבי משוב מישושיים

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

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

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

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

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

דוגמאות לתבניות רטט

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

תבנית הגדלת נפח החשיפה

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

  1. Timings: מערך של משכי זמן, באלפיות השנייה, לכל קטע של צורת הגל.
  2. עוצמות: עוצמת הרטט הרצויה לכל משך זמן שצוין בארגומנט הראשון, שמיוצגת על ידי ערך שלם מ-0 עד 255, כאשר 0 מייצג את מצב הרטט 'כבוי' ו-255 מייצג את העוצמה המקסימלית של המכשיר.
  3. אינדקס החזרה: האינדקס במערך שצוין בארגומנט הראשון שבו מתחילה החזרה על צורת הגל, או -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);

דוגמאות להרכבי רטט

בקטעים הבאים מופיעות כמה דוגמאות להגדרות של רטט, שנלקחו מאפליקציית הדוגמה של Haptics ב-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. הפרימיטיב הזה יעיל במיוחד כשקוראים לו יותר מפעם אחת. אם משלבים כמה סיבובים, אפשר ליצור אפקט של תנודה וחוסר יציבות, שאפשר לשפר אותו עוד יותר על ידי החלת שינוי גודל אקראי על כל פרימיטיב. אפשר גם להתנסות בפער בין פרימיטיבים סמוכים של ספין. שתי ספינים ללא פער (0 ms ביניהם) יוצרים תחושת ספין חזקה. הגדלת הפער בין הסיבובים מ-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;
            }
            }
        });
    }
}

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

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

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

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

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

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

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

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

איור 5. גרף של צורת גל של האצת פלט עבור רטט שמדמה קפיץ מקפץ.

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

שיגור טיל

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

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

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

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

איור 6. גרף של צורת גל של האצת פלט לרעידה שמדמה שיגור של טיל.

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