দ্বিমাত্রিক স্ক্রলিং: scrollable2D, draggable2D

Jetpack Compose-এ, scrollable2D এবং draggable2D হল নিম্ন-স্তরের মডিফায়ার যা টু-ডাইমেনশনে পয়েন্টার ইনপুট ম্যানেজ করার জন্য ডিজাইন করা হয়েছে। স্ট্যান্ডার্ড 1D মডিফায়ার scrollable ও draggable একটি নির্দিষ্ট ওরিয়েন্টেশনে সীমাবদ্ধ থাকলেও, 2D ভ্যারিয়েন্ট একই সাথে X ও Y অক্ষ জুড়ে হওয়া মুভমেন্ট ট্র্যাক করে।

যেমন, আগে থেকে থাকা scrollable মডিফায়ারটি সিঙ্গেল-ওরিয়েন্টেশন স্ক্রলিং ও ফ্লিং করার জন্য ব্যবহার করা হয়, অন্যদিকে scrollable2d মডিফায়ারটি 2D-তে স্ক্রলিং ও ফ্লিং করার জন্য ব্যবহার করা হয়। এর ফলে আপনি স্প্রেডশিট বা ইমেজ ভিউয়ারের মতো আরও জটিল লেআউট তৈরি করতে পারেন যেগুলি সব দিকে মুভ করে। scrollable2d মডিফায়ার এছাড়াও 2D সিনারিওতে নেস্টেড স্ক্রলিং কাজ করে।

ছবি ১. ম্যাপে দ্বিমুখী প্যানিং।

scrollable2D বা draggable2D বেছে নিন

আপনি যে UI এলিমেন্ট সরাতে চান এবং এইসব এলিমেন্টের জন্য পছন্দসই ফিজিক্যাল আচরণ কেমন হবে তার উপর নির্ভর করে সঠিক API বেছে নেওয়া হয়।

Modifier.scrollable2D: কন্টেনারের মধ্যে থাকা কন্টেন্ট সরাতে, কন্টেনারের উপর এই মডিফায়ার ব্যবহার করুন। যেমন, এটি ম্যাপ, স্প্রেডশিট বা ফটো ভিউয়ারের সাথে ব্যবহার করুন, যেখানে কন্টেনারের কন্টেন্টকে অনুভূমিক ও উল্লম্ব, উভয় দিকেই স্ক্রল করতে হয়। এতে বিল্ট-ইন ফ্লিং সাপোর্ট অন্তর্ভুক্ত থাকে, তাই সোয়াইপ করার পরেও কন্টেন্ট চলতে থাকে এবং এটি পৃষ্ঠার অন্যান্য স্ক্রল করা কম্পোনেন্টের সাথে কোঅর্ডিনেট করে।

Modifier.draggable2D: কোনও কম্পোনেন্ট সরাতে এই মডিফায়ার ব্যবহার করুন। এটি একটি হালকা মডিফায়ার, তাই ব্যবহারকারীর আঙুল থামলেই মুভমেন্ট বন্ধ হয়ে যায়। এতে ফ্লিং সাপোর্ট অন্তর্ভুক্ত থাকে না।

আপনি কোনও কম্পোনেন্টকে টেনে নিয়ে যাওয়ার উপযুক্ত করতে চাইলে, কিন্তু ফ্লিং বা নেস্টেড স্ক্রল সাপোর্টের প্রয়োজন না হলে, draggable2D ব্যবহার করুন।

2D মডিফায়ার প্রয়োগ করা

2D মডিফায়ার কীভাবে ব্যবহার করতে হয় তা দেখানোর জন্য নিম্নলিখিত বিভাগে উদাহরণ দেওয়া হয়েছে।

প্রয়োগ করুন Modifier.scrollable2D

যেসব কন্টেনারে ব্যবহারকারীকে সব দিকে কন্টেন্ট সরাতে হবে, সেখানে এই মডিফায়ার ব্যবহার করুন।

2D মুভমেন্ট ডেটা ক্যাপচার করা

এই উদাহরণে দেখানো হয়েছে কীভাবে কাঁচা 2D মুভমেন্ট ডেটা ক্যাপচার করতে হয় এবং 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 কম্পোনেন্ট সেই offset স্টেটের বর্তমান X ও Y ভ্যালু দেখায়, যা ব্যবহারকারী ড্র্যাগ করার সাথে সাথে রিয়েল-টাইমে আপডেট হয়।

বড় ভিউপোর্ট প্যান করা

এই উদাহরণে দেখানো হয়েছে যে কীভাবে ক্যাপচার করা টু-ডাইমেনশনাল স্ক্রল করা যায় এমন ডেটা ব্যবহার করতে হয় এবং কন্টেন্ট যদি তার পেরেন্ট কন্টেনারের থেকে বড় হয়, তাহলে কীভাবে তাতে 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-এর জন্য ল্যাম্বডাকে শুধুমাত্র ব্যবহৃত ডেল্টা রিটার্ন করতে হবে, যাতে সন্তান সীমায় পৌঁছে গেলে প্যারেন্ট তালিকা স্বাভাবিকভাবে দায়িত্ব নিতে পারে।
  • কোনও ব্যবহারকারী যখন তির্যকভাবে ফ্লিং করেন, তখন 2D ভেলোসিটি শেয়ার করা হয়। অ্যানিমেশন চলাকালীন বাচ্চা কোনও সীমানায় পৌঁছালে, অবশিষ্ট মোমেন্টামকে প্রোপাগেট করা হয় যাতে অভিভাবক স্বাভাবিকভাবে স্ক্রল করা চালিয়ে যেতে পারেন।

@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)
        )
    }
}

ছবি ৫. একটি উল্লম্ব স্ক্রল করা তালিকার মধ্যে একটি বেগুনি রঙের বক্স যা ইন্টার্নাল 2D মুভমেন্টের অনুমতি দেয়, কিন্তু বক্সের ইন্টার্নাল Y-অফসেট তার ৩০০-পিক্সেল সীমাতে পৌঁছালে প্যারেন্ট লিস্টে উল্লম্ব স্ক্রল কন্ট্রোল পাস করে।

আগের স্নিপেটে:

  • 2D কম্পোনেন্ট ইন্টার্নালি প্যান করার জন্য X অ্যাক্সিস মুভমেন্ট ব্যবহার করতে পারে, একই সাথে চাইল্ডের নিজস্ব ভার্টিক্যাল বাউন্ডারি পৌঁছে গেলে প্যারেন্ট লিস্টে Y অ্যাক্সিস মুভমেন্ট ডিসপ্যাচ করতে পারে।
  • 2D সারফেসের মধ্যে ব্যবহারকারীকে আটকে না রেখে, সিস্টেম ব্যবহৃত ডেল্টা গণনা করে এবং অবশিষ্ট অংশটি হায়ারার্কি অনুযায়ী পাস করে। এর ফলে ব্যবহারকারী আঙুল না তুলেই পৃষ্ঠার বাকি অংশ স্ক্রল করা চালিয়ে যেতে পারেন।

প্রয়োগ করুন Modifier.draggable2D

আলাদা আলাদা UI এলিমেন্ট সরানোর জন্য draggable2D মডিফায়ার ব্যবহার করুন।

কম্পোজ করা যায় এমন এলিমেন্ট টেনে আনুন

এই উদাহরণে draggable2D-এর সবচেয়ে সাধারণ ব্যবহারের ক্ষেত্র দেখানো হয়েছে — কোনও ব্যবহারকারীকে UI এলিমেন্ট বেছে নিতে এবং পেরেন্ট কন্টেনারের মধ্যে যেকোনও জায়গায় সেটি আবার পজিশন করতে দেওয়া।

@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 ব্যবহার করে 2D ইনপুট এরিয়া তৈরি করতে হয় যেখানে একটি নির্দিষ্ট সারফেসের মধ্যে একটি সিলেক্টর নব সীমাবদ্ধ থাকে। ড্র্যাগ করা যায় এমন এলিমেন্টের উদাহরণে কম্পোনেন্টটি নিজেই সরে যায়, কিন্তু এই ইমপ্লিমেন্টেশনে ২ডি ডেল্টা ব্যবহার করে কালার পিকার জুড়ে চাইল্ড কম্পোজ করা যায় এমন "সিলেক্টর" সরানো হয়:

@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)
                    }
                )
        )
    }
}

ছবি ৭. সাদা বৃত্তাকার সিলেক্টর নব সহ একটি কালার গ্রেডিয়েন্ট যা যেকোনও দিকে টেনে আনা যেতে পারে, এটি দেখায় যে কীভাবে 2D ডেল্টা কন্টেনারের সীমানায় ক্ল্যাম্প করা হয় যাতে বেছে নেওয়া রঙের মান আপডেট করা যায়।

পূর্ববর্তী স্নিপেটে নিম্নলিখিত বিষয়গুলি অন্তর্ভুক্ত রয়েছে:

  • এটি onSizeChanged মডিফায়ার ব্যবহার করে গ্রেডিয়েন্ট কন্টেনারের আসল ডাইমেনশন ক্যাপচার করে। সিলেকটরটি ঠিক কোথায় কোথায় প্রান্ত আছে তা জানে।
  • graphicsLayer-এর মধ্যে, এটি translationX ও translationY অ্যাডজাস্ট করে যাতে নিশ্চিত করা যায় যে ড্র্যাগ করার সময় সিলেক্টরটি সেন্টারে আছে।