Thành phần kết hợp kéo

Việc cho phép người dùng kéo các thành phần trên màn hình là một tính năng cơ bản của các ứng dụng Android hiện đại. Cho dù bạn đang xây dựng một thanh trượt tuỳ chỉnh, một thành phần vuốt để đóng hay một canvas tương tác, Jetpack Compose đều cung cấp một bộ API toàn diện để dễ dàng xử lý các sự kiện kéo.

API kéo của Compose giúp đơn giản hoá sự phức tạp của các sự kiện chạm thô. Thay vì tính toán toạ độ chạm và quản lý vòng đời cử chỉ theo cách thủ công bằng pointerInput, bạn có thể sử dụng các đối tượng sửa đổi tích hợp sẵn liên kết trực tiếp với hệ thống quản lý trạng thái và ảnh động của Compose.

Trang này mô tả cách thức và thời điểm sử dụng từng API kéo.

Chọn API kéo phù hợp

Có một số API mà bạn có thể dùng để triển khai nội dung có thể kéo.

  • Chọn các thành phần Material chuẩn (như SwipeToDismissBox hoặc ngăn điều hướng) khi: Bạn cần hành vi vuốt hoặc kéo Material 3 mặc định (ví dụ: xoá email, lưu trữ nhiệm vụ hoặc mở trình đơn bên / bảng dưới cùng). Các thành phần này cung cấp các hành vi tích hợp sẵn mà không cần quản lý trạng thái hoặc bố cục tuỳ chỉnh.
  • Chọn Modifier.draggable khi: Bạn chỉ cần các delta kéo thô (ví dụ: kéo thanh trượt tuỳ chỉnh). Để biết thêm thông tin, hãy xem bài viết Kéo dọc theo một trục bằng đối tượng có thể kéo.
    • Chọn Modifier.draggable2D khi: Bạn cần một thành phần di chuyển liên tục trên cả trục X và Y cùng một lúc (ví dụ: kéo qua bản đồ hoặc tạo nút hành động nổi mà người dùng có thể kéo đến bất cứ đâu trên màn hình). Để biết thêm thông tin, hãy xem bài viết Kéo theo hai chiều bằng Modifier.draggable2D.
  • Chọn Modifier.anchoredDraggable khi: Bạn đang tạo các thành phần tương tác tuỳ chỉnh cần điều chỉnh nhanh giữa hai hoặc nhiều vị trí riêng biệt (ví dụ: bảng dưới cùng, ngăn điều hướng hoặc thao tác vuốt để hiển thị). Để biết thêm thông tin, hãy xem bài viết Kéo các thành phần giữa các điểm neo bằng anchoredDraggable.

Kéo dọc theo một trục bằng draggable

Phương thức sửa đổi draggable là điểm truy cập cấp cao cho các cử chỉ kéo theo một hướng đồng thời báo cáo khoảng cách kéo bằng pixel.

Quan trọng là bạn phải lưu ý rằng phương thức sửa đổi này tương tự như scrollable, vì phương thức này chỉ phát hiện cử chỉ. Bạn cần giữ và biểu thị trạng thái trên màn hình bằng cách di chuyển phần tử qua đối tượng sửa đổi offset:

@Composable
private fun DraggableText() {
    var offsetX by remember { mutableFloatStateOf(0f) }
    Text(
        modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), 0) }
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            ),
        text = "Drag me!"
    )
}

Nếu bạn cần điều khiển toàn bộ cử chỉ kéo, hãy cân nhắc chuyển sang sử dụng trình phát hiện cử chỉ kéo, thông qua đối tượng sửa đổi pointerInput.

@Composable
private fun DraggableTextLowLevel() {
    Box(modifier = Modifier.fillMaxSize()) {
        var offsetX by remember { mutableFloatStateOf(0f) }
        var offsetY by remember { mutableFloatStateOf(0f) }

        Box(
            Modifier
                .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
                .background(Color.Blue)
                .size(50.dp)
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
        )
    }
}

CHÈN VĂN BẢN THAY THẾ VÀO ĐÂY
Hình 1. Một phần tử trên giao diện người dùng bị kéo bằng ngón tay

Kéo các thành phần giữa các điểm neo bằng anchoredDraggable

