في Jetpack Compose، scrollable2D وdraggable2D هما معدِّلان منخفض المستوى مصمَّمان للتعامل مع إدخال المؤشر في بُعدَين. في حين أنّ أدوات التعديل القياسية ذات البُعد الواحد scrollable وdraggable تقتصر على اتجاه واحد، تتتبّع الصيغ الثنائية الأبعاد الحركة على المحورين X وY في الوقت نفسه.
على سبيل المثال، يتم استخدام المعدِّل scrollable للتمرير السريع والتمرير مع إبقاء الإصبع على الشاشة في اتجاه واحد، بينما يتم استخدام scrollable2d للتمرير السريع والتمرير مع إبقاء الإصبع على الشاشة في اتجاهين. يتيح لك ذلك إنشاء تخطيطات أكثر تعقيدًا تتحرك في جميع الاتجاهات، مثل جداول البيانات أو عارضات الصور. يتيح المعدِّل scrollable2d أيضًا التمرير المتداخل في سيناريوهات ثنائية الأبعاد.
اختَر scrollable2D أو draggable2D
يعتمد اختيار واجهة برمجة التطبيقات المناسبة على عناصر واجهة المستخدم التي تريد نقلها والسلوك المادي المفضّل لهذه العناصر.
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لضمان بقاء أداة الاختيار في المنتصف أثناء السحب.