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

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

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

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

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

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

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

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

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

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