Đối tượng sửa đổi anchoredDraggable cho phép bạn kéo một thành phần giữa một tập hợp các vị trí được xác định trước, gọi là điểm neo. Khi người dùng thả cử chỉ kéo, thành phần sẽ ổn định ở điểm neo gần nhất. anchoredDraggable rất hữu ích khi tạo các thành phần như bảng dưới cùng tuỳ chỉnh, thanh trượt phức tạp hoặc bảng điều khiển ngăn kéo.

Nền tảng API

AnchoredDraggable API bao gồm các phần chính sau:

  • Điểm neo (DraggableAnchors): Trình tạo xác định mối liên kết giữa các trạng thái tuỳ chỉnh (ví dụ: lớp enum cho Start, Center, End) và giá trị bù trừ pixel tương ứng.
  • Trạng thái (AnchoredDraggableState<T>): Một phần tử giữ trạng thái quản lý vị trí hiện tại của thành phần (độ lệch), điểm neo mà thành phần đó được đặt và tiến trình kéo. Bạn nên tạo trạng thái này bằng rememberSaveable và AnchoredDraggableState.Saver<T> để duy trì trạng thái khi có thay đổi về cấu hình.
  • Đối tượng sửa đổi cử chỉ (Modifier.anchoredDraggable): Đối tượng sửa đổi phát hiện cử chỉ kéo dọc theo Orientation, cập nhật trạng thái bằng delta kéo và xử lý cử chỉ hất để tạo ảnh động cho thành phần đến điểm neo gần nhất khi thả ra.

Ví dụ: Tạo thành phần SwipeToDismiss tuỳ chỉnh

Ví dụ này minh hoạ cách tạo một thành phần SwipeToDismiss tuỳ chỉnh (biểu ngữ màu trắng) mà người dùng có thể vuốt sang trái để đóng hoặc kéo sang phải để xem hiệu ứng kéo giãn khi cuộn quá mức.

  1. Xác định giá trị neo. Xác định một enum đại diện cho các vị trí riêng biệt mà nội dung có thể kéo có thể ổn định:

    private enum class AnchoredDraggableSwipeDismissValue { DismissedStart, Resting }

  2. Tạo thực thể và lưu AnchoredDraggableState. Sử dụng rememberSaveable với AnchoredDraggableState.Saver() để duy trì vị trí hiện tại của thành phần trong quá trình tạo lại hoạt động và thay đổi cấu hình (ví dụ: xoay màn hình hoặc thay đổi giao diện):

    val state =
        rememberSaveable(saver = AnchoredDraggableState.Saver()) {
            AnchoredDraggableState(initialValue = AnchoredDraggableSwipeDismissValue.Resting)
        }

  3. Áp dụng các đối tượng sửa đổi layout, offset và draggable. Để thành phần có thể kéo được và đảm bảo các điểm neo của thành phần đó thích ứng với kích thước của thành phần, hãy áp dụng các đối tượng sửa đổi sau cho nội dung cần di chuyển một cách trực quan:

    • Modifier.layout: Vị trí neo thường phụ thuộc vào kích thước của thành phần, điều này không được biết trong quá trình kết hợp ban đầu. Sử dụng đối tượng sửa đổi layout để đo lường thành phần, tính toán bản đồ DraggableAnchors một cách linh động dựa trên chiều rộng của thành phần và cập nhật trạng thái bằng state.updateAnchors().

    • Modifier.anchoredDraggable: Kết nối với trạng thái, gửi các delta kéo theo hướng đã chỉ định và xử lý cử chỉ hất để tạo ảnh động cho thành phần đến điểm neo gần nhất khi được thả ra. Thêm đối tượng sửa đổi này vào thành phần cần phản hồi cử chỉ kéo và truyền cho thành phần đó state và kéo orientation (và overscrollEffect, không bắt buộc).
    • Modifier.offset: Đọc độ lệch từ state.requireOffset() bên trong lambda độ lệch. Việc đọc độ dời trong hàm lambda này đảm bảo giá trị chỉ được đọc trong giai đoạn đặt, tránh việc tái cấu trúc không cần thiết trong quá trình kéo. Áp dụng đối tượng sửa đổi này cho nội dung cần di chuyển một cách trực quan.

    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }

  4. [Không bắt buộc] Tích hợp hiệu ứng cuộn quá mức. API overscrollEffect triển khai hiệu ứng hình ảnh xuất hiện khi các cạnh của vùng chứa cuộn đã được tiếp cận bằng thao tác cuộn hoặc vuốt. Bạn có thể cung cấp phản hồi trực quan khi người dùng kéo qua ranh giới neo đã xác định bằng hiệu ứng cuộn quá mức (thường là kéo giãn hoặc phát sáng).

    1. Gửi: Truyền một thực thể overscrollEffect đến đối tượng sửa đổi anchoredDraggable. Thao tác này sẽ gửi các delta kéo đến trạng thái AnchoredDraggable hoặc hiệu ứng cuộn quá mức, tuỳ thuộc vào việc cử chỉ nằm bên trong hay bên ngoài giới hạn neo.
    2. Hiển thị: Áp dụng Modifier.overscroll có cùng thực thể OverscrollEffect cho thành phần sẽ hiển thị hiệu ứng một cách trực quan.

