יש כמה מונחים ומושגים שחשוב להבין כשעובדים על טיפול בתנועות באפליקציה. בדף הזה מוסברים המונחים "מצביעים", "אירועי מצביע" ו"תנועות", ומוצגות רמות ההפשטה השונות של תנועות. בנוסף, הוא מתעמק בצריכה ובהפצה של אירועים.
הגדרות
כדי להבין את המושגים השונים בדף הזה, צריך להבין חלק מהמונחים שבהם משתמשים:
- מצביע: אובייקט פיזי שמאפשר לכם לבצע פעולות באפליקציה.
במכשירים ניידים, הסמן הנפוץ ביותר הוא האצבע שלכם שנוגעת במסך המגע. אפשר גם להשתמש בסטיילוס במקום באצבע.
במסכים גדולים, אפשר להשתמש בעכבר או במשטח מגע כדי לבצע פעולות במסך באופן עקיף. מכשיר לקליטת נתונים חייב להיות מסוגל "להצביע" על קואורדינטה כדי להיחשב כמצביע, ולכן מקלדת, למשל, לא יכולה להיחשב כמצביע. ב-Compose, סוג הסמן נכלל בשינויים של הסמן באמצעות
PointerType. - אירוע של סמן: מתאר אינטראקציה ברמה נמוכה של סמן אחד או יותר עם האפליקציה בזמן נתון. כל אינטראקציה עם הסמן, כמו הנחת אצבע על המסך או גרירת העכבר, תפעיל אירוע. ב-Compose, כל המידע הרלוונטי לאירוע כזה נכלל בכיתה
PointerEvent. - תנועה: רצף של אירועי סמן שאפשר לפרש כפעולה אחת. לדוגמה, תנועת נגיעה יכולה להיחשב כרצף של אירוע לחיצה ואחריו אירוע שחרור. יש תנועות נפוצות שמשמשות באפליקציות רבות, כמו לחיצה, גרירה או שינוי צורה, אבל אפשר גם ליצור תנועה מותאמת אישית לפי הצורך.
רמות שונות של הפשטה
ב-Jetpack Compose יש רמות שונות של הפשטה לטיפול בתנועות.
ברמה העליונה נמצאת התמיכה ברכיבים. רכיבים קומפוזביליים כמו Button כוללים תמיכה בתנועות באופן אוטומטי. כדי להוסיף תמיכה בתנועות לרכיבים בהתאמה אישית, אפשר להוסיף משתני תנועות כמו clickable לפונקציות קומפוזביליות שרירותיות. לבסוף, אם אתם צריכים תנועה בהתאמה אישית, אתם יכולים להשתמש במשנה pointerInput.
ככלל, כדאי לבנות ברמת ההפשטה הגבוהה ביותר שמציעה את הפונקציונליות שאתם צריכים. כך תוכלו ליהנות מהשיטות המומלצות שכלולות בשכבה. לדוגמה, Button מכיל יותר מידע סמנטי, שמשמש לנגישות, מאשר clickable, שמכיל יותר מידע מאשר הטמעה גולמית של pointerInput.
תמיכה ברכיבים
הרבה רכיבים מוכנים לשימוש ב-Compose כוללים סוג כלשהו של טיפול פנימי בתנועות. לדוגמה, LazyColumn מגיב לתנועות גרירה על ידי גלילה בתוכן שלו, Button מציג אדווה כשלוחצים עליו, והרכיב SwipeToDismiss מכיל לוגיקה של החלקה כדי לסגור רכיב. הטיפול הזה בתנועות פועל באופן אוטומטי.
בנוסף לטיפול הפנימי בתנועות, רכיבים רבים דורשים גם מהמתקשר לטפל בתנועה. לדוגמה, Button מזהה באופן אוטומטי לחיצות ומפעיל אירוע מסוג קליק. מעבירים למבנה Button פונקציית למבדה מסוג onClick כדי להגיב לתנועה. באופן דומה, מוסיפים פונקציית למבדה של onValueChange ל-Slider כדי להגיב לפעולת הגרירה של המשתמש בידית של פס ההזזה.
אם זה מתאים לתרחיש לדוגמה שלכם, עדיף להשתמש בתנועות שכלולות ברכיבים, כי הן כוללות תמיכה מוכנה לשימוש במיקוד ובנגישות, והן נבדקו היטב. לדוגמה, Button מסומן באופן מיוחד כדי ששירותי הנגישות יתארו אותו כלחצן, ולא כרכיב כלשהו שניתן ללחוץ עליו:
// Talkback: "Click me!, Button, double tap to activate" Button(onClick = { /* TODO */ }) { Text("Click me!") } // Talkback: "Click me!, double tap to activate" Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }
מידע נוסף על נגישות ב-Compose זמין במאמר נגישות ב-Compose.
הוספת תנועות ספציפיות לרכיבים קומפוזביליים שרירותיים באמצעות מגבילים
אפשר להחיל משתני שינוי של תנועות על כל רכיב Composable שרירותי כדי שהרכיב יאזין לתנועות. לדוגמה, אפשר לאפשר ל-Box כללי לטפל בתנועות הקשה על ידי הפיכתו ל-clickable, או לאפשר ל-Column לטפל בגלילה אנכית על ידי החלת verticalScroll.
יש הרבה מגדירי שינוי לטיפול בסוגים שונים של תנועות:
- טיפול בהקשות ובלחיצות באמצעות המשתנים
clickable,combinedClickable,selectable,toggleableו-triStateToggleable. - מטפלים בגלילה באמצעות פונקציות השינוי
horizontalScroll,verticalScrollופונקציות שינוי כלליות יותר כמוscrollable. - גרירת נקודות אחיזה באמצעות מקש הצירוף
draggableו-swipeable. - טיפול בתנועות עם מגע במספר נקודות כמו הזזה, סיבוב ושינוי מרחק התצוגה, באמצעות המשנה
transformable.
ככלל, עדיף להשתמש בשינויי תנועות מוכנים מראש ולא בטיפול מותאם אישית בתנועות.
המשתנים מוסיפים עוד פונקציונליות מעבר לטיפול באירועי הסמן בלבד.
לדוגמה, המשנה clickable לא רק מוסיף זיהוי של לחיצות ונגיעות, אלא גם מוסיף מידע סמנטי, אינדיקציות חזותיות לאינטראקציות, העברת העכבר, מיקוד ותמיכה במקלדת. אתם יכולים לבדוק את קוד המקור של clickable כדי לראות איך הפונקציונליות מתווספת.
הוספת תנועה מותאמת אישית לרכיבים קומפוזביליים שרירותיים באמצעות המשנה pointerInput
לא כל תנועה מיושמת באמצעות משנה תנועה מוכן מראש. לדוגמה, אי אפשר להשתמש במקש צירוף כדי להגיב לגרירה אחרי לחיצה ארוכה, ללחיצה על מקש Control או ללחיצה בשלוש אצבעות. במקום זאת, אתם יכולים לכתוב פונקציה משלכם לטיפול בתנועות כדי לזהות את התנועות בהתאמה אישית. אתם יכולים ליצור handler של תנועות באמצעות המשתנה pointerInput, שנותן לכם גישה לאירועים של המצביע הגולמי.
הקוד הבא מקשיב לאירועי מצביע גולמיים:
@Composable private fun LogPointerEvents(filter: PointerEventType? = null) { var log by remember { mutableStateOf("") } Column { Text(log) Box( Modifier .size(100.dp) .background(Color.Red) .pointerInput(filter) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() // handle pointer event if (filter == null || event.type == filter) { log = "${event.type}, ${event.changes.first().position}" } } } } ) } }
אם נפרק את קטע הקוד הזה, הרכיבים העיקריים הם:
- המשתנה
pointerInput. מעבירים לפונקציה מפתח אחד או יותר. כשהערך של אחד מהמפתחות האלה משתנה, פונקציית הלמבדה של תוכן ה-modifier מופעלת מחדש. הדוגמה מעבירה מסנן אופציונלי לפונקציה הקומפוזבילית. אם הערך של המסנן הזה משתנה, צריך להפעיל מחדש את הגורם המטפל באירועים של המצביע כדי לוודא שהאירועים הנכונים מתועדים ביומן. -
awaitPointerEventScopeיוצר היקף של פונקציית coroutine שאפשר להשתמש בו כדי להמתין לאירועי סמן. -
awaitPointerEventמשהה את ה-coroutine עד שמתרחש אירוע המצביע הבא.
למרות שהאזנה לאירועי קלט גולמיים היא כלי רב עוצמה, כתיבת תנועה מותאמת אישית על סמך הנתונים הגולמיים האלה היא גם מורכבת. כדי לפשט את היצירה של תנועות בהתאמה אישית, יש הרבה שיטות זמינות.
זיהוי תנועות מלאות
במקום לטפל באירועי המצביע הגולמיים, אתם יכולים להאזין לתנועות ספציפיות שמתרחשות ולהגיב בהתאם. ב-AwaitPointerEventScope יש שיטות להאזנה לאירועים הבאים:
- לחיצה, לחיצה קצרה, לחיצה כפולה ולחיצה ארוכה:
detectTapGestures - גרירות:
detectHorizontalDragGestures,detectVerticalDragGestures,detectDragGesturesו-detectDragGesturesAfterLongPress - טרנספורמציות:
detectTransformGestures
אלה גלאים ברמה העליונה, ולכן אי אפשר להוסיף כמה גלאים בתוך משתנה pointerInput אחד. קטע הקוד הבא מזהה רק את הלחיצות, ולא את הגרירות:
var log by remember { mutableStateOf("") } Column { Text(log) Box( Modifier .size(100.dp) .background(Color.Red) .pointerInput(Unit) { detectTapGestures { log = "Tap!" } // Never reached detectDragGestures { _, _ -> log = "Dragging" } } ) }
באופן פנימי, השיטה detectTapGestures חוסמת את ה-coroutine, והגלאי השני לא מופעל אף פעם. אם אתם צריכים להוסיף יותר ממאזין אחד לתנועות לפונקציה קומפוזבילית, השתמשו במכונות נפרדות של משתנה ה-modifier pointerInput:
var log by remember { mutableStateOf("") } Column { Text(log) Box( Modifier .size(100.dp) .background(Color.Red) .pointerInput(Unit) { detectTapGestures { log = "Tap!" } } .pointerInput(Unit) { // These drag events will correctly be triggered detectDragGestures { _, _ -> log = "Dragging" } } ) }
טיפול באירועים לכל תנועה
בהגדרה, תנועות מתחילות באירוע של לחיצה על המסך. אתם יכולים להשתמש ב-helper method awaitEachGesture במקום בלולאת while(true) שעוברת דרך כל אירוע גולמי. ה-method awaitEachGesture מפעילה מחדש את הבלוק שמכיל את כל הסמנים כשהם מורמים, מה שמציין שהתנועה הושלמה:
@Composable private fun SimpleClickable(onClick: () -> Unit) { Box( Modifier .size(100.dp) .pointerInput(onClick) { awaitEachGesture { awaitFirstDown().also { it.consume() } val up = waitForUpOrCancellation() if (up != null) { up.consume() onClick() } } } ) }
בפועל, כמעט תמיד כדאי להשתמש ב-awaitEachGesture, אלא אם אתם מגיבים לאירועי סמן בלי לזהות תנועות. דוגמה לכך היא hoverable, שלא מגיב לאירועים של לחיצה או שחרור של הסמן – הוא רק צריך לדעת מתי הסמן נכנס לגבולות שלו או יוצא מהם.
המתנה לאירוע או לתנועת משנה ספציפיים
יש קבוצה של שיטות שעוזרות לזהות חלקים נפוצים של תנועות:
- השהיה עד שסמן יורד באמצעות
awaitFirstDown, או המתנה עד שכל הסמנים יעלו באמצעותwaitForUpOrCancellation. - יוצרים מאזין גרירה ברמה נמוכה באמצעות
awaitTouchSlopOrCancellationו-awaitDragOrCancellation. ה-gesture handler מושהה עד שהסמן מגיע ל-touch slop, ואז מושהה עד שמגיע אירוע הגרירה הראשון. אם אתם רוצים לגרור רק לאורך ציר אחד, השתמשו במקום זאת ב-awaitHorizontalTouchSlopOrCancellationוב-awaitHorizontalDragOrCancellation, או ב-awaitVerticalTouchSlopOrCancellationוב-awaitVerticalDragOrCancellation. - השהיה עד ללחיצה ארוכה באמצעות
awaitLongPressOrCancellation. - משתמשים ב-
dragכדי להאזין לאירועי גרירה באופן רציף, או ב-horizontalDragאו ב-verticalDragכדי להאזין לאירועי גרירה בציר אחד.
החלת חישובים על אירועים עם כמה נקודות מגע
כשמשתמש מבצע תנועה עם מגע במספר נקודות באמצעות יותר מסמן אחד, קשה להבין את השינוי הנדרש על סמך הערכים הגולמיים.
אם המשנה transformable או השיטות detectTransformGestures לא מספקים שליטה מספיק מדויקת לתרחיש לדוגמה שלכם, אתם יכולים להאזין לאירועים הגולמיים ולהחיל עליהם חישובים. ה-methods האלה הן calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation ו-calculateZoom.
שליחת אירועים ובדיקה של מיקום המצביע
לא כל אירוע של סמן נשלח לכל משתנה של pointerInput. שליחת האירועים פועלת באופן הבא:
- אירועי הסמן נשלחים להיררכיה של רכיבים קומפוזביליים. ברגע שמצביע חדש מפעיל את אירוע המצביע הראשון שלו, המערכת מתחילה לבצע בדיקה של מיקום המצביע של הרכיבים הניתנים לקישור ש "עומדים בדרישות". רכיב Composable נחשב כשיר כשיש לו יכולות טיפול בקלט של מצביע. הבדיקה של מיקום המצביע מתבצעת מלמעלה למטה בעץ של ממשק המשתמש. רכיב Composable "נפגע" כשאירוע המצביע מתרחש בתוך הגבולות של הרכיב הזה. התהליך הזה יוצר שרשרת של רכיבים קומפוזביליים שעוברים בדיקה של מיקום המצביע בהצלחה.
- כברירת מחדל, כשיש כמה רכיבים קומפוזביליים שעומדים בדרישות באותה רמה של העץ, רק הרכיב הקומפוזבילי עם ה-z-index הגבוה ביותר "נפגע". לדוגמה, כשמוסיפים שני רכיבי
Buttonחופפים ל-Box, רק הרכיב שמשורטט למעלה מקבל אירועי סמן. באופן תיאורטי, אפשר לשנות את ההתנהגות הזו על ידי יצירת הטמעה משלכם שלPointerInputModifierNodeוהגדרתsharePointerInputWithSiblingsכ-true. - אירועים נוספים של אותו מצביע נשלחים לאותה שרשרת של רכיבים קומפוזביליים, ומועברים לפי הלוגיקה של הפצת האירועים. המערכת לא מבצעת יותר בדיקות של מיקום המצביע הזה. כלומר, כל רכיב Composable בשרשרת מקבל את כל האירועים של הסמן הזה, גם כשהם מתרחשים מחוץ לגבולות של אותו רכיב Composable. רכיבים קומפוזביליים שלא נמצאים בשרשרת אף פעם לא מקבלים אירועי סמן, גם כשהסמן נמצא בתוך הגבולות שלהם.
אירועי העברת העכבר, שמופעלים על ידי העברת העכבר או הסטיילוס, הם חריגים לכללים שמוגדרים כאן. אירועי העברת העכבר נשלחים לכל רכיב Composable שהם פוגעים בו. לכן, כשמשתמש מעביר את הסמן מהגבולות של רכיב Composable אחד לרכיב הבא, במקום לשלוח את האירועים לרכיב ה-Composable הראשון, האירועים נשלחים לרכיב ה-Composable החדש.
צריכת אירועים
אם ליותר מרכיב קומפוזבילי אחד מוקצה handler של תנועות, ה-handlers האלה לא אמורים להתנגש. לדוגמה, בממשק המשתמש הזה:
כשמשתמש לוחץ על כפתור הסימנייה, פונקציית הלמבדה onClick של הכפתור מטפלת בתנועה הזו. כשמשתמש מקיש על כל חלק אחר של הפריט ברשימה, ה-ListItem
מטפל בתנועה הזו ומנווט למאמר. מבחינת קלט המצביע, הכפתור צריך לצרוך את האירוע הזה, כדי שהרכיב הראשי שלו יידע לא להגיב לו יותר. תנועות שכלולות ברכיבים מוכנים לשימוש ומשתני התנועות הנפוצים כוללים את התנהגות הצריכה הזו, אבל אם אתם כותבים תנועה מותאמת אישית משלכם, אתם צריכים לצרוך אירועים באופן ידני. כדי לעשות את זה, משתמשים ב-method PointerInputChange.consume:
Modifier.pointerInput(Unit) { awaitEachGesture { while (true) { val event = awaitPointerEvent() // consume all changes event.changes.forEach { it.consume() } } } }
צריכת אירוע לא מפסיקה את הפצת האירוע לרכיבים קומפוזביליים אחרים. במקום זאת, פונקציה קומפוזבילית צריכה להתעלם במפורש מאירועים שנצרכו. כשכותבים תנועות בהתאמה אישית, צריך לבדוק אם אירוע כבר נצרך על ידי רכיב אחר:
Modifier.pointerInput(Unit) { awaitEachGesture { while (true) { val event = awaitPointerEvent() if (event.changes.any { it.isConsumed }) { // A pointer is consumed by another gesture handler } else { // Handle unconsumed event } } } }
הפצת אירועים
כמו שציינו קודם, שינויים בסמן מועברים לכל רכיב Composable שהוא פוגע בו.
אבל אם יש יותר מרכיב קומפוזבילי אחד כזה, באיזה סדר האירועים מופצים? אם ניקח את הדוגמה מהקטע הקודם, ממשק המשתמש הזה מתורגם לעץ ממשק המשתמש הבא, שבו רק ListItem ו-Button מגיבים לאירועי סמן:
אירועי הסמן עוברים דרך כל אחד מהרכיבים האלה של Composable שלוש פעמים, במהלך שלושה "מעברים":
- במעבר הראשוני, האירוע עובר מהחלק העליון של עץ ממשק המשתמש לחלק התחתון. התהליך הזה מאפשר להורה ליירט אירוע לפני שהילד יכול לצרוך אותו. לדוגמה, הסברים קצרים צריכים ליירט לחיצה ארוכה במקום להעביר אותה לצאצאים שלהם. בדוגמה שלנו,
ListItemמקבל את האירוע לפניButton. - ב-Main pass, האירוע עובר מהצמתים הנמוכים ביותר בעץ ממשק המשתמש עד לרמה הבסיסית (root) של עץ ממשק המשתמש. בשלב הזה בדרך כלל צורכים את התנועות, וזהו השלב שמוגדר כברירת מחדל כשמאזינים לאירועים. טיפול בתנועות בשלב הזה אומר שצומתי עלה מקבלים עדיפות על פני צומתי הורה, וזו ההתנהגות הכי הגיונית לרוב התנועות. בדוגמה שלנו, ה-
Buttonמקבל את האירוע לפני ה-ListItem. - במעבר הסופי, האירוע עובר פעם נוספת מהחלק העליון של עץ ממשק המשתמש אל צומתי העלים. התהליך הזה מאפשר לרכיבים שנמצאים גבוה יותר במחסנית להגיב לצריכת האירועים על ידי ההורה שלהם. לדוגמה, אם לוחצים על כפתור ואז גוררים את הרכיב הראשי שניתן לגלילה, האפקט של הגלים נעלם.
אפשר להציג את רצף האירועים באופן חזותי כך:
אחרי ששינוי בקלט נצרך, המידע הזה מועבר מאותה נקודה בתהליך והלאה:
בקוד, אתם יכולים לציין את הכרטיס הרצוי:
Modifier.pointerInput(Unit) { awaitPointerEventScope { val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial) val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final) } }
בקטע הקוד הזה, אותו אירוע זהה מוחזר על ידי כל אחת מהקריאות האלה ל-method של await, למרות שהנתונים לגבי הצריכה עשויים להשתנות.
בדיקת התנועות
בשיטות הבדיקה, אתם יכולים לשלוח אירועי סמן באופן ידני באמצעות השיטה performTouchInput. כך אפשר לבצע תנועות מלאות ברמה גבוהה יותר (כמו צביטה או לחיצה ארוכה) או תנועות ברמה נמוכה (כמו הזזת הסמן במספר מסוים של פיקסלים):
composeTestRule.onNodeWithTag("MyList").performTouchInput { swipeUp() swipeDown() click() }
דוגמאות נוספות מופיעות במסמכי התיעוד של performTouchInput.
מידע נוסף
במקורות המידע הבאים אפשר למצוא מידע נוסף על תנועות ב-Jetpack Compose:
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- נגישות ב-Compose
- גלילה
- לחיצה קצרה וארוכה