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

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

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

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

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

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

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

‫Modifier.draggable2D: משתמשים בצירוף המקשים הזה כדי להזיז רכיב. זהו משנה קל משקל, כך שהתנועה נעצרת בדיוק כשאצבע המשתמש נעצרת. הוא לא כולל תמיכה בהעברה.

אם רוצים שרכיב יהיה ניתן לגרירה, אבל לא צריך תמיכה בהחלקה או בגלילה בתצוגת עץ, משתמשים ב-draggable2D.

הטמעת משתנים דו-ממדיים

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

הטמעה של Modifier.scrollable2D

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

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

בדוגמה הבאה אפשר לראות איך לתעד נתוני תנועה גולמיים בדו-ממד ולהציג את ההיסט ב-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, מוגדרת פונקציית lambda כדי לטפל בכל דלתא שנוצרת על ידי האצבע של המשתמש. הקוד offset.value += delta מעדכן את המצב הידני עם המיקום החדש.
  • הרכיבים של Text מציגים את ערכי ה-X וה-Y הנוכחיים של המצב offset, שמתעדכנים בזמן אמת כשהמשתמש גורר.

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

בדוגמה הזו אפשר לראות איך משתמשים בנתונים שצולמו ב-2D עם אפשרות גלילה, ואיך להחיל 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

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

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

  • הלמבדה של 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

במאמר גרירה בשני ממדים באמצעות Modifier.draggable2D מוסבר איך משתמשים ב-Modifier.draggable2D.