Đoạn mã sau đây cho biết cách thêm hiệu ứng kéo giãn tiêu chuẩn:

val overscrollEffect = rememberOverscrollEffect()
Box(
    modifier.fillMaxWidth()
) {
    Box(Modifier.fillMaxWidth()) {
        background()
    }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            // Read state.requireOffset() inside the offset lambda to defer reading to the placement phase
            .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val anchors = DraggableAnchors {
                    AnchoredDraggableSwipeDismissValue.DismissedStart at -placeable.width.toFloat()
                    AnchoredDraggableSwipeDismissValue.Resting at 0f
                }
                state.updateAnchors(anchors)
                layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) }
            }
            // Pass overscrollEffect to anchoredDraggable so deltas beyond the bounds (min and max anchor) are dispatched to overscrollEffect, and render the visual effect with Modifier.overscroll.
            .overscroll(overscrollEffect)
            .anchoredDraggable(
                state,
                Orientation.Horizontal,
                overscrollEffect = overscrollEffect
            )
    ) {
        dismissContent()
    }
}

Kết quả

Đoạn mã trước đó tạo ra một biểu ngữ màu trắng, có văn bản "Drag me to dismiss!" (Kéo tôi để đóng!), người dùng có thể vuốt sang trái để đóng (hoặc kéo sang phải để xem hiệu ứng kéo giãn khi cuộn quá mức):

Video 2. Cách triển khai thành phần vuốt để đóng tuỳ chỉnh.

Ví dụ: Tạo ngăn điều hướng tuỳ chỉnh, dựa trên cử chỉ

