רכיבים של ממשק המשתמש מספקים משוב למשתמש במכשיר באמצעות התגובה שלהם לאינטראקציות של המשתמש. לכל רכיב יש דרך משלו להגיב לאינטראקציות, וכך המשתמשים יכולים לדעת מה האינטראקציות שלהם עושות. לדוגמה, אם משתמש נוגע בלחצן במסך המגע של המכשיר, הלחצן עשוי להשתנות בדרך כלשהי, למשל על ידי הוספת צבע הדגשה. השינוי הזה מאפשר למשתמש לדעת שהוא נגע בלחצן. אם המשתמש לא התכוון ללחוץ על הכפתור, הוא ידע לגרור את האצבע מהכפתור לפני שהוא מרים אותה. אחרת, הכפתור יופעל.
במסמכי התיעוד של Compose בנושא תנועות מוסבר איך רכיבי 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(), ב-Compose יש גם את collectIsFocusedAsState(), collectIsDraggedAsState() ו-collectIsHoveredAsState(). ה-methods האלה הן למעשה methods נוחות שמבוססות על ממשקי API של InteractionSource ברמה נמוכה יותר. במקרים מסוימים, יכול להיות שתרצו להשתמש ישירות בפונקציות האלה ברמה הנמוכה יותר.
לדוגמה, נניח שאתם צריכים לדעת אם לוחצים על כפתור, וגם אם גוררים אותו. אם משתמשים גם ב-collectIsPressedAsState() וגם ב-collectIsDraggedAsState(), Compose מבצע הרבה עבודה כפולה, ואין ערובה לכך שכל האינטראקציות יתקבלו בסדר הנכון. במצבים כאלה, כדאי לעבוד ישירות עם 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 של Material, פועלים גם כמפיקים וגם כצרכנים. הם מטפלים באירועי קלט ומיקוד, וגם משנים את המראה שלהם בתגובה לאירועים האלה, למשל הצגת אפקט אדווה או אנימציה של הגובה שלהם. כתוצאה מכך, הם חושפים ישירות את 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 כפרמטר באופן הזה. בנוסף לשיטות המומלצות להעלאת הרמה של מצב, זה גם מאפשר לקרוא ולשלוט בקלות במצב החזותי של רכיב, באותו אופן שבו אפשר לקרוא ולשלוט בכל סוג אחר של מצב (כמו מצב מופעל).
Compose פועל לפי גישה ארכיטקטונית שכבתית, כך שרכיבי 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
אם אתם צריכים מידע ברמה נמוכה על אינטראקציות עם רכיב, אתם יכולים להשתמש בממשקי 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 מחשבת את שכבת-העל המתאימה של המצב שבה צריך להשתמש לאינטראקציה האחרונה:
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") }
אם לחיצה מתחילה ומסתיימת באותו פריים, הטקסט "Pressed!" לא יוצג אף פעם. ברוב המקרים, זו לא בעיה – הצגת אפקט חזותי לפרק זמן קצר כל כך תגרום להבהוב, והמשתמש לא ממש יבחין בכך. במקרים מסוימים, כמו הצגת אפקט אדווה או אנימציה דומה, יכול להיות שתרצו להציג את האפקט למשך זמן מינימלי, במקום להפסיק מיד אם הלחצן כבר לא לחוץ. כדי לעשות את זה, אתם יכולים להפעיל ולהפסיק אנימציות ישירות מתוך פונקציית הלמבדה של האיסוף, במקום לכתוב למצב. יש דוגמה לתבנית הזו בקטע יצירת Indication מתקדם עם גבול מונפש.
דוגמה: יצירת רכיב עם טיפול מותאם אישית באינטראקציות
כדי להראות איך אפשר ליצור רכיבים עם תגובה מותאמת אישית לקלט, הנה דוגמה ללחצן ששונה. במקרה הזה, נניח שאתם רוצים כפתור שמגיב ללחיצות על ידי שינוי המראה שלו:
כדי לעשות את זה, יוצרים פונקציה קומפוזבילית מותאמת אישית שמבוססת על 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() } }
כך נראה השימוש ברכיב ה-composable החדש:
PressIconButton( onClick = {}, icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) }, text = { Text("Add to cart") } )
מכיוון שה-PressIconButton החדש הזה מבוסס על ה-Material Button הקיים, הוא מגיב לאינטראקציות של המשתמשים בכל הדרכים הרגילות. כשהמשתמש לוחץ על הכפתור, האטימות שלו משתנה מעט, בדיוק כמו Button רגיל.
איך יוצרים אפקט מותאם אישית לשימוש חוזר באמצעות Indication ומחילים אותו
בקטעים הקודמים למדתם איך לשנות חלק מרכיב בתגובה לInteractionים שונים, למשל להציג סמל כשלוחצים עליו. אפשר להשתמש באותה גישה כדי לשנות את הערך של הפרמטרים שאתם מספקים לרכיב, או לשנות את התוכן שמוצג בתוך רכיב, אבל זה רלוונטי רק לכל רכיב בנפרד. הרבה פעמים, לאפליקציה או למערכת עיצוב יש מערכת גנרית לאפקטים חזותיים עם שמירת מצב – אפקט שצריך להחיל על כל הרכיבים באופן עקבי.
אם אתם יוצרים מערכת עיצוב כזו, יכול להיות שיהיה קשה להתאים אישית רכיב אחד ולהשתמש בהתאמה האישית הזו ברכיבים אחרים, מהסיבות הבאות:
- כל רכיב במערכת העיצוב צריך את אותו קוד סטנדרטי
- קל לשכוח להחיל את האפקט הזה על רכיבים חדשים ורכיבים מותאמים אישית שניתן ללחוץ עליהם.
- יכול להיות שיהיה קשה לשלב את האפקט המותאם אישית עם אפקטים אחרים
כדי להימנע מהבעיות האלה ולהרחיב בקלות רכיב בהתאמה אישית בכל המערכת, אתם יכולים להשתמש ב-Indication.
Indication מייצג אפקט חזותי שאפשר להשתמש בו שוב ושוב ברכיבים שונים באפליקציה או במערכת העיצוב. הסרטון Indication מחולק לשני חלקים:
IndicationNodeFactory: פונקציית factory שיוצרת מופעים שלModifier.Nodeשמעבדים אפקטים חזותיים לרכיב. בהטמעות פשוטות יותר שלא משתנות בין רכיבים, אפשר להשתמש ב-singleton (אובייקט) ולעשות בו שימוש חוזר בכל האפליקציה.המופעים האלה יכולים להיות עם שמירת מצב (stateful) או ללא שמירת מצב (stateless). הם נוצרים לכל רכיב, ולכן הם יכולים לאחזר ערכים מ-
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, פועלים לפי השלבים הבאים:
יוצרים את
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() } } }
יוצרים את
IndicationNodeFactory. התפקיד היחיד שלו הוא ליצור מופע צומת חדש למקור אינטראקציה שסופק. מכיוון שאין פרמטרים להגדרת האינדיקציה, ה-factory יכול להיות אובייקט:object ScaleIndication : IndicationNodeFactory { override fun create(interactionSource: InteractionSource): DelegatableNode { return ScaleNode(interactionSource) } override fun equals(other: Any?): Boolean = other === ScaleIndication override fun hashCode() = 100 }
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!") }
Indication בהתאמה אישית.יצירת Indication מתקדם עם גבול מונפש
Indication לא מוגבל רק לאפקטים של טרנספורמציה, כמו שינוי קנה מידה של רכיב. מכיוון שהפונקציה IndicationNodeFactory מחזירה Modifier.Node, אפשר לצייר כל סוג של אפקט מעל או מתחת לתוכן, כמו בממשקי API אחרים לציור. לדוגמה, אפשר לצייר גבול עם אנימציה מסביב לרכיב ושכבת-על מעל הרכיב כשלוחצים עליו:
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 – אם מתרחשת לחיצה במהלך לחיצה קיימת או אנימציית מנוחה, האנימציה הקודמת מבוטלת, ואנימציית הלחיצה מתחילה מההתחלה. כדי לתמוך בכמה אפקטים בו-זמנית (למשל, עם אדוות, שבהן אנימציית אדווה חדשה תצויר מעל אדוות אחרות), אפשר לעקוב אחרי האנימציות ברשימה, במקום לבטל אנימציות קיימות ולהתחיל אנימציות חדשות.
מומלץ בשבילך
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- הסבר על תנועות
- Kotlin ל-Jetpack Compose
- רכיבים ופריסות של Material