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