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

در Jetpack Compose، scrollable2D و draggable2D اصلاح‌کننده‌های سطح پایینی هستند که برای مدیریت ورودی اشاره‌گر در دو بعد طراحی شده‌اند. در حالی که اصلاح‌کننده‌های استاندارد یک‌بعدی، scrollable و draggable به یک جهت‌گیری محدود هستند، انواع دوبعدی حرکت را در هر دو محور X و Y به طور همزمان ردیابی می‌کنند.

برای مثال، اصلاح‌کننده‌ی scrollable موجود برای اسکرول کردن و جابجایی در یک جهت استفاده می‌شود، در حالی که scrollable2d برای اسکرول کردن و جابجایی در حالت دوبعدی استفاده می‌شود. این به شما امکان می‌دهد طرح‌بندی‌های پیچیده‌تری ایجاد کنید که در همه جهات حرکت می‌کنند، مانند صفحات گسترده یا نمایشگرهای تصویر. اصلاح‌کننده‌ی scrollable2d همچنین از پیمایش تو در تو در سناریوهای دوبعدی پشتیبانی می‌کند.

شکل ۱. پیمایش افقی دو جهته روی نقشه.

انتخاب گزینه scrollable2D یا draggable2D

انتخاب API مناسب به عناصر رابط کاربری که می‌خواهید جابجا کنید و رفتار فیزیکی ترجیحی برای این عناصر بستگی دارد.

Modifier.scrollable2D : از این اصلاح‌کننده روی یک ظرف برای جابجایی محتوا درون آن استفاده کنید. برای مثال، از آن با نقشه‌ها، صفحات گسترده یا نمایشگرهای عکس استفاده کنید، جایی که محتوای ظرف نیاز به پیمایش در هر دو جهت افقی و عمودی دارد. این شامل پشتیبانی داخلی از fling است تا محتوا پس از کشیدن انگشت به حرکت خود ادامه دهد و با سایر اجزای پیمایش در صفحه هماهنگ شود.

Modifier.draggable2D : از این مدیفایر برای حرکت دادن خودِ یک کامپوننت استفاده کنید. این یک مدیفایر سبک است، بنابراین حرکت دقیقاً زمانی که انگشت کاربر متوقف می‌شود، متوقف می‌شود. این مدیفایر شامل پشتیبانی از حرکت سریع (fling) نمی‌شود.

اگر می‌خواهید یک کامپوننت را قابل کشیدن کنید، اما به پشتیبانی از fling یا پیمایش تو در تو نیاز ندارید، 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 .

قطعه کد قبلی شامل موارد زیر است:

  • اندازه کانتینر (container) روی یک اندازه ثابت ( 600x400dp ) تنظیم شده است، در حالی که اندازه محتوا (content) بسیار بزرگتر ( 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 state) ردیابی می‌کند.
  • از اصلاح‌کننده‌ی 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 را تنظیم می‌کند تا مطمئن شود که انتخابگر هنگام کشیدن در مرکز باقی می‌ماند.