Ví dụ này minh hoạ cách tạo một ngăn điều hướng tuỳ chỉnh, dựa trên cử chỉ. Khi người dùng kéo theo chiều ngang, màn hình nền trước (ScreenContents) sẽ trượt sang phải, thu nhỏ từ 1.0f xuống 0.8f và bo tròn các góc để hiển thị ngăn điều hướng bên dưới.

  1. Xác định trạng thái và vị trí của neo. Trong ví dụ này, các điểm neo là trạng thái mở và đóng của ngăn. Xác định enum cho trạng thái Open và Closed, đồng thời liên kết các trạng thái đó với độ lệch pixel bằng DraggableAnchors:

    enum class DrawerState {
        Open,
        Closed
    }
    
        val anchors = DraggableAnchors {
            DrawerState.Open at drawerWidth
            DrawerState.Closed at 0f
        }
        val state = remember {
            AnchoredDraggableState(
                initialValue = DrawerState.Closed,
                anchors = anchors,
            )
        }

  2. Tạo ảnh động cho anchoredDraggableState khi nhấp vào một mục. Đặt HomeScreenDrawer ở cuối Box và ScreenContents ở trên cùng. Ngăn điều hướng vẫn giữ nguyên, trong khi lớp trên cùng dịch chuyển và điều chỉnh tỷ lệ trên đó.

    Khi người dùng chọn một mục trong ngăn kéo, hãy cập nhật trạng thái màn hình đang hoạt động và tự động đóng ngăn kéo bằng state.animateTo(DrawerState.Closed):

    @Composable
    private fun AnchoredDraggableDrawerOnClickSample(
        modifier: Modifier = Modifier,
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        var screenState by remember { mutableStateOf(ScreenState.Home) }
    
        Box(modifier = modifier.fillMaxSize()) {
            HomeScreenDrawer(
                selectedScreen = screenState,
                onScreenSelected = { screen ->
                    screenState = screen
                    // Dismiss drawer when a destination is selected
                    coroutineScope.launch { state.animateTo(DrawerState.Closed) }
                }
            )
    
            // ScreenContents(...)
        }
    }

  3. Sử dụng state.requireOffset() để thay đổi nhiều thuộc tính hình ảnh. Thay vì chỉ bù trừ nội dung, bạn có thể thay đổi nhiều thuộc tính bằng Modifier.graphicsLayer để đồng thời tạo ảnh động cho bản dịch, tỷ lệ và bán kính góc dựa trên tiến trình kéo:

    • Sử dụng state.requireOffset() để lấy độ lệch kéo hiện tại.
    • Sử dụng lerp() (phép nội suy tuyến tính) để tính tỷ lệ tương ứng (1f đến 0.8f) và bán kính góc (0.dp đến 32.dp) so với độ lệch và chiều rộng ngăn kéo.
    • Modifier.graphicsLayer thực thi các thay đổi trong giai đoạn vẽ, đảm bảo ảnh động có hiệu suất cao mà không kích hoạt quá trình kết hợp lại trong quá trình kéo.

    @Composable
    private fun AnchoredDraggableDrawerGraphicsLayerSample(
        state: AnchoredDraggableState<DrawerState>,
        drawerWidth: Float,
        selectedScreen: ScreenState,
        onDrawerClicked: () -> Unit
    ) {
        ScreenContents(
            selectedScreen = selectedScreen,
            onDrawerClicked = onDrawerClicked,
            modifier = Modifier
                .graphicsLayer {
                    this.translationX = state.requireOffset()
                    val scale = lerp(1f, 0.8f, state.requireOffset() / drawerWidth)
                    this.scaleX = scale
                    this.scaleY = scale
                    val cornerRadius = lerp(0f, 32f, state.requireOffset() / drawerWidth)
                    this.shape = RoundedCornerShape(cornerRadius.dp)
                    this.clip = true
                }
                .anchoredDraggable(state, Orientation.Horizontal)
        )
    }

  4. Kích hoạt ảnh động có lập trình bằng animateTo. Kiểm soát ngăn điều hướng theo phương thức lập trình (chẳng hạn như khi nhấn vào biểu tượng trình đơn hoặc chọn một mục trong ngăn điều hướng) bằng cách chạy state.animateTo() trong phạm vi coroutine:

    fun toggleDrawerState(
        state: AnchoredDraggableState<DrawerState>,
        coroutineScope: CoroutineScope
    ) {
        coroutineScope.launch {
            if (state.currentValue == DrawerState.Open) {
                state.animateTo(DrawerState.Closed)
            } else {
                state.animateTo(DrawerState.Open)
            }
        }
    }

Kết quả

Video 3. Màn hình nền trước (Sleep) được kéo sang phải, cho thấy ngăn điều hướng bên dưới.

Kéo theo hai chiều bằng Modifier.draggable2D

Sử dụng đối tượng sửa đổi draggable2D để di chuyển đồng thời từng phần tử trên giao diện người dùng trên cả trục X và Y.

Kéo một thành phần kết hợp

Ví dụ này cho thấy trường hợp sử dụng phổ biến nhất của 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à đặt lại vị trí của phần tử đó ở bất kỳ đâu 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)
        }
    }
}

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

Đoạn mã trước đó bao gồm:

  • 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 để dịch chuyển vị trí của thành phần dựa trên delta kéo.
  • Vì không có tính năng hỗ trợ vuốt nhanh, 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 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 chọn bị ràng buộc trong một bề mặt cụ thể. Không giống như ví dụ về thành phần kéo được (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 "bộ chọn" thành phần kết hợp con trên bộ 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)
                    }
                )
        )
    }
}

Video 5. Một độ dốc màu có núm chọn hình tròn màu trắng có thể kéo theo bất kỳ hướng nào, minh hoạ cách các delta 2D được giới hạn giá trị 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ước đó bao gồm:

  • Hàm 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 dải chuyển màu. Bộ chọn biết chính xác vị trí của các cạnh.
  • Bên trong graphicsLayer, hàm này điều chỉnh translationX và translationY để đảm bảo bộ chọn luôn ở giữa trong khi kéo.