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

האפשרות לאפשר למשתמשים לגרור רכיבים באופן פיזי על פני המסך היא בסיסית באפליקציות מודרניות ל-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.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) לבין ערכי היסט הפיקסלים התואמים.
  • ‫State (מצב) (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: מתחבר למצב, שולח את שינויי המיקום של הגרירה בכיוון שצוין ומטפל בתנועת ההטלה כדי להנפיש את הרכיב לנקודת העיגון הקרובה ביותר כשהוא משוחרר. מוסיפים את המשתנה הזה לרכיב שאמור להגיב לתנועות גרירה, ומעבירים לו את 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. Dispatch: מעבירים מופע של overscrollEffect ל-modifier של anchoredDraggable. הפעולה הזו שולחת את ההפרשים של הגרירה למצב AnchoredDraggable או לאפקט הגלילה מעבר לקצה, בהתאם למיקום התנועה בתוך או מחוץ לגבולות העוגן.
    2. ‫Render: מחילים את 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() בתוך היקף של פונקציית coroutine:

    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.
  • משתמשים במשנה 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 כדי לוודא שהבורר נשאר במרכז בזמן הגרירה.