التمرير في اتجاهَين: 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، يتم تعريف دالة لامدا للتعامل مع كل قيمة دلتا ينشئها إصبع المستخدم. يعدِّل الرمز البرمجي 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`

يوضّح هذا المثال كيفية دمج مكوّن ثنائي الاتجاه في حاوية رئيسية عادية أحادية البُعد، مثل خلاصة أخبار عمودية.

يُرجى مراعاة النقاط التالية عند تنفيذ التمرير المضمّن:

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