האפשרות לאפשר למשתמשים לגרור רכיבים באופן פיזי על פני המסך היא בסיסית באפליקציות מודרניות ל-Android. בין אם אתם יוצרים רכיב מותאם אישית של פס הזזה, רכיב של החלקה לסגירה או Canvas אינטראקטיבי, Jetpack Compose מספק קבוצה מקיפה של ממשקי API לטיפול קל באירועי גרירה.
ממשקי ה-API של גרירה ב-Compose מסתירים את המורכבות של אירועי מגע גולמיים.
במקום לחשב באופן ידני את קואורדינטות המגע ולנהל את מחזורי החיים של המחוות באמצעות pointerInput, אפשר להשתמש במאפייני שינוי מובנים שמשולבים ישירות במערכות לניהול מצבים ולאנימציה של Compose.
בדף הזה מוסבר איך ומתי להשתמש בכל ממשק API של גרירה.
בחירת ה-API הנכון לגרירה
יש כמה ממשקי API שאפשר להשתמש בהם כדי להטמיע תוכן שניתן לגרירה.
- בוחרים רכיבי Material רגילים (כמו
SwipeToDismissBoxאו חלוניות הזזה לניווט) כש: צריך התנהגות ברירת מחדל של החלקה או גרירה ב-Material 3 (לדוגמה, מחיקת אימייל, העברה לארכיון של משימה או פתיחה של תפריט צדדי או גיליונות בתחתית המסך). הם מספקים התנהגויות מובנות בלי לדרוש ניהול מותאם אישית של מצב או פריסה. - מתי
Modifier.draggableלבחור: כשצריך רק את השינויים הגולמיים בגרירה (לדוגמה, גרירת פס הזזה בהתאמה אישית). מידע נוסף מופיע במאמר בנושא Drag along a single axis with draggable (גרירה לאורך ציר יחיד באמצעות רכיב שניתן לגרירה).- בחירה
Modifier.draggable2Dמתי: כשרוצים שרכיב ינוע ברציפות לאורך ציר X וציר Y בו-זמנית (לדוגמה, כשמזיזים מפה או כשיוצרים כפתור פעולה צף (FAB) שהמשתמש יכול לגרור לכל מקום במסך). מידע נוסף זמין במאמר גרירה בשני ממדים באמצעות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) לבין ערכי היסט הפיקסלים התואמים. - State (מצב) (
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: מתחבר למצב, שולח את שינויי המיקום של הגרירה בכיוון שצוין ומטפל בתנועת ההטלה כדי להנפיש את הרכיב לנקודת העיגון הקרובה ביותר כשהוא משוחרר. מוסיפים את המשתנה הזה לרכיב שאמור להגיב לתנועות גרירה, ומעבירים לו את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מטמיע אפקט חזותי שמוצג כשמגיעים לקצוות של קונטיינר גלילה באמצעות גלילה או החלקה. אתם יכולים לספק משוב חזותי כשמשתמש גורר מעבר לגבולות המוגדרים של העוגן עם אפקט גלילה מעבר לקצה (בדרך כלל מתיחה או הבהוב).- Dispatch: מעבירים מופע של
overscrollEffectל-modifier שלanchoredDraggable. הפעולה הזו שולחת את ההפרשים של הגרירה למצבAnchoredDraggableאו לאפקט הגלילה מעבר לקצה, בהתאם למיקום התנועה בתוך או מחוץ לגבולות העוגן. - Render: מחילים את
Modifier.overscrollעם אותו מופע שלOverscrollEffectעל הרכיב שאמור להציג את האפקט.
- Dispatch: מעבירים מופע של
בקטע הקוד הבא אפשר לראות איך מוסיפים אפקט מתיחה רגיל:
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()בתוך היקף של פונקציית coroutine: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. - משתמשים במשנה
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}
- אנימציות מבוססות-ערך