גלילה דו-ממדית: scrollable2D, ‏ draggable2D

ב-Jetpack פיתוח נייטיב, scrollable2D ו-draggable2D הם משנים ברמה נמוכה שנועדו לטפל בקלט של מצביע בשני ממדים. בעוד שהמשנים הסטנדרטיים של 1D‏ scrollable ו-draggable מוגבלים לאוריינטציה אחת, הווריאציות של 2D עוקבות אחרי תנועה לאורך ציר X וציר Y בו-זמנית.

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

איור 1. הזזה דו-כיוונית במפה.

בוחרים באפשרות scrollable2D או draggable2D.

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

Modifier.scrollable2D: משתמשים במאפיין הזה בקונטיינר כדי להעביר תוכן בתוכו. לדוגמה, אפשר להשתמש בו במפות, בגיליונות אלקטרוניים או בתוכנות לצפייה בתמונות, שבהן התוכן של הקונטיינר צריך לגלול גם אופקית וגם אנכית. הוא כולל תמיכה מובנית בהחלקה מהירה, כך שהתוכן ממשיך לנוע אחרי החלקה, והוא מתואם עם רכיבי גלילה אחרים בדף.

Modifier.draggable2D: משתמשים בשינוי הזה כדי להזיז רכיב. זהו שינוי קל משקל, ולכן התנועה מפסיקה בדיוק כשהאצבע של המשתמש מפסיקה. הוא לא כולל תמיכה בהעברה.

אם רוצים לאפשר גרירה של רכיב, אבל לא צריך תמיכה בהטלה או בגלילה מקוננת, משתמשים ב-draggable2D.

הטמעה של ערכי מקדם דו-ממדיים

בקטעים הבאים מופיעות דוגמאות שממחישות איך להשתמש במאפייני השינוי של תמונות דו-ממדיות.

הטמעה של Modifier.scrollable2D

משתמשים במגדיר הזה במאגרי תגים שבהם המשתמש צריך להזיז תוכן לכל הכיוונים.

איסוף נתוני תנועה דו-ממדיים

בדוגמה הזו מוסבר איך ללכוד נתוני תנועה גולמיים דו-ממדיים ולהציג את ההיסטים בציר X ובציר Y:

@Composable
private fun Scrollable2DSample() {
    // 1. Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            // ...
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 2. Attach the 2D scroll logic to capture XY movement deltas
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        // 3. Update the cumulative offset state with the new movement delta
                        offset += delta

                        // Return the delta to indicate the entire movement was handled by this box
                        delta
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // 4. Display the current X and Y values from the offset state in real-time
                Text(
                    text = "X: ${offset.x.roundToInt()}",
                    // ...
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    // ...
                )
            }
        }
    }
}

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

קטע הקוד שלמעלה מבצע את הפעולות הבאות:

  • משתמש ב-offset כמצב שמכיל את המרחק הכולל שהמשתמש גלל.
  • בתוך rememberScrollable2DState, מוגדרת פונקציית למדה לטיפול בכל דלתא שנוצרת על ידי האצבע של המשתמש. הקוד offset.value += delta מעדכן את המצב הידני עם המיקום החדש.
  • רכיבי Text מציגים את הערכים הנוכחיים של X ו-Y של מצב offset הזה, שמתעדכנים בזמן אמת כשהמשתמש גורר.

הזזה של אזור תצוגה גדול

בדוגמה הזו מוצג איך להשתמש בנתונים שנתפסו של גלילה דו-ממדית ולהחיל translationX ו-translationY על תוכן שגדול יותר מהקונטיינר של רכיב האב שלו:

@Composable
private fun Panning2DImage() {

    // Manually track the total distance the user has moved in both X and Y directions
    val offset = remember { mutableStateOf(Offset.Zero) }

    // Define how gestures are captured. The lambda is called for every finger movement
    val scrollState = rememberScrollable2DState { delta ->
        offset.value += delta
        delta
    }

    // The Viewport (Container): A fixed-size box that acts as a window into the larger content
    Box(
        modifier = Modifier
            .size(600.dp, 400.dp) // The visible area dimensions
            // ...
            // Hide any parts of the large content that sit outside this container's boundaries
            .clipToBounds()
            // Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
            .scrollable2D(state = scrollState),
        contentAlignment = Alignment.Center,
    ) {
        // The Content: An image given a much larger size than the container viewport
        Image(
            painter = painterResource(R.drawable.cheese_5),
            contentDescription = null,
            modifier = Modifier
                .requiredSize(1200.dp, 800.dp)
                // Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
                // we use graphicsLayer to shift the drawing position based on the tracked offset.
                .graphicsLayer {
                    translationX = offset.value.x
                    translationY = offset.value.y
                },
            contentScale = ContentScale.FillBounds
        )
    }
}

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

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

  • הגודל של מאגר התוכן מוגדר כגודל קבוע (600x400dp), אבל התוכן מקבל גודל גדול בהרבה (1200x800dp) כדי שלא ישנה את הגודל שלו לגודל של מאגר האב.
  • המשנה clipToBounds() במאגר התגים מבטיח שכל חלק מהתוכן הגדול שנמצא מחוץ לתיבה 600x400 יהיה מוסתר מהתצוגה.
  • בניגוד לרכיבים ברמה גבוהה כמו LazyColumn, ב-scrollable2D התוכן לא מועבר אוטומטית. במקום זאת, צריך להחיל את offset המעקב על התוכן באמצעות טרנספורמציות של graphicsLayer או היסטים של פריסות.
  • בתוך הבלוק graphicsLayer, הפקודות translationX = offset.value.x ו-translationY = offset.value.y משנות את מיקום הציור של התמונה או הטקסט בהתאם לתנועת האצבע, ויוצרות את האפקט החזותי של גלילה.

הטמעה של גלילה מקוננת באמצעות scrollable2D

בדוגמה הזו מוצג אופן השילוב של רכיב דו-כיווני ברכיב הורה חד-ממדי רגיל, כמו פיד חדשות אנכי.

כשמטמיעים גלילה מקוננת, חשוב לזכור את הנקודות הבאות:

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

@Composable
private fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val maxScrollDp = 250.dp
    val maxScrollPx = with(LocalDensity.current) { maxScrollDp.toPx() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(Color(0xFFF5F5F5)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            "Scroll down to find the 2D Box",
            modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
            style = TextStyle(fontSize = 18.sp, color = Color.Gray)
        )

        // The Child: A 2D scrollable box with nested scroll coordination
        Box(
            modifier = Modifier
                .size(250.dp)
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        val oldOffset = offset

                        // Calculate new potential offset and clamp it to our boundaries
                        val newX = (oldOffset.x + delta.x).coerceIn(-maxScrollPx, maxScrollPx)
                        val newY = (oldOffset.y + delta.y).coerceIn(-maxScrollPx, maxScrollPx)

                        val newOffset = Offset(newX, newY)

                        // Calculate exactly how much was consumed by the child
                        val consumed = newOffset - oldOffset

                        offset = newOffset

                        // IMPORTANT: Return ONLY the consumed delta.
                        // The remaining (unconsumed) delta propagates to the parent Column.
                        consumed
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                val density = LocalDensity.current
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${with(density) { offset.x.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${with(density) { offset.y.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: 250 or -250,\nthis parent list will take over the vertical scroll.",
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
            style = TextStyle(fontSize = 14.sp, color = Color.Gray)
        )
    }
}

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

בקטע הקוד שלמעלה:

  • הרכיב הדו-ממדי יכול לצרוך תנועה בציר X כדי להזיז את התוכן שלו אופקית, ובמקביל לשלוח תנועה בציר Y לרשימת ההורה ברגע שמגיעים לגבולות האנכיים של הרכיב.
  • במקום להגביל את המשתמש למשטח הדו-ממדי, המערכת מחשבת את הדלתא שנצרכה ומעבירה את היתרה למעלה בהיררכיה. כך המשתמש יכול להמשיך לגלול בשאר הדף בלי להרים את האצבע.

הטמעה של Modifier.draggable2D

כדי להזיז רכיבי ממשק משתמש ספציפיים, משתמשים בשינוי draggable2D.

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

בדוגמה הזו מוצג תרחיש השימוש הנפוץ ביותר ב-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)
        }
    }
}

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

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

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

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

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

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