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

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

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

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

אינטראקציות

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

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

כשמשתמש יוצר אינטראקציה עם רכיב בממשק המשתמש, המערכת מייצגת את ההתנהגות שלו באמצעות יצירה של Interaction אירועים. לדוגמה, אם משתמש נוגע בלחצן, הלחצן יוצר PressInteraction.Press אם המשתמש מרים את האצבע בתוך הלחצן, נוצרת PressInteraction.Release ליידע את הלחצן שהקליק הסתיים. לעומת זאת, אם משתמש גורר את האצבע אל מחוץ ללחצן, ואז מרים את האצבע, על הלחצן יוצרת PressInteraction.Cancel כדי לציין שהלחיצה על הלחצן בוטלה, לא הושלמה.

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

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

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

מצב אינטראקציה

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

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

מלבד collectIsPressedAsState(), 'כתיבה' מספקת גם collectIsFocusedAsState(), collectIsDraggedAsState() וגם collectIsHoveredAsState() השיטות האלה הן למעשה שיטות נוחות על בסיס ממשקי API של InteractionSource ברמה נמוכה יותר. במקרים מסוימים, ייתכן שרוצים להשתמש בפונקציות שברמה הנמוכה יותר באופן ישיר.

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

בקטע הבא מוסבר איך לצרוך ולפלט אינטראקציות עם InteractionSource ו-MutableInteractionSource, בהתאמה.

צריכה ופליטה של Interaction

InteractionSource מייצג זרם של Interactions לקריאה בלבד – הוא לא אפשרי פולט Interaction ל-InteractionSource. לפטור Interaction, עליך להשתמש ב-MutableInteractionSource, שמתחיל מ- InteractionSource.

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

דוגמה לשימוש בתכונה

עבור תנאי שמשרטט גבול למצב מיקוד, צריך רק להקפיד Interactions, כדי שתוכלו לאשר InteractionSource:

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

ברור בחתימת הפונקציה שההתאמה הזו היא צרכן – הוא יכול לצרוך Interaction, אבל לא פולט אותן.

דוגמה למקש צירוף

עבור מגביל שמטפל באירועים של העברת העכבר, כמו Modifier.hoverable, צריך: צריך פליטת נתונים Interactions ולקבל MutableInteractionSource במקום זאת:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

המשתנה הזה הוא מפיק – הוא יכול להשתמש MutableInteractionSource כדי פליטת HoverInteractions כשמעבירים את העכבר מעליו או שלא זזים.

לפתח רכיבים שצורכים ומייצרים

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

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,

    // exposes MutableInteractionSource as a parameter
    interactionSource: MutableInteractionSource? = null,

    elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { /* content() */ }

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

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

פיתוח נייטיב מבוסס על גישה אדריכלית רב-שכבתית, כך שרכיבי Material ברמה גבוהה בנויים על גבי בניינים בסיסיים בלוקים שמייצרים את Interaction שהם צריכים כדי לשלוט בהדים של אפקטים חזותיים. ספריית הבסיס מספקת התאמות ברמה גבוהה של אינטראקציות כמו Modifier.hoverable, Modifier.focusable, Modifier.draggable.

כדי ליצור רכיב שמגיב לאירועים של העברת העכבר, אפשר פשוט להשתמש Modifier.hoverable ולהעביר את MutableInteractionSource כפרמטר. בכל פעם שמרחפים מעל הרכיב, הוא פולט ערכי HoverInteraction, ואפשר להשתמש כדי לשנות את האופן שבו הרכיב ייראה.

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

כדי שיהיה אפשר להתמקד ברכיב הזה, אפשר להוסיף Modifier.focusable ולהעביר אותו MutableInteractionSource בתור פרמטר. עכשיו, גם HoverInteraction.Enter/Exit ו-FocusInteraction.Focus/Unfocus הועברו דרך אותו MutableInteractionSource, ואפשר להתאים אישית את שני סוגי האינטראקציה באותו מקום:

// This InteractionSource will emit hover and focus interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource)
        .focusable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

הערך Modifier.clickable גבוה יותר שהם פשוט מופשטים מהרמה hoverable ו-focusable — כדי שרכיב יהיה ניתן ללחוץ עליו, במרומז, ורכיבים שניתן ללחוץ עליהם להתמקד יותר. אפשר להשתמש ב-Modifier.clickable כדי ליצור רכיב מטפל באינטראקציות של העברת עכבר, התמקדות ולחיצה, בלי לשלב נמוך יותר ממשקי API ברמה. אם ברצונך להפוך גם את הרכיב שלך לניתן ללחיצה, מחליפים את hoverable ואת focusable ב-clickable:

// This InteractionSource will emit hover, focus, and press interactions
val interactionSource = remember { MutableInteractionSource() }
Box(
    Modifier
        .size(100.dp)
        .clickable(
            onClick = {},
            interactionSource = interactionSource,

            // Also show a ripple effect
            indication = ripple()
        ),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

עבודה עם InteractionSource

אם אתם צריכים מידע ברמה נמוכה על אינטראקציות עם רכיב, תוכלו: להשתמש ב-flow APIs רגילים ל-InteractionSource של הרכיב הזה. לדוגמה, נניח שברצונך לשמור רשימה של לחיצה ולגרור אינטראקציות עבור InteractionSource. הקוד הזה מבצע חצי מהעבודה, הלחיצות החדשות לרשימה כשהן מגיעות:

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

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

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

עכשיו, אם רוצים לדעת אם מתבצעת כרגע לחיצה או גרירה של הרכיב, כל מה שצריך לעשות הוא לבדוק אם השדה interactions ריק:

val isPressedOrDragged = interactions.isNotEmpty()

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

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

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

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

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

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

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

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

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

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

כדי לעשות את זה, צריך ליצור תוכן קומפוזבילי בהתאמה אישית שמבוסס על Button, ולבקש פרמטר icon נוסף כדי לצייר את הסמל (במקרה הזה, עגלת קניות). שלך קוראים לפונקציה collectIsPressedAsState() כדי לבדוק אם המשתמש מעביר את העכבר מעל לחצן; כשהם יצטרפו, תוכלו להוסיף את הסמל. הקוד נראה כך:

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource? = null
) {
    val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false

    Button(
        onClick = onClick,
        modifier = modifier,
        interactionSource = interactionSource
    ) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

כך נראה השימוש בתוכן הקומפוזבילי החדש:

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

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

אפשר ליצור ולהחיל אפקט מותאם אישית לשימוש חוזר עם Indication

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

אם אתם בונים מערכת עיצוב כזו, מתאימים אישית רכיב אחד שימוש חוזר בהתאמה אישית הזו לרכיבים אחרים עשוי להיות קשה מהסיבות הבאות:

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

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

  • IndicationNodeFactory: מפעל שיוצר מכונות Modifier.Node לעבד אפקטים חזותיים עבור רכיב. לאפליקציות פשוטות יותר שלא משתנה בין רכיבים שונים, הוא יכול להיות אובייקט יחיד (SSO) ולהשתמש בו מחדש את כל האפליקציה.

    המופעים האלה יכולים להיות עם שמירת מצב או ללא שמירת מצב. מאחר שהם נוצרים הם יכולים לאחזר ערכים מ-CompositionLocal כדי לשנות את האופן שבו הם מופיעים או פועלים בתוך רכיב מסוים, כמו בכל רכיב אחר Modifier.Node.

  • Modifier.indication: מקש צירוף שמושך את Indication עבור לרכיב הזה. Modifier.clickable ועוד מגבילי אינטראקציה ברמה גבוהה מקבלים ישירות פרמטר של אינדיקציה, כך שהם לא רק פולטים Interaction, אבל יכול גם ליצור אפקטים חזותיים לInteraction emit. לכן, במקרים פשוטים, אפשר פשוט להשתמש ב-Modifier.clickable נדרשות Modifier.indication.

החלפת האפקט בIndication

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

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

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale")

Button(
    modifier = Modifier.scale(scale),
    onClick = { },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

כדי להמיר את אפקט ההתאמה בקטע הקוד שלמעלה לIndication, פועלים לפי את השלבים הבאים:

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

    הצומת צריך להטמיע את הפרמטר DrawModifierNode כדי שהוא יוכל לשנות מברירת המחדל ContentDrawScope#draw(), ועבד אפקט של קנה מידה באמצעות אותו שרטוט פקודות כמו כל API אחר של גרפיקה ב-Compose.

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

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

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

    object ScaleIndication : IndicationNodeFactory {
        override fun create(interactionSource: InteractionSource): DelegatableNode {
            return ScaleNode(interactionSource)
        }
    
        override fun equals(other: Any?): Boolean = other === ScaleIndication
        override fun hashCode() = 100
    }

  3. Modifier.clickable משתמש ב-Modifier.indication באופן פנימי, ולכן כדי ליצור רכיב קליקבילי עם ScaleIndication, כל מה שצריך לעשות הוא לספק את Indication כפרמטר ל-clickable:

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    כך גם קל ליצור רכיבים ברמה גבוהה לשימוש חוזר באמצעות Indication — לחצן יכול להיראות כך:

    @Composable
    fun ScaleButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        shape: Shape = CircleShape,
        content: @Composable RowScope.() -> Unit
    ) {
        Row(
            modifier = modifier
                .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp)
                .clickable(
                    enabled = enabled,
                    indication = ScaleIndication,
                    interactionSource = interactionSource,
                    onClick = onClick
                )
                .border(width = 2.dp, color = Color.Blue, shape = shape)
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }

לאחר מכן אפשר להשתמש בלחצן בדברים הבאים:

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

אנימציה של לחצן עם סמל של עגלת קניות שמצטמצם כשלוחצים על הלחצן
איור 4. לחצן שנוצר באמצעות Indication בהתאמה אישית.

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

Indication לא מוגבל רק להשפעות של טרנספורמציה, כמו התאמה לעומס (scaling) לרכיב הזה. מכיוון ש-IndicationNodeFactory מחזירה Modifier.Node, אפשר לצייר כל סוג של השפעה מעל או מתחת לתוכן כמו במקרה של ממשקי API אחרים לשרטוט. עבור ניתן לצייר גבול מונפש מסביב לרכיב ושכבת-על העליון של הרכיב כשלוחצים עליו:

לחצן עם אפקט של קשת צבעונית בלחיצה
איור 5. אפקט גבול מונפש של Indication.

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

data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory {

    override fun create(interactionSource: InteractionSource): DelegatableNode {
        return NeonNode(
            shape,
            // Double the border size for a stronger press effect
            borderWidth * 2,
            interactionSource
        )
    }
}

גם ההטמעה של Modifier.Node זהה מבחינה רעיונית, גם אם מורכב יותר. כמו קודם, ב-InteractionSource נמדדת כאשר הוא מצורף, מפעיל אנימציות, ומיישם את DrawModifierNode כדי לצייר ההשפעה על התוכן:

private class NeonNode(
    private val shape: Shape,
    private val borderWidth: Dp,
    private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
    var currentPressPosition: Offset = Offset.Zero
    val animatedProgress = Animatable(0f)
    val animatedPressAlpha = Animatable(1f)

    var pressedAnimation: Job? = null
    var restingAnimation: Job? = null

    private suspend fun animateToPressed(pressPosition: Offset) {
        // Finish any existing animations, in case of a new press while we are still showing
        // an animation for a previous one
        restingAnimation?.cancel()
        pressedAnimation?.cancel()
        pressedAnimation = coroutineScope.launch {
            currentPressPosition = pressPosition
            animatedPressAlpha.snapTo(1f)
            animatedProgress.snapTo(0f)
            animatedProgress.animateTo(1f, tween(450))
        }
    }

    private fun animateToResting() {
        restingAnimation = coroutineScope.launch {
            // Wait for the existing press animation to finish if it is still ongoing
            pressedAnimation?.join()
            animatedPressAlpha.animateTo(0f, tween(250))
            animatedProgress.snapTo(0f)
        }
    }

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                    is PressInteraction.Release -> animateToResting()
                    is PressInteraction.Cancel -> animateToResting()
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition(
            currentPressPosition, size
        )
        val brush = animateBrush(
            startPosition = startPosition,
            endPosition = endPosition,
            progress = animatedProgress.value
        )
        val alpha = animatedPressAlpha.value

        drawContent()

        val outline = shape.createOutline(size, layoutDirection, this)
        // Draw overlay on top of content
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha * 0.1f
        )
        // Draw border on top of overlay
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha,
            style = Stroke(width = borderWidth.toPx())
        )
    }

    /**
     * Calculates a gradient start / end where start is the point on the bounding rectangle of
     * size [size] that intercepts with the line drawn from the center to [pressPosition],
     * and end is the intercept on the opposite end of that line.
     */
    private fun calculateGradientStartAndEndFromPressPosition(
        pressPosition: Offset,
        size: Size
    ): Pair<Offset, Offset> {
        // Convert to offset from the center
        val offset = pressPosition - size.center
        // y = mx + c, c is 0, so just test for x and y to see where the intercept is
        val gradient = offset.y / offset.x
        // We are starting from the center, so halve the width and height - convert the sign
        // to match the offset
        val width = (size.width / 2f) * sign(offset.x)
        val height = (size.height / 2f) * sign(offset.y)
        val x = height / gradient
        val y = gradient * width

        // Figure out which intercept lies within bounds
        val intercept = if (abs(y) <= abs(height)) {
            Offset(width, y)
        } else {
            Offset(x, height)
        }

        // Convert back to offsets from 0,0
        val start = intercept + size.center
        val end = Offset(size.width - start.x, size.height - start.y)
        return start to end
    }

    private fun animateBrush(
        startPosition: Offset,
        endPosition: Offset,
        progress: Float
    ): Brush {
        if (progress == 0f) return TransparentBrush

        // This is *expensive* - we are doing a lot of allocations on each animation frame. To
        // recreate a similar effect in a performant way, it would be better to create one large
        // gradient and translate it on each frame, instead of creating a whole new gradient
        // and shader. The current approach will be janky!
        val colorStops = buildList {
            when {
                progress < 1 / 6f -> {
                    val adjustedProgress = progress * 6f
                    add(0f to Blue)
                    add(adjustedProgress to Color.Transparent)
                }
                progress < 2 / 6f -> {
                    val adjustedProgress = (progress - 1 / 6f) * 6f
                    add(0f to Purple)
                    add(adjustedProgress * MaxBlueStop to Blue)
                    add(adjustedProgress to Blue)
                    add(1f to Color.Transparent)
                }
                progress < 3 / 6f -> {
                    val adjustedProgress = (progress - 2 / 6f) * 6f
                    add(0f to Pink)
                    add(adjustedProgress * MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 4 / 6f -> {
                    val adjustedProgress = (progress - 3 / 6f) * 6f
                    add(0f to Orange)
                    add(adjustedProgress * MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 5 / 6f -> {
                    val adjustedProgress = (progress - 4 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                else -> {
                    val adjustedProgress = (progress - 5 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxYellowStop to Yellow)
                    add(MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
            }
        }

        return linearGradient(
            colorStops = colorStops.toTypedArray(),
            start = startPosition,
            end = endPosition
        )
    }

    companion object {
        val TransparentBrush = SolidColor(Color.Transparent)
        val Blue = Color(0xFF30C0D8)
        val Purple = Color(0xFF7848A8)
        val Pink = Color(0xFFF03078)
        val Orange = Color(0xFFF07800)
        val Yellow = Color(0xFFF0D800)
        const val MaxYellowStop = 0.16f
        const val MaxOrangeStop = 0.33f
        const val MaxPinkStop = 0.5f
        const val MaxPurpleStop = 0.67f
        const val MaxBlueStop = 0.83f
    }
}

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