Thao tác cuộn hai chiều: scrollable2D, draggable2D

Trong Jetpack Compose, scrollable2Ddraggable2D là các đối tượng sửa đổi cấp thấp được thiết kế để xử lý dữ liệu đầu vào của con trỏ theo hai chiều. Mặc dù các đối tượng sửa đổi 1D tiêu chuẩn scrollabledraggable chỉ giới hạn ở một hướng duy nhất, nhưng các biến thể 2D theo dõi chuyển động đồng thời trên cả trục X và Y.

Ví dụ: đối tượng sửa đổi scrollable hiện có được dùng để cuộn và hất theo một hướng, còn scrollable2d được dùng để cuộn và hất theo 2 hướng. Điều này cho phép bạn tạo các bố cục phức tạp hơn di chuyển theo mọi hướng, chẳng hạn như bảng tính hoặc trình xem hình ảnh. Đối tượng sửa đổi scrollable2d cũng hỗ trợ thao tác cuộn lồng nhau trong các trường hợp 2D.

Hình 1. Thao tác di chuyển hai chiều trên bản đồ.

Chọn scrollable2D hoặc draggable2D

Việc chọn API phù hợp phụ thuộc vào các phần tử trên giao diện người dùng mà bạn muốn di chuyển và hành vi thực tế mà bạn muốn cho các phần tử này.

Modifier.scrollable2D: Sử dụng đối tượng sửa đổi này trên một vùng chứa để di chuyển nội dung bên trong vùng chứa đó. Ví dụ: sử dụng thuộc tính này với bản đồ, bảng tính hoặc trình xem ảnh, trong đó nội dung của vùng chứa cần di chuyển theo cả hướng ngang và hướng dọc. Thành phần này có hỗ trợ thao tác hất tích hợp để nội dung tiếp tục di chuyển sau khi bạn vuốt và phối hợp với các thành phần cuộn khác trên trang.

Modifier.draggable2D: Sử dụng đối tượng sửa đổi này để di chuyển chính thành phần. Đây là một đối tượng sửa đổi có kích thước nhỏ, vì vậy chuyển động sẽ dừng chính xác khi ngón tay của người dùng dừng lại. Không bao gồm tính năng truyền nội dung.

Nếu bạn muốn tạo một thành phần có thể kéo, nhưng không cần hỗ trợ thao tác hất hoặc cuộn lồng nhau, hãy dùng draggable2D.

Triển khai các giá trị bổ sung 2D

Các phần sau đây cung cấp ví dụ minh hoạ cách sử dụng các đối tượng sửa đổi 2D.

Triển khai Modifier.scrollable2D

Sử dụng đối tượng sửa đổi này cho những vùng chứa mà người dùng cần di chuyển nội dung theo mọi hướng.

Ghi lại dữ liệu chuyển động 2D

Ví dụ này cho thấy cách ghi lại dữ liệu chuyển động 2D thô và hiển thị độ lệch 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()}",
                    // ...
                )
            }
        }
    }
}

Hình 2. Một hộp màu tím theo dõi và hiển thị độ lệch toạ độ X và Y hiện tại khi người dùng kéo con trỏ trên bề mặt của hộp.

Đoạn mã trên thực hiện những việc sau:

  • Sử dụng offset làm trạng thái lưu giữ tổng khoảng cách mà người dùng đã cuộn.
  • Bên trong rememberScrollable2DState, một hàm lambda được xác định để xử lý mọi delta do ngón tay của người dùng tạo ra. Mã offset.value += delta cập nhật trạng thái thủ công bằng vị trí mới.
  • Các thành phần Text hiển thị các giá trị X và Y hiện tại của trạng thái offset đó, các giá trị này sẽ cập nhật theo thời gian thực khi người dùng kéo.

Lia một khung nhìn lớn

Ví dụ này cho thấy cách sử dụng dữ liệu có thể cuộn 2D đã chụp và áp dụng translationXtranslationY cho nội dung lớn hơn vùng chứa mẹ:

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

Hình 3. Khung nhìn hình ảnh có thể lia máy theo hai hướng, được tạo bằng Modifier.scrollable2D.
Hình 4. Khung nhìn văn bản có thể lia máy theo cả hai hướng, được tạo bằng Modifier.scrollable2D.

Đoạn mã trên bao gồm những nội dung sau:

  • Vùng chứa được đặt thành kích thước cố định (600x400dp), trong khi nội dung được đặt thành kích thước lớn hơn nhiều (1200x800dp) để tránh nội dung đổi kích thước thành kích thước của vùng chứa gốc.
  • Đối tượng sửa đổi clipToBounds() trên vùng chứa đảm bảo rằng mọi phần của nội dung lớn nằm ngoài hộp 600x400 đều bị ẩn khỏi chế độ xem.
  • Không giống như các thành phần cấp cao như LazyColumn, scrollable2D không tự động di chuyển nội dung cho bạn. Thay vào đó, bạn phải áp dụng offset được theo dõi cho nội dung của mình, bằng cách sử dụng các phép biến đổi graphicsLayer hoặc độ lệch bố cục.
  • Bên trong khối graphicsLayer, translationX = offset.value.xtranslationY = offset.value.y sẽ dịch chuyển vị trí vẽ của hình ảnh hoặc văn bản dựa trên chuyển động của ngón tay, tạo hiệu ứng hình ảnh cuộn.

Triển khai tính năng cuộn lồng bằng scrollable2D

Ví dụ này minh hoạ cách tích hợp một thành phần hai chiều vào một thành phần mẹ một chiều tiêu chuẩn, chẳng hạn như nguồn cấp tin tức dọc.

Khi triển khai tính năng cuộn lồng nhau, hãy lưu ý những điểm sau:

  • Lambda cho rememberScrollable2DState chỉ nên trả về delta đã dùng để danh sách mẹ tự động tiếp quản khi danh sách con đạt đến giới hạn.
  • Khi người dùng thực hiện thao tác hất theo đường chéo, vận tốc 2D sẽ được chia sẻ. Nếu thành phần con chạm vào một ranh giới trong quá trình chuyển động, thì động lượng còn lại sẽ được truyền đến thành phần mẹ để tiếp tục cuộn một cách tự nhiên.

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

Hình 5. Một hộp màu tím trong danh sách cuộn theo chiều dọc cho phép chuyển động 2D bên trong, nhưng chuyển quyền kiểm soát thao tác cuộn theo chiều dọc cho danh sách gốc khi độ lệch Y bên trong của hộp đạt đến giới hạn 300 pixel.

Trong đoạn mã trước đó:

  • Thành phần 2D có thể sử dụng chuyển động theo trục X để xoay nội bộ trong khi đồng thời gửi chuyển động theo trục Y đến danh sách mẹ sau khi đạt đến ranh giới dọc của thành phần con.
  • Thay vì giữ người dùng trong bề mặt 2D, hệ thống sẽ tính toán mức tiêu thụ delta và chuyển phần còn lại lên hệ thống phân cấp. Điều này đảm bảo người dùng có thể tiếp tục cuộn qua phần còn lại của trang mà không cần nhấc ngón tay lên.

Triển khai Modifier.draggable2D

Sử dụng đối tượng sửa đổi draggable2D để di chuyển từng phần tử trên giao diện người dùng.

Kéo một phần tử kết hợp

Ví dụ này cho thấy trường hợp sử dụng phổ biến nhất cho draggable2D – cho phép người dùng chọn một phần tử trên giao diện người dùng và định vị lại phần tử đó ở bất kỳ vị trí nào trong vùng chứa mẹ.

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

Hình 6. Một hộp nhỏ màu tím được định vị lại trên nền xám, minh hoạ thao tác kéo trực tiếp trong không gian 2D, trong đó phần tử sẽ ngừng di chuyển ngay khi người dùng nhấc ngón tay lên.

Đoạn mã trên bao gồm những nội dung sau:

  • Theo dõi vị trí của hộp bằng trạng thái offset.
  • Sử dụng đối tượng sửa đổi offset để thay đổi vị trí của thành phần dựa trên độ lệch khi kéo.
  • Vì không có chế độ hất, nên hộp sẽ ngừng di chuyển ngay khi người dùng nhấc ngón tay lên.

Kéo một thành phần kết hợp con dựa trên vùng kéo của thành phần kết hợp mẹ

Ví dụ này minh hoạ cách sử dụng draggable2D để tạo một vùng nhập 2D, trong đó núm bộ chọn bị ràng buộc trong một bề mặt cụ thể. Không giống như ví dụ về phần tử có thể kéo, di chuyển chính thành phần đó, cách triển khai này sử dụng các delta 2D để di chuyển một thành phần kết hợp con "bộ chọn" trên một công cụ chọn màu:

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

Hình 7. Một độ dốc màu có núm chọn hình tròn màu trắng có thể kéo theo mọi hướng, minh hoạ cách các delta 2D được cố định vào ranh giới của vùng chứa để cập nhật các giá trị màu đã chọn.

Đoạn mã trên bao gồm những nội dung sau:

  • Thành phần này sử dụng đối tượng sửa đổi onSizeChanged để ghi lại kích thước thực tế của vùng chứa gradient. Bộ chọn biết chính xác vị trí của các cạnh.
  • Bên trong graphicsLayer, nó điều chỉnh translationXtranslationY để đảm bảo bộ chọn luôn ở giữa trong khi kéo.