التمرير في اتجاهَين: scrollable2D وdraggable2D

في Jetpack Compose، scrollable2D وdraggable2D هما معدِّلان منخفض المستوى مصمَّمان للتعامل مع إدخال المؤشر في بُعدَين. في حين أنّ أدوات التعديل القياسية ذات البُعد الواحد scrollable وdraggable تقتصر على اتجاه واحد، تتتبّع الصيغ الثنائية الأبعاد الحركة على المحورين X وY في الوقت نفسه.

على سبيل المثال، يتم استخدام المعدِّل scrollable للتمرير السريع والتمرير مع إبقاء الإصبع على الشاشة في اتجاه واحد، بينما يتم استخدام scrollable2d للتمرير السريع والتمرير مع إبقاء الإصبع على الشاشة في اتجاهين. يتيح لك ذلك إنشاء تخطيطات أكثر تعقيدًا تتحرك في جميع الاتجاهات، مثل جداول البيانات أو عارضات الصور. يتيح المعدِّل scrollable2d أيضًا التمرير المتداخل في سيناريوهات ثنائية الأبعاد.

الشكل 1. تحريك الخريطة في اتجاهين

اختَر 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()}",
                    // ...
                )
            }
        }
    }
}

الشكل 2. مربّع أرجواني يتتبّع ويعرض إزاحات الإحداثيات X وY الحالية أثناء سحب المستخدم للمؤشر على سطحه

ينفّذ المقتطف السابق ما يلي:

  • يستخدم 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
        )
    }
}

الشكل 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 لضمان بقاء أداة الاختيار في المنتصف أثناء السحب.