האפשרות לאפשר למשתמשים לגרור רכיבים באופן פיזי על פני המסך היא בסיסית באפליקציות מודרניות ל-Android. לא משנה אם אתם יוצרים סרגל הזזה בהתאמה אישית, רכיב החלקה לסגירה או קנבס אינטראקטיבי, Jetpack Compose מספק קבוצה מקיפה של ממשקי API לטיפול קל באירועי גרירה.
ממשקי ה-API של Compose לגרירה מסתירים את המורכבות של אירועי מגע גולמיים.
במקום לחשב באופן ידני את הקואורדינטות של המגע ולנהל את מחזורי החיים של התנועות באמצעות pointerInput, אתם יכולים להשתמש בשינויי התנהגות מובנים שקשורים ישירות למערכות ניהול המצבים והאנימציה של Compose.
בדף הזה מוסבר איך ומתי להשתמש בכל API של גרירה.
בחירת ה-API הנכון לגרירה
יש כמה ממשקי API שבהם אתם יכולים להשתמש כדי להטמיע תוכן שניתן לגרירה.
- בחרו רכיבי Material סטנדרטיים (כמו
SwipeToDismissBoxאו חלוניות הזזה לניווט) כש: אתם צריכים התנהגות ברירת מחדל של החלקה או גרירה ב-Material 3 (לדוגמה, מחיקת אימייל, העברת משימה לארכיון או פתיחת תפריט צדדי או גיליונות תחתונים). הם מספקים התנהגויות מובנות בלי צורך בניהול מותאם אישית של המצב או הפריסה. - מתי לבחור
Modifier.draggable: כשצריך רק את השינויים הגולמיים בגרירה (לדוגמה, גרירה של פס הזזה בהתאמה אישית). מידע נוסף זמין במאמר גרירה לאורך ציר אחד באמצעות רכיב שניתן לגרירה.- מתי
Modifier.draggable2Dכדאי לבחור באפשרות הזו: כשצריך רכיב שינוע באופן רציף גם בציר ה-X וגם בציר ה-Y בו-זמנית (לדוגמה, הזזה של מפה או יצירת כפתור פעולה צף שהמשתמש יכול לגרור לכל מקום במסך). מידע נוסף זמין במאמר גרירה בשני ממדים באמצעותModifier.draggable2D.
- מתי
- מתי
Modifier.anchoredDraggableמתאים: כשיוצרים רכיבים אינטראקטיביים בהתאמה אישית שצריכים לעבור בין שני מיקומים נפרדים או יותר (לדוגמה, גיליון תחתון, חלונית הזזה לניווט או פעולת החלקה לחשיפה). מידע נוסף זמין במאמר גרירת רכיבים בין נקודות עיגון באמצעותanchoredDraggable.
גרירה לאורך ציר אחד באמצעות draggable
המשתנה draggable הוא נקודת הכניסה ברמה הגבוהה לתנועות גרירה בכיוון אחד, והוא מדווח על מרחק הגרירה בפיקסלים.
חשוב לציין שהתכונה הזו דומה ל-scrollable, כי היא רק מזהה את התנועה. צריך לשמור את המצב ולייצג אותו במסך, למשל על ידי הזזת הרכיב באמצעות המשתנה offset:
@Composable private fun DraggableText() { var offsetX by remember { mutableFloatStateOf(0f) } Text( modifier = Modifier .offset { IntOffset(offsetX.roundToInt(), 0) } .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> offsetX += delta } ), text = "Drag me!" ) }
אם אתם צריכים לשלוט בכל תנועת הגרירה, כדאי להשתמש בגלאי תנועות הגרירה באמצעות המשתנה pointerInput.
@Composable private fun DraggableTextLowLevel() { Box(modifier = Modifier.fillMaxSize()) { var offsetX by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) } Box( Modifier .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } .background(Color.Blue) .size(50.dp) .pointerInput(Unit) { detectDragGestures { change, dragAmount -> change.consume() offsetX += dragAmount.x offsetY += dragAmount.y } } ) } }
גרירת רכיבים בין נקודות עיגון באמצעות anchoredDraggable
המשתנה anchoredDraggable מאפשר לגרור רכיב בין קבוצה של מיקומים מוגדרים מראש שנקראים נקודות עיגון. כשהמשתמש משחרר את תנועת הגרירה, הרכיב מתמקם בנקודת העיגון הקרובה ביותר. התכונה anchoredDraggable שימושית ליצירת רכיבים כמו גיליונות תחתונים בהתאמה אישית, פקדי הזזה מורכבים או חלוניות של מגירות.
פלטפורמת ה-API
AnchoredDraggable API מורכב מהחלקים העיקריים הבאים:
- Anchors (
DraggableAnchors): כלי להגדרת המיפוי בין המצבים בהתאמה אישית (למשל, מחלקת enum שלStart, Center, End) לבין ערכי היסט הפיקסלים התואמים שלהם. - מצב (
AnchoredDraggableState<T>): מאחסן מצב שמנהל את המיקום הנוכחי של הרכיב (היסט), את נקודת העיגון שבה הוא נמצא ואת התקדמות הגרירה שלו. כדי לשמור על המצב שלו בשינויים בהגדרות, צריך ליצור אותו באמצעותrememberSaveableו-AnchoredDraggableState.Saver<T>. - Gesture modifier (
Modifier.anchoredDraggable): משתנה שמזהה תנועות גרירה לאורךOrientation, מעדכן את המצב באמצעות דלתא של הגרירה ומטפל בתנועת ההחלקה כדי להוסיף אנימציה לרכיב לעוגן הקרוב ביותר בזמן השחרור.
דוגמה: יצירת רכיב SwipeToDismiss בהתאמה אישית
בדוגמה הזו מוסבר איך ליצור רכיב SwipeToDismiss בהתאמה אישית (באנר לבן) שהמשתמש יכול להחליק שמאלה כדי לסגור, או לגרור ימינה כדי לראות אפקט מתיחה בגלילה מעבר לקצה.
הגדרת ערכי עוגן. מגדירים enum שמייצג את המיקומים הנפרדים שאליהם אפשר להעביר את התוכן שניתן לגרירה:
private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }
יוצרים מופע ושומרים את
AnchoredDraggableState. משתמשים ב-rememberSaveableעםAnchoredDraggableState.Saver()כדי לשמור על המיקום הנוכחי של הרכיב במהלך יצירה מחדש של פעילות ושינויים בהגדרות (למשל, סיבוב המסך או שינויים בעיצוב):val state = rememberSaveable(saver = AnchoredDraggableState.Saver()) { AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting) }
החלת השינויים
layout,offsetו-draggable. כדי שיהיה אפשר לגרור את הרכיב ולוודא שנקודות העיגון שלו מתאימות לגודל שלו, צריך להחיל את המשתנים הבאים על התוכן שרוצים להזיז:-
Modifier.layout: מיקומי העוגנים תלויים בדרך כלל בגודל הרכיב, שלא ידוע במהלך הקומפוזיציה הראשונית. משתמשים במשנהlayoutכדי למדוד את הרכיב, מחשבים את המפהDraggableAnchorsבאופן דינמי על סמך הרוחב שלה ומעדכנים את המצב באמצעותstate.updateAnchors().
-
Modifier.anchoredDraggable: מתחבר למצב, שולח את השינויים בגרירה בכיוון שצוין ומטפל בתנועת ההחלקה כדי להוסיף אנימציה לרכיב לעוגן הקרוב ביותר כשמשחררים אותו. מוסיפים את ה-modifier הזה לרכיב שאמור להגיב לתנועות גרירה, ומעבירים לו אתstateואתorientation(ואופציונלית, גם אתoverscrollEffect). -
Modifier.offset: קריאת ההיסט מ-state.requireOffset()בתוך פונקציית הלמבדה של ההיסט. קריאת ההיסט בתוך ה-lambda הזו מבטיחה שהערך ייקרא רק במהלך שלב המיקום, וכך נמנעת הרכבה מחדש מיותרת במהלך הגרירה. מחילים את המשתנה הזה על התוכן שרוצים להזיז באופן חזותי.
Box( modifier = Modifier .fillMaxWidth() // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase .offset { IntOffset(state.requireOffset().roundToInt(), 0) } .layout { measurable, constraints -> val placeable = measurable.measure(constraints) val anchors = DraggableAnchors { AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat() AnchoredDraggableSwipeDismissValue.Resting at 0f } state.updateAnchors(anchors) layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } } // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll. .overscroll(overscrollEffect) .anchoredDraggable( state, Orientation.Horizontal, overscrollEffect = overscrollEffect ) ) { dismissContent() }
-
[אופציונלי] שילוב של גלילה מעבר לקצה. ה-API
overscrollEffectמיישם אפקט חזותי שמוצג כשמגיעים לקצוות של קונטיינר גלילה באמצעות גלילה או החלקה. אתם יכולים לספק משוב חזותי כשהמשתמש גורר מעבר לגבולות העוגן שהוגדרו, באמצעות אפקט גלילה מעבר לקצה (בדרך כלל מתיחה או זוהר).- שליחה: מעבירים מופע של
overscrollEffectל-modifier שלanchoredDraggable. הפעולה הזו שולחת את השינויים בגרירה למצבAnchoredDraggableאו לאפקט הגלילה מעבר לקצה, בהתאם למיקום של התנועה – בתוך או מחוץ לגבולות של נקודת העיגון. - עיבוד: מחילים את
Modifier.overscrollעם אותו מופע שלOverscrollEffectעל הרכיב שבו רוצים לעבד את האפקט באופן חזותי.
- שליחה: מעבירים מופע של
בקטע הקוד הבא אפשר לראות איך מוסיפים אפקט מתיחה רגיל:
val overscrollEffect = rememberOverscrollEffect() Box( modifier.fillMaxWidth() ) { Box(Modifier.fillMaxWidth()) { background() } Box( modifier = Modifier .fillMaxWidth() // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase .offset { IntOffset(state.requireOffset().roundToInt(), 0) } .layout { measurable, constraints -> val placeable = measurable.measure(constraints) val anchors = DraggableAnchors { AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat() AnchoredDraggableSwipeDismissValue.Resting at 0f } state.updateAnchors(anchors) layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } } // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll. .overscroll(overscrollEffect) .anchoredDraggable( state, Orientation.Horizontal, overscrollEffect = overscrollEffect ) ) { dismissContent() } }
תוצאה
קטע הקוד שלמעלה יוצר באנר לבן עם הטקסט "Drag me to dismiss!". המשתמש יכול להחליק שמאלה כדי לסגור את הבאנר (או לגרור ימינה כדי לראות את אפקט המתיחה של גלילת היתר):
דוגמה: יצירת חלונית הזזה לניווט בהתאמה אישית שמופעלת באמצעות תנועות
בדוגמה הזו מוסבר איך ליצור חלונית הזזה לניווט בהתאמה אישית, שמופעלת באמצעות תנועות. כשהמשתמש גורר את המסך בצורה אופקית, המסך שבחזית (ScreenContents) זז ימינה, משתנה מ-1.0f ל-0.8f והפינות שלו מתעגלות כדי לחשוף את חלונית ההזזה לניווט שמתחתיו.
מגדירים את המצבים והמיקומים של העוגן. בדוגמה הזו, נקודות העיגון הן המצבים הפתוחים והסגורים של המגירה. מגדירים enum למצבים
Openו-Closed, וממפים אותם להיסטים של פיקסלים באמצעותDraggableAnchors:enum class DrawerState { Open, Closed } val anchors = DraggableAnchors { DrawerState.Open at drawerWidth DrawerState.Closed at 0f } val state = remember { AnchoredDraggableState( initialValue = DrawerState.Closed, anchors = anchors, ) }
הנפשה של
anchoredDraggableStateבלחיצה על פריט. מניחים אתHomeScreenDrawerבתחתית שלBoxואתScreenContentsלמעלה. החלונית נשארת במקום, והשכבה העליונה זזה ומשנה את הגודל שלה מעליה.כשמשתמש בוחר פריט בתפריט ההזזה, מעדכנים את מצב המסך הפעיל וסוגרים את התפריט באופן אוטומטי באמצעות
state.animateTo(DrawerState.Closed):@Composable private fun AnchoredDraggableDrawerOnClickSample( modifier: Modifier = Modifier, state: AnchoredDraggableState<DrawerState>, coroutineScope: CoroutineScope ) { var screenState by remember { mutableStateOf(ScreenState.Home) } Box(modifier = modifier.fillMaxSize()) { HomeScreenDrawer( selectedScreen = screenState, onScreenSelected = { screen -> screenState = screen // Dismiss drawer when a destination is selected coroutineScope.launch { state.animateTo(DrawerState.Closed) } } ) // ScreenContents(...) } }
משתמשים ב-
state.requireOffset()כדי לשנות כמה מאפיינים חזותיים. במקום רק להזיז את התוכן, אתם יכולים לשנות כמה מאפיינים באמצעותModifier.graphicsLayerכדי להוסיף אנימציה בו-זמנית למיקום, לקנה המידה ולרדיוס הפינה על סמך התקדמות הגרירה:- אפשר להשתמש ב-
state.requireOffset()כדי לקבל את ההיסט הנוכחי של הגרירה. - משתמשים ב-
lerp()(אינטרפולציה לינארית) כדי לחשב את קנה המידה הפרופורציונלי (1fעד0.8f) ואת רדיוס הפינה (0.dpעד32.dp) ביחס להיסט ולרוחב של חלונית ההזזה. -
Modifier.graphicsLayerמבצע שינויים בשלב השרטוט, וכך מבטיח אנימציות עם ביצועים גבוהים בלי להפעיל קומפוזיציות מחדש במהלך הגרירה.
@Composable private fun AnchoredDraggableDrawerGraphicsLayerSample( state: AnchoredDraggableState<DrawerState>, drawerWidth: Float, selectedScreen: ScreenState, onDrawerClicked: () -> Unit ) { ScreenContents( selectedScreen = selectedScreen, onDrawerClicked = onDrawerClicked, modifier = Modifier .graphicsLayer { this.translationX = state.requireOffset() val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth) this.scaleX = scale this.scaleY = scale val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth) this.shape = RoundedCornerShape(cornerRadius.dp) this.clip = true } .anchoredDraggable(state, Orientation.Horizontal) ) }
- אפשר להשתמש ב-
הפעלת אנימציות פרוגרמטיות באמצעות
animateTo. כדי לשלוט בתפריט הצד באופן פרוגרמטי (למשל, כשמקישים על סמל התפריט או בוחרים פריט בתפריט הצד), מפעילים אתstate.animateTo()בתוך היקף של קורוטינה:fun toggleDrawerState( state: AnchoredDraggableState<DrawerState>, coroutineScope: CoroutineScope ) { coroutineScope.launch { if (state.currentValue == DrawerState.Open) { state.animateTo(DrawerState.Closed) } else { state.animateTo(DrawerState.Open) } } }
תוצאה
גרירה בשני ממדים באמצעות Modifier.draggable2D
משתמשים במשנה draggable2D כדי להזיז רכיבים ספציפיים בממשק המשתמש גם בציר ה-X וגם בציר ה-Y בו-זמנית.
גרירת רכיב קומפוזבילי
בדוגמה הזו מוצג התרחיש הנפוץ ביותר לשימוש ב-draggable2D – לאפשר למשתמש לבחור רכיב בממשק המשתמש ולשנות את המיקום שלו בכל מקום בתוך מאגר הורה.
@Composable private fun DraggableComposableElement() { // 1. Track the position of the floating window var offset by remember { mutableStateOf(Offset.Zero) } Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) { Box( modifier = Modifier // 2. Apply the offset to the box's position .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } // ... // 3. Attach the 2D drag logic .draggable2D( state = rememberDraggable2DState { delta -> // 4. Update the position based on the movement delta offset += delta } ), contentAlignment = Alignment.Center ) { Text("Video Preview", color = Color.White, fontSize = 12.sp) } } }
קטע הקוד שלמעלה כולל את הדברים הבאים:
- מעקב אחרי המיקום של התיבה באמצעות מצב
offset. - משתמשים ב-modifier
offsetכדי לשנות את המיקום של הרכיב על סמך השינויים בגרירה. - אין תמיכה בהשלכה, ולכן התיבה מפסיקה לזוז ברגע שהמשתמש מרים את האצבע.
גרירה של רכיב קומפוזבילי צאצא על סמך אזור הגרירה של ההורה
הדוגמה הזו ממחישה איך להשתמש ב-draggable2D כדי ליצור אזור קלט דו-ממדי שבו כפתור הבורר מוגבל למשטח ספציפי. בניגוד לדוגמה של הרכיב שניתן לגרירה, שבה הרכיב עצמו זז, בהטמעה הזו נעשה שימוש בדלתות הדו-ממדיות כדי להזיז "בורר" של רכיב קומפוזבילי צאצא בכלי לבחירת צבעים:
@Composable private fun ExampleColorSelector( // ... ) { // 1. Maintain the 2D position of the selector in state. var selectorOffset by remember { mutableStateOf(Offset.Zero) } // 2. Track the size of the background container. var containerSize by remember { mutableStateOf(IntSize.Zero) } Box( modifier = Modifier .size(300.dp, 200.dp) // Capture the actual pixel dimensions of the container when it's laid out. .onSizeChanged { containerSize = it } .clip(RoundedCornerShape(12.dp)) .background( brush = remember(hue) { // Create a simple gradient representing Saturation and Value for the given Hue. Brush.linearGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f))) } ) ) { Box( modifier = Modifier .size(24.dp) .graphicsLayer { // Center the selector on the finger by subtracting half its size. translationX = selectorOffset.x - (24.dp.toPx() / 2) translationY = selectorOffset.y - (24.dp.toPx() / 2) } // ... // 3. Configure 2D touch dragging. .draggable2D( state = rememberDraggable2DState { delta -> // 4. Calculate the new position and clamp it to the container bounds val newX = (selectorOffset.x + delta.x) .coerceIn(0f, containerSize.width.toFloat()) val newY = (selectorOffset.y + delta.y) .coerceIn(0f, containerSize.height.toFloat()) selectorOffset = Offset(newX, newY) } ) ) } }
קטע הקוד שלמעלה כולל את הפרטים הבאים:
- הוא משתמש ב-modifier
onSizeChangedכדי לתעד את המימדים בפועל של הקונטיינר של הגרדיאנט. הכלי לבחירת צבעים יודע בדיוק איפה נמצאים הקצוות. - בתוך
graphicsLayer, הוא משנה אתtranslationXו-translationYכדי לוודא שהבורר נשאר במרכז בזמן הגרירה.
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- הסבר על תנועות
- דוגמה לאנימציה מתקדמת: תנועות {:#gesture-and-animation}
- אנימציות מבוססות-ערך