גרירת רכיבים קומפוזביליים

האפשרות לאפשר למשתמשים לגרור רכיבים באופן פיזי על פני המסך היא בסיסית באפליקציות מודרניות ל-Android. לא משנה אם אתם יוצרים סרגל הזזה בהתאמה אישית, רכיב החלקה לסגירה או קנבס אינטראקטיבי, Jetpack Compose מספק קבוצה מקיפה של ממשקי API לטיפול קל באירועי גרירה.

ממשקי ה-API של Compose לגרירה מסתירים את המורכבות של אירועי מגע גולמיים. במקום לחשב באופן ידני את הקואורדינטות של המגע ולנהל את מחזורי החיים של התנועות באמצעות pointerInput, אתם יכולים להשתמש בשינויי התנהגות מובנים שקשורים ישירות למערכות ניהול המצבים והאנימציה של Compose.

בדף הזה מוסבר איך ומתי להשתמש בכל API של גרירה.

בחירת ה-API הנכון לגרירה

יש כמה ממשקי API שבהם אתם יכולים להשתמש כדי להטמיע תוכן שניתן לגרירה.

  • בחרו רכיבי Material סטנדרטיים (כמו SwipeToDismissBox או חלוניות הזזה לניווט) כש: אתם צריכים התנהגות ברירת מחדל של החלקה או גרירה ב-Material 3 (לדוגמה, מחיקת אימייל, העברת משימה לארכיון או פתיחת תפריט צדדי או גיליונות תחתונים). הם מספקים התנהגויות מובנות בלי צורך בניהול מותאם אישית של המצב או הפריסה.
  • מתי לבחור Modifier.draggable: כשצריך רק את השינויים הגולמיים בגרירה (לדוגמה, גרירה של פס הזזה בהתאמה אישית). מידע נוסף זמין במאמר גרירה לאורך ציר אחד באמצעות רכיב שניתן לגרירה.
  • מתי 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
                    }
                }
        )
    }
}

כאן מוסיפים טקסט חלופי
איור 1. רכיב בממשק המשתמש שנגרר באמצעות אצבע

גרירת רכיבים בין נקודות עיגון באמצעות 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 בהתאמה אישית (באנר לבן) שהמשתמש יכול להחליק שמאלה כדי לסגור, או לגרור ימינה כדי לראות אפקט מתיחה בגלילה מעבר לקצה.

  1. הגדרת ערכי עוגן. מגדירים enum שמייצג את המיקומים הנפרדים שאליהם אפשר להעביר את התוכן שניתן לגרירה:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. יוצרים מופע ושומרים את AnchoredDraggableState. משתמשים ב-rememberSaveable עם AnchoredDraggableState.Saver() כדי לשמור על המיקום הנוכחי של הרכיב במהלך יצירה מחדש של פעילות ושינויים בהגדרות (למשל, סיבוב המסך או שינויים בעיצוב):

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. החלת השינויים 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()
    }

  4. [אופציונלי] שילוב של גלילה מעבר לקצה. ה-API‏ overscrollEffect מיישם אפקט חזותי שמוצג כשמגיעים לקצוות של קונטיינר גלילה באמצעות גלילה או החלקה. אתם יכולים לספק משוב חזותי כשהמשתמש גורר מעבר לגבולות העוגן שהוגדרו, באמצעות אפקט גלילה מעבר לקצה (בדרך כלל מתיחה או זוהר).

    1. שליחה: מעבירים מופע של overscrollEffect ל-modifier של anchoredDraggable. הפעולה הזו שולחת את השינויים בגרירה למצב AnchoredDraggable או לאפקט הגלילה מעבר לקצה, בהתאם למיקום של התנועה – בתוך או מחוץ לגבולות של נקודת העיגון.
    2. עיבוד: מחילים את 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!‎". המשתמש יכול להחליק שמאלה כדי לסגור את הבאנר (או לגרור ימינה כדי לראות את אפקט המתיחה של גלילת היתר):

סרטון 2. הטמעה של רכיב מותאם אישית להחלקה כדי לסגור.

דוגמה: יצירת חלונית הזזה לניווט בהתאמה אישית שמופעלת באמצעות תנועות

בדוגמה הזו מוסבר איך ליצור חלונית הזזה לניווט בהתאמה אישית, שמופעלת באמצעות תנועות. כשהמשתמש גורר את המסך בצורה אופקית, המסך שבחזית (ScreenContents) זז ימינה, משתנה מ-1.0f ל-0.8f והפינות שלו מתעגלות כדי לחשוף את חלונית ההזזה לניווט שמתחתיו.

  1. מגדירים את המצבים והמיקומים של העוגן. בדוגמה הזו, נקודות העיגון הן המצבים הפתוחים והסגורים של המגירה. מגדירים 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,
            )
        }

  2. הנפשה של 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(...)
        }
    }

  3. משתמשים ב-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)
        )
    }

  4. הפעלת אנימציות פרוגרמטיות באמצעות 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)
            }
        }
    }

תוצאה

סרטון 3. המסך שבחזית (שינה) נגרר שמאלה, ומוצגת חלונית ההזזה לניווט שמתחתיו.

גרירה בשני ממדים באמצעות 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)
        }
    }
}

סרטון 4. תיבה סגולה קטנה שמשנה את המיקום שלה על רקע אפור, ומדגימה גרירה ישירה בדו-ממד שבה הרכיב מפסיק לזוז ברגע שהמשתמש מרים את האצבע.

קטע הקוד שלמעלה כולל את הדברים הבאים:

  • מעקב אחרי המיקום של התיבה באמצעות מצב 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)
                    }
                )
        )
    }
}

סרטון 5. טווח צבעים עם כפתור בחירה עגול בצבע לבן שאפשר לגרור לכל כיוון, שמדגים איך הפרשי ערכים דו-ממדיים מוגבלים לגבולות של הקונטיינר כדי לעדכן את ערכי הצבעים שנבחרו.

קטע הקוד שלמעלה כולל את הפרטים הבאים:

  • הוא משתמש ב-modifier‏ onSizeChanged כדי לתעד את המימדים בפועל של הקונטיינר של הגרדיאנט. הכלי לבחירת צבעים יודע בדיוק איפה נמצאים הקצוות.
  • בתוך graphicsLayer, הוא משנה את translationX ו-translationY כדי לוודא שהבורר נשאר במרכז בזמן הגרירה.