التمرير في اتجاهَين: 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

لمزيد من المعلومات حول كيفية استخدام Modifier.draggable2D، يُرجى الاطّلاع على السحب في بُعدَين باستخدام Modifier.draggable2D.