רכיבי ממשק המשתמש מספקים משוב למשתמש המכשיר בדרך שבה הוא להגיב לאינטראקציות של המשתמשים. לכל רכיב יש דרך משלו להגיב אינטראקציה, שעוזרת למשתמשים לדעת מה האינטראקציות שלהם עושות. עבור לדוגמה, אם משתמש נוגע בלחצן במסך המגע של מכשיר, הלחצן שישתנו באופן מסוים, למשל על ידי הוספת צבע הדגשה. השינוי הזה מודיע למשתמש שהוא נגע בלחצן. אם המשתמש לא רצה לעשות שהם צריכים לדעת לגרור את האצבע מהלחצן לפני כן משחררים – אחרת הלחצן יופעל.
מסמכי התיעוד של תנועות לכתיבה מכסה איך רכיבי הרכבה מטפלים באירוע של מצביע ברמה נמוכה, כמו תנועות של מצביע ו קליקים. מחוץ לתיבה, נסחו תקציר של האירועים ברמה נמוכה כך אינטראקציות ברמה גבוהה יותר – לדוגמה, סדרה של אירועי מצביע עשויה להסתכם לוחצים על הלחצן ומשחררים. הבנת ההפשטות שברמה גבוהה יותר יכולה עוזרות לכם להתאים אישית את התגובה של ממשק המשתמש למשתמש. לדוגמה, ייתכן שתרצו כדי להתאים אישית את האופן שבו המראה של רכיב משתנה כאשר המשתמש מקיים אינטראקציה עם אותו, או שאולי פשוט תרצו לנהל יומן של הפעולות האלה מצד המשתמשים. הזה מספק לכם את המידע הדרוש כדי לשנות את הרכיבים הסטנדרטיים של ממשק המשתמש, או לעצב משלכם.
אינטראקציות
במקרים רבים, לא צריך לדעת מה בדיוק רכיב הכתיבה שלך
פירוש אינטראקציות של משתמשים. לדוגמה, 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
מתקדם עם גבול מונפש.
דוגמה: פיתוח רכיב עם טיפול באינטראקציות בהתאמה אישית
כדי לראות איך אפשר ליצור רכיבים עם תגובה מותאמת אישית לקלט, דוגמה ללחצן ששונה. במקרה כזה, נניח שאתם רוצים לחצן מגיב ללחיצות על ידי שינוי המראה שלו:
כדי לעשות את זה, צריך ליצור תוכן קומפוזבילי בהתאמה אישית שמבוסס על 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
, פועלים לפי
את השלבים הבאים:
יוצרים את
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
. באחריותו היחידה ליצור מופע של צומת חדש עבור מקור אינטראקציה שצוין. מאחר שאין הפרמטרים להגדרת האינדיקציה, המפעל יכול להיות אובייקט: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
לא מוגבל רק להשפעות של טרנספורמציה, כמו התאמה לעומס (scaling)
לרכיב הזה. מכיוון ש-IndicationNodeFactory
מחזירה Modifier.Node
, אפשר לצייר
כל סוג של השפעה מעל או מתחת לתוכן כמו במקרה של ממשקי API אחרים לשרטוט. עבור
ניתן לצייר גבול מונפש מסביב לרכיב ושכבת-על
העליון של הרכיב כשלוחצים עליו:
ההטמעה של 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 פיתוח נייטיב
- רכיבים ופריסות של חומר לימוד