پیمایش دوبعدی: scrollable2D،‏ draggable2D

در Jetpack Compose، scrollable2D و draggable2D اصلاح‌کننده‌های سطح پایینی هستند که برای مدیریت ورودی اشاره‌گر در دو بعد طراحی شده‌اند. درحالی‌که اصلاح‌کننده‌های استاندارد 1D scrollable و draggable به یک جهت محدود می‌شوند، انواع 2D حرکت را به‌طور هم‌زمان در هر دو محور 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()}",
                    // ...
                )
            }
        }
    }
}

شکل ۲. چارگوش بنفشی که انحرافات مختصات 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
        )
    }
}

شکل ۳. ناحیه نمایش تصویر با قابلیت حرکت دوطرفه، ایجادشده با Modifier.scrollable2D.
شکل ۴. ناحیه نمایش نوشتار با پیمایش دوطرفه، ایجادشده با 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)
        )
    }
}

شکل ۵. یک چارگوش بنفش در فهرست پیمایش عمودی که امکان حرکت داخلی دو بعدی را فراهم می‌کند، اما وقتی افست Y داخلی چارگوش به حد ۳۰۰ پیکسل خود می‌رسد، کنترل پیمایش عمودی را به فهرست اصلی منتقل می‌کند.

در گلچین قبلی:

  • عنصر دوبعدی می‌تواند حرکت محور 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 را تنظیم می‌کند تا مطمئن شود انتخابگر هنگام کشیدن در مرکز باقی می‌ماند.