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

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

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

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

אינטראקציות

במקרים רבים, לא צריך לדעת איך רכיב ה-Compose מפרש את האינטראקציות של המשתמשים. לדוגמה, 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 מבוסס על Kotlin flows, כך שאפשר לאסוף ממנו את האינטראקציות באותה דרך שבה עובדים עם כל flow אחר. מידע נוסף על ההחלטה הזו בנוגע לעיצוב זמין בפוסט בבלוג Illuminating Interactions.

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

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

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(). ה-methods האלה הן למעשה methods נוחות שנבנו על בסיס ממשקי 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 {
    // ...
}

העיבוד הזה הוא producer – הוא יכול להשתמש ב-MutableInteractionSource שסופק כדי להפיק HoverInteractions כשמצביעים עליו או כשמפסיקים להצביע עליו.

יצירת רכיבים שצורכים ומפיקים

רכיבים ברמה גבוהה, כמו ButtonMaterial, משמשים גם כמפיקים וגם כצרכנים. הם מטפלים באירועי קלט ומיקוד, וגם משנים את המראה שלהם בתגובה לאירועים האלה, למשל מציגים אפקט אדווה או אנימציה של הגובה שלהם. כתוצאה מכך, הם חושפים ישירות את 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() */ }

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

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

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

כדי ליצור רכיב שמגיב לאירועי ריחוף, אפשר פשוט להשתמש ב-‎ Modifier.hoverable ולהעביר את MutableInteractionSource כפרמטר. בכל פעם שמעבירים את העכבר מעל הרכיב, הוא פולט HoverInteractions, ואפשר להשתמש בזה כדי לשנות את המראה של הרכיב.

// 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

אם אתם צריכים מידע ברמה נמוכה על אינטראקציות עם רכיב, אתם יכולים להשתמש בממשקי API רגילים של זרימת נתונים עבור 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()

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

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

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

שימו לב שהדוגמאות הקודמות בקטע הזה מייצגות את 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")
}

אם לחיצה מתחילה ומסתיימת באותו פריים, הטקסט אף פעם לא יוצג כ-"Pressed!‎". ברוב המקרים, זה לא משנה – הצגת אפקט ויזואלי למשך זמן קצר כל כך תגרום להבהוב, והמשתמש לא ישים לב לכך. במקרים מסוימים, כמו הצגת אפקט אדווה או אנימציה דומה, יכול להיות שתרצו להציג את האפקט למשך זמן מינימלי לפחות, במקום להפסיק אותו מיד אם כבר לא לוחצים על הלחצן. כדי לעשות את זה, אפשר להתחיל ולהפסיק אנימציות ישירות מתוך פונקציית ה-lambda של האיסוף, במקום לכתוב למצב. דוגמה לדפוס הזה מופיעה בקטע יצירת 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 החדש מבוסס על Material הקיים, ‏ Button הוא מגיב לאינטראקציות של משתמשים בכל הדרכים הרגילות. כשמשתמש לוחץ על הכפתור, רמת השקיפות שלו משתנה מעט, בדיוק כמו בכפתור Material רגיל Button.

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

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

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

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

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

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

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

  • Modifier.indication: משנה שיוצר Indication לרכיב. ‫Modifier.clickable ומאפיינים אחרים של אינטראקציות ברמה גבוהה מקבלים פרמטר של אינדיקציה ישירות, כך שהם לא רק פולטים Interaction, אלא גם יכולים ליצור אפקטים חזותיים לInteraction שהם פולטים. לכן, במקרים פשוטים, אפשר להשתמש רק ב-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, כך שצריך רק לקרוא לפונקציה הזו בתוך טרנספורמציה של קנה מידה. חשוב לוודא שההטמעות שלכם תמיד קוראות ל-drawContent() בשלב מסוים, אחרת הרכיב שאליו אתם מחילים את Indication לא יוצג.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 לא מוגבל רק לאפקטים של טרנספורמציה, כמו שינוי גודל של רכיב. מכיוון שהפונקציה 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 – אם מתבצעת לחיצה במהלך לחיצה קיימת או אנימציה במצב מנוחה, האנימציה הקודמת מבוטלת והאנימציה של הלחיצה מתחילה מההתחלה. כדי לתמוך במספר אפקטים בו-זמניים (למשל, באפקט של אדוות, שבו אנימציה חדשה של אדווה מצוירת מעל אדוות אחרות), אפשר לעקוב אחרי האנימציות ברשימה, במקום לבטל אנימציות קיימות ולהתחיל אנימציות חדשות.