ב-Jetpack Compose, scrollable2D ו-draggable2D הם אמצעי שינוי ברמה נמוכה שנועדו לטפל בקלט של מצביע בשני ממדים. בעוד שהמשנים הסטנדרטיים של 1D scrollable ו-draggable מוגבלים לכיוון אחד, הגרסאות של 2D עוקבות אחרי התנועה לאורך ציר X וציר Y בו-זמנית.
לדוגמה, משתמשים במחרוזת scrollable הקיימת כדי לגלול ולזרוק בכיוון אחד, ובמחרוזת scrollable2d כדי לגלול ולזרוק בדו-ממד. כך תוכלו ליצור פריסות מורכבות יותר שניתן להזיז לכל הכיוונים, כמו גיליונות אלקטרוניים או תוכנות לצפייה בתמונות. המשנה scrollable2d תומך גם בגלילה מקוננת בתרחישים דו-ממדיים.
בוחרים באפשרות 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()}", // ... ) } } } }
קטע הקוד שלמעלה מבצע את הפעולות הבאות:
- משתמש ב-
offsetכמצב שמכיל את המרחק הכולל שהמשתמש גלל. - בתוך
rememberScrollable2DState, מוגדרת פונקציית lambda לטיפול בכל דלתא שנוצרת על ידי האצבע של המשתמש. הקוד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 ) } }
Modifier.scrollable2D.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) ) } }
בקטע הקוד שלמעלה:
- הרכיב הדו-ממדי יכול לצרוך תנועה בציר 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) } } }
קטע הקוד שלמעלה כולל את הפריטים הבאים:
- המיקום של התיבה נשמר באמצעות
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) } ) ) } }
קטע הקוד שלמעלה כולל את הרכיבים הבאים:
- הוא משתמש במחרוזת
onSizeChangedכדי לתעד את המאפיינים בפועל של קונטיינר הגרדיאנט. הכלי לבחירת אזורים יודע בדיוק איפה הקצוות. - בתוך
graphicsLayer, הוא משנה אתtranslationXואתtranslationYכדי לוודא שהסמן יישאר במרכז בזמן הגרירה.