การเลื่อนแบบ 2 มิติ: scrollable2D, draggable2D

ใน Jetpack Compose, scrollable2D และ draggable2D เป็น ตัวปรับแต่งระดับต่ำที่ออกแบบมาเพื่อจัดการอินพุตตัวชี้ใน 2 มิติ แม้ว่าตัวปรับแต่ง 1 มิติมาตรฐาน scrollable และ draggable จะจำกัดไว้ที่การวางแนวเดียว แต่ตัวปรับแต่ง 2 มิติจะติดตามการเคลื่อนไหวทั้งแกน X และแกน Y พร้อมกัน

ตัวอย่างเช่น ตัวปรับแต่ง scrollable ที่มีอยู่ใช้สำหรับการเลื่อนและการปัดในแนวเดียว ขณะที่ scrollable2d ใช้สำหรับการเลื่อนและการปัด ใน 2 มิติ ซึ่งช่วยให้คุณสร้างเลย์เอาต์ที่ซับซ้อนมากขึ้นซึ่งเคลื่อนที่ได้ทุกทิศทาง เช่น สเปรดชีตหรือโปรแกรมดูรูปภาพ นอกจากนี้ ตัวปรับแต่ง scrollable2d ยังรองรับการเลื่อนที่ซ้อนกันในสถานการณ์ 2 มิติด้วย

รูปที่ 1 การเลื่อนสองทิศทางบนแผนที่

เลือก scrollable2D หรือ draggable2D

การเลือก API ที่เหมาะสมขึ้นอยู่กับองค์ประกอบ UI ที่คุณต้องการย้ายและลักษณะการทำงานทางกายภาพที่ต้องการสำหรับองค์ประกอบเหล่านี้

Modifier.scrollable2D: ใช้ตัวปรับแต่งนี้กับคอนเทนเนอร์เพื่อย้ายเนื้อหาภายใน เช่น ใช้กับแผนที่ สเปรดชีต หรือโปรแกรมดูรูปภาพ ซึ่งเนื้อหาของคอนเทนเนอร์ต้องเลื่อนทั้งในแนวนอนและแนวตั้ง ตัวปรับแต่งนี้มีการรองรับการปัดในตัวเพื่อให้เนื้อหาเคลื่อนที่ต่อไปหลังจากการปัด และทำงานร่วมกับคอมโพเนนต์การเลื่อนอื่นๆ ในหน้า

Modifier.draggable2D: ใช้ตัวปรับแต่งนี้เพื่อย้ายคอมโพเนนต์เอง ตัวปรับแต่งนี้เป็นตัวปรับแต่งที่มีน้ำหนักเบา ดังนั้นการเคลื่อนไหวจะหยุดลงทันทีที่นิ้วของผู้ใช้หยุด และไม่มีการรองรับการปัด

หากต้องการทำให้คอมโพเนนต์ลากได้ แต่ไม่จำเป็นต้องมีการรองรับการปัดหรือการเลื่อนที่ซ้อนกัน ให้ใช้ draggable2D

ใช้ตัวปรับแต่ง 2 มิติ

ส่วนต่อไปนี้มีตัวอย่างเพื่อแสดงวิธีใช้ตัวปรับแต่ง 2 มิติ

ใช้ Modifier.scrollable2D

ใช้ตัวปรับแต่งนี้กับคอนเทนเนอร์ที่ผู้ใช้ต้องย้ายเนื้อหาได้ทุกทิศทาง

บันทึกข้อมูลการเคลื่อนไหว 2 มิติ

ตัวอย่างนี้แสดงวิธีบันทึกข้อมูลการเคลื่อนไหว 2 มิติแบบดิบและแสดงออฟเซ็ต 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 ซึ่งจะอัปเดตแบบเรียลไทม์ขณะที่ผู้ใช้ลาก

เลื่อนวิวพอร์ตขนาดใหญ่

ตัวอย่างนี้แสดงวิธีใช้ข้อมูลที่เลื่อนได้ 2 มิติที่บันทึกไว้และใช้ 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 จะถูกซ่อนไว้ไม่ให้เห็น
  • scrollable2D จะไม่ย้ายเนื้อหาให้คุณโดยอัตโนมัติ ซึ่งแตกต่างจากคอมโพเนนต์ระดับสูง เช่น LazyColumn แต่คุณต้องใช้ offset ที่ติดตามกับเนื้อหา โดยใช้การแปลง graphicsLayer หรือออฟเซ็ตเลย์เอาต์
  • ภายในบล็อก graphicsLayer ตัวปรับแต่ง translationX = offset.value.x และ translationY = offset.value.y จะเลื่อนตำแหน่งการวาดรูปภาพหรือข้อความตามการเคลื่อนไหวของนิ้ว ซึ่งสร้างเอฟเฟกต์ภาพของการเลื่อน

ใช้การเลื่อนที่ซ้อนกันกับ scrollable2D

ตัวอย่างนี้แสดงวิธีผสานรวมคอมโพเนนต์สองทิศทางเข้ากับคอมโพเนนต์หลักมาตรฐานหนึ่งมิติ เช่น ฟีดข่าวแนวตั้ง

โปรดคำนึงถึงประเด็นต่อไปนี้เมื่อใช้การเลื่อนที่ซ้อนกัน

  • แลมบ์ดาสำหรับ rememberScrollable2DState ควรแสดงผลเฉพาะเดลต้า ที่ใช้ เพื่อให้รายการระดับบนสุดเข้ามาควบคุมโดยธรรมชาติ เมื่อรายการระดับล่างถึง ขีดจำกัด
  • เมื่อผู้ใช้ปัดในแนวทแยง ระบบจะแชร์ความเร็ว 2 มิติ หากรายการระดับล่างชนขอบเขตระหว่างภาพเคลื่อนไหว ระบบจะส่งต่อโมเมนตัมที่เหลือ ไปยังรายการระดับบนสุดเพื่อให้เลื่อนต่อไปตามธรรมชาติ

@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 กล่องสีม่วงภายในรายการเลื่อนแนวตั้งที่อนุญาตให้เคลื่อนไหว 2 มิติภายใน แต่จะส่งการควบคุมการเลื่อนแนวตั้งไปยังรายการระดับบนสุดเมื่อออฟเซ็ต Y ภายในของกล่องถึงขีดจำกัด 300 พิกเซล

ในข้อมูลโค้ดก่อนหน้า

  • คอมโพเนนต์ 2 มิติสามารถใช้การเคลื่อนไหวแกน X เพื่อเลื่อนภายใน ขณะเดียวกันก็ส่งการเคลื่อนไหวแกน Y ไปยังรายการระดับบนสุดเมื่อถึงขอบเขตแนวตั้งของคอมโพเนนต์
  • ระบบจะคำนวณเดลต้าที่ใช้และส่งส่วนที่เหลือขึ้นไปตามลำดับชั้น แทนที่จะดักผู้ใช้ไว้ภายในพื้นผิว 2 มิติ ซึ่งจะช่วยให้ผู้ใช้เลื่อนดูส่วนอื่นๆ ของหน้าได้โดยไม่ต้องยกนิ้ว

ใช้ Modifier.draggable2D

ใช้ตัวปรับแต่ง draggable2D เพื่อย้ายองค์ประกอบ UI แต่ละรายการ

ลากองค์ประกอบ Composable

ตัวอย่างนี้แสดง Use Case ที่พบบ่อยที่สุดสำหรับ 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)
        }
    }
}

รูปที่ 6 กล่องสีม่วงขนาดเล็กที่จัดตำแหน่งใหม่บนพื้นหลังสีเทา ซึ่งแสดงการลาก 2 มิติโดยตรงที่องค์ประกอบจะหยุดเคลื่อนที่ทันทีที่ผู้ใช้ยกนิ้ว

ข้อมูลโค้ดก่อนหน้ามีสิ่งต่อไปนี้

  • ติดตามตำแหน่งของกล่องโดยใช้สถานะ offset
  • ใช้ตัวปรับแต่ง offset เพื่อเลื่อนตำแหน่งของคอมโพเนนต์ตามเดลต้าการลาก
  • เนื่องจากไม่มีการรองรับการปัด กล่องจึงหยุดเคลื่อนที่ทันทีที่ผู้ใช้ยกนิ้ว

ลากคอมโพเนนต์ย่อยตามพื้นที่การลากของคอมโพเนนต์ระดับบนสุด

ตัวอย่างนี้แสดงวิธีใช้ draggable2D เพื่อสร้างพื้นที่อินพุต 2 มิติที่ปุ่มเลือกถูกจำกัดไว้ภายในพื้นผิวที่เฉพาะเจาะจง การติดตั้งใช้งานนี้ใช้เดลต้า 2 มิติเพื่อย้าย "ตัวเลือก" คอมโพสที่ย่อยไปตามเครื่องมือเลือกสี ซึ่งแตกต่างจากตัวอย่างองค์ประกอบที่ลากได้ซึ่งย้ายคอมโพเนนต์เอง

@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 การไล่ระดับสีพร้อมปุ่มเลือกสีขาวทรงกลมที่ลากได้ทุกทิศทาง ซึ่งแสดงวิธีหนีบเดลต้า 2 มิติไว้ที่ขอบเขตของคอนเทนเนอร์เพื่ออัปเดตค่าสีที่เลือก

ข้อมูลโค้ดก่อนหน้ามีสิ่งต่อไปนี้

  • ใช้ตัวปรับแต่ง onSizeChanged เพื่อบันทึกขนาดจริงของคอนเทนเนอร์การไล่ระดับสี ตัวเลือกจะทราบตำแหน่งที่แน่นอนของขอบ
  • ภายใน graphicsLayer ตัวปรับแต่ง translationX และ translationY จะปรับเพื่อให้ตัวเลือกอยู่ตรงกลางขณะลาก