Tìm hiểu cử chỉ

Có một số thuật ngữ và khái niệm quan trọng mà bạn cần hiểu rõ khi xử lý cử chỉ trong một ứng dụng. Trang này giải thích các thuật ngữ về con trỏ, sự kiện con trỏ và cử chỉ, đồng thời giới thiệu các cấp độ trừu tượng khác nhau cho cử chỉ. Ngoài ra, API này cũng đi sâu hơn vào việc tiêu thụ và truyền tải sự kiện.

Định nghĩa

Để hiểu các khái niệm khác nhau trên trang này, bạn cần hiểu một số thuật ngữ được dùng:

  • Con trỏ: Một đối tượng thực mà bạn có thể sử dụng để tương tác với ứng dụng của mình. Đối với thiết bị di động, con trỏ phổ biến nhất là ngón tay tương tác với màn hình cảm ứng. Ngoài ra, bạn có thể dùng bút cảm ứng để thay thế ngón tay. Đối với màn hình lớn, bạn có thể dùng chuột hoặc bàn di chuột để tương tác gián tiếp với màn hình. Thiết bị đầu vào phải có khả năng "trỏ" vào một toạ độ để được coi là con trỏ, do đó, chẳng hạn như bàn phím không thể được coi là con trỏ. Trong Compose, loại con trỏ được đưa vào các thay đổi về con trỏ bằng cách sử dụng PointerType.
  • Sự kiện con trỏ: Mô tả hoạt động tương tác cấp thấp của một hoặc nhiều con trỏ với ứng dụng tại một thời điểm nhất định. Mọi hoạt động tương tác với con trỏ, chẳng hạn như đặt ngón tay lên màn hình hoặc kéo chuột, sẽ kích hoạt một sự kiện. Trong Compose, tất cả thông tin liên quan cho một sự kiện như vậy đều nằm trong lớp PointerEvent.
  • Cử chỉ: Một chuỗi các sự kiện con trỏ có thể được diễn giải là một hành động. Ví dụ: một cử chỉ nhấn có thể được coi là một chuỗi một sự kiện xuống, theo sau là một sự kiện lên. Có những cử chỉ phổ biến được nhiều ứng dụng sử dụng, chẳng hạn như nhấn, kéo hoặc biến đổi. Tuy nhiên, bạn cũng có thể tạo cử chỉ tuỳ chỉnh của riêng mình khi cần.

Các cấp độ trừu tượng khác nhau

Jetpack Compose cung cấp nhiều cấp độ trừu tượng để xử lý cử chỉ. Ở cấp cao nhất là hỗ trợ thành phần. Các thành phần kết hợp như Button sẽ tự động bao gồm tính năng hỗ trợ cử chỉ. Để hỗ trợ cử chỉ vào các thành phần tuỳ chỉnh, bạn có thể thêm đối tượng sửa đổi cử chỉ như clickable vào các thành phần kết hợp tuỳ ý. Cuối cùng, nếu cần một cử chỉ tuỳ chỉnh, bạn có thể sử dụng đối tượng sửa đổi pointerInput.

Theo quy tắc, hãy xây dựng dựa trên cấp độ trừu tượng cao nhất để cung cấp chức năng mà bạn cần. Bằng cách này, bạn được hưởng lợi từ các phương pháp hay nhất có trong lớp này. Ví dụ: Button chứa nhiều thông tin ngữ nghĩa hơn (dùng để hỗ trợ tiếp cận) so với clickable (chứa nhiều thông tin hơn so với cách triển khai pointerInput thô).

Hỗ trợ thành phần

Nhiều thành phần có sẵn trong Compose bao gồm một số loại cách xử lý cử chỉ nội bộ. Ví dụ: một LazyColumn sẽ phản hồi các cử chỉ kéo bằng cách cuộn nội dung, Button hiển thị một hiệu ứng gợn sóng khi bạn nhấn xuống phần tử đó và thành phần SwipeToDismiss chứa logic vuốt để đóng một phần tử. Kiểu xử lý cử chỉ này sẽ tự động hoạt động.

Bên cạnh cách xử lý cử chỉ nội bộ, nhiều thành phần cũng yêu cầu phương thức gọi xử lý cử chỉ. Ví dụ: Button sẽ tự động phát hiện các lượt nhấn và kích hoạt một sự kiện nhấp chuột. Bạn truyền hàm lambda onClick đến Button để phản ứng với cử chỉ. Tương tự, bạn thêm một hàm lambda onValueChange vào Slider để phản ứng với việc người dùng kéo thanh trượt.

Khi phù hợp với trường hợp sử dụng của bạn, hãy ưu tiên các cử chỉ được đưa vào thành phần, vì các cử chỉ này có sẵn khả năng hỗ trợ lấy nét và hỗ trợ tiếp cận, đồng thời các cử chỉ này đã được kiểm thử kỹ lưỡng. Ví dụ: Button được đánh dấu theo một cách đặc biệt để các dịch vụ hỗ trợ tiếp cận mô tả chính xác nút đó là một nút, thay vì chỉ là bất kỳ phần tử nào nhấp vào được:

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

Để tìm hiểu thêm về tính năng hỗ trợ tiếp cận trong Compose, hãy xem bài viết Hỗ trợ tiếp cận trong Compose.

Thêm các cử chỉ cụ thể vào thành phần kết hợp tuỳ ý bằng đối tượng sửa đổi

Bạn có thể áp dụng đối tượng sửa đổi cử chỉ cho bất kỳ thành phần kết hợp tuỳ ý nào để thành phần kết hợp nghe theo cử chỉ. Ví dụ: bạn có thể cho phép một Box chung xử lý cử chỉ nhấn bằng cách đặt clickable hoặc cho phép Column xử lý thao tác cuộn theo chiều dọc bằng cách áp dụng verticalScroll.

Có nhiều đối tượng sửa đổi để xử lý các loại cử chỉ khác nhau:

Theo quy tắc, hãy ưu tiên đối tượng sửa đổi cử chỉ có sẵn so với xử lý cử chỉ tuỳ chỉnh. Các đối tượng sửa đổi bổ sung thêm nhiều chức năng ngoài việc xử lý sự kiện con trỏ thuần tuý. Ví dụ: đối tượng sửa đổi clickable không chỉ thêm tính năng phát hiện các thao tác nhấn và nhấn, mà còn thêm thông tin ngữ nghĩa, chỉ báo trực quan về các lượt tương tác, di chuột, lấy tiêu điểm và hỗ trợ bàn phím. Bạn có thể kiểm tra mã nguồn của clickable để xem cách chức năng này được thêm.

Thêm cử chỉ tuỳ chỉnh vào các thành phần kết hợp tuỳ ý bằng đối tượng sửa đổi pointerInput

Không phải cử chỉ nào cũng được triển khai bằng đối tượng sửa đổi cử chỉ có sẵn. Ví dụ: bạn không thể sử dụng đối tượng sửa đổi để phản ứng với một thao tác kéo sau khi nhấn và giữ, nhấp kiểm soát hoặc nhấn bằng ba ngón tay. Thay vào đó, bạn có thể viết trình xử lý cử chỉ của riêng mình để xác định các cử chỉ tuỳ chỉnh này. Bạn có thể tạo một trình xử lý cử chỉ bằng đối tượng sửa đổi pointerInput để cho phép bạn truy cập vào các sự kiện con trỏ thô.

Mã sau đây nghe các sự kiện con trỏ thô:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

Nếu bạn chia nhỏ đoạn mã này thì các thành phần cốt lõi là:

  • Đối tượng sửa đổi pointerInput. Bạn truyền cho nó một hoặc nhiều khoá. Khi giá trị của một trong các khoá đó thay đổi, hàm lambda nội dung của đối tượng sửa đổi sẽ được thực thi lại. Mẫu này sẽ truyền một bộ lọc không bắt buộc đến thành phần kết hợp. Nếu giá trị của bộ lọc đó thay đổi, thì trình xử lý sự kiện con trỏ sẽ được thực thi lại để đảm bảo ghi lại các sự kiện phù hợp.
  • awaitPointerEventScope tạo một phạm vi coroutine có thể dùng để chờ các sự kiện con trỏ.
  • awaitPointerEvent sẽ tạm ngưng coroutine cho đến khi xảy ra sự kiện con trỏ tiếp theo.

Mặc dù việc nghe các sự kiện nhập thô rất hữu ích, nhưng việc viết một cử chỉ tuỳ chỉnh dựa trên dữ liệu thô này cũng rất phức tạp. Có nhiều phương thức tiện ích để đơn giản hoá việc tạo các cử chỉ tuỳ chỉnh.

Phát hiện toàn bộ cử chỉ

Thay vì xử lý các sự kiện con trỏ thô, bạn có thể lắng nghe các cử chỉ cụ thể sẽ xảy ra và phản hồi thích hợp. AwaitPointerEventScope cung cấp các phương thức để theo dõi:

Đây là các trình phát hiện cấp cao nhất, vì vậy, bạn không thể thêm nhiều trình phát hiện trong một đối tượng sửa đổi pointerInput. Đoạn mã sau chỉ phát hiện các thao tác nhấn, chứ không phát hiện các thao tác kéo:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Về phía nội bộ, phương thức detectTapGestures chặn coroutine và không bao giờ tiếp cận được trình phát hiện thứ hai. Nếu bạn cần thêm nhiều trình nghe cử chỉ vào một thành phần kết hợp, hãy sử dụng các thực thể đối tượng sửa đổi pointerInput riêng biệt:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Xử lý sự kiện trên mỗi cử chỉ

Theo định nghĩa, cử chỉ bắt đầu bằng một sự kiện con trỏ xuống. Bạn có thể sử dụng phương thức trợ giúp awaitEachGesture thay vì vòng lặp while(true) truyền qua từng sự kiện thô. Phương thức awaitEachGesture sẽ khởi động lại khối chứa khi tất cả các con trỏ đều được nâng lên, cho biết cử chỉ đã hoàn tất:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

Trong thực tế, hầu như bạn luôn muốn sử dụng awaitEachGesture trừ phi bạn phản hồi các sự kiện con trỏ mà không xác định cử chỉ. Ví dụ: hoverable không phản hồi các sự kiện con trỏ xuống hoặc lên. Công cụ này chỉ cần biết thời điểm một con trỏ đi vào hoặc thoát khỏi ranh giới của mình.

Chờ một sự kiện hoặc cử chỉ phụ cụ thể

Có một nhóm phương thức giúp xác định các phần phổ biến của cử chỉ:

Áp dụng phép tính cho sự kiện nhiều điểm chạm

Khi người dùng thực hiện một cử chỉ nhiều điểm chạm bằng nhiều hơn một con trỏ, việc hiểu phép biến đổi bắt buộc dựa trên các giá trị thô sẽ rất phức tạp. Nếu đối tượng sửa đổi transformable hoặc các phương thức detectTransformGestures không cung cấp đủ quyền kiểm soát chi tiết cho trường hợp sử dụng của bạn, thì bạn có thể theo dõi các sự kiện thô và áp dụng các phép tính trên các sự kiện đó. Các phương thức trợ giúp này là calculateCentroid, calculateCentroidSize, calculatePan, calculateRotationcalculateZoom.

Gửi sự kiện và kiểm thử nhấn

Không phải sự kiện con trỏ nào cũng được gửi đến mọi đối tượng sửa đổi pointerInput. Tính năng điều phối sự kiện hoạt động như sau:

  • Các sự kiện con trỏ được gửi đến hệ phân cấp thành phần kết hợp. Thời điểm khi một con trỏ mới kích hoạt sự kiện con trỏ đầu tiên, hệ thống sẽ bắt đầu kiểm thử nhấn các thành phần kết hợp "đủ điều kiện". Một thành phần kết hợp được coi là đủ điều kiện khi có khả năng xử lý dữ liệu đầu vào của con trỏ. Kiểm thử lượt truy cập di chuyển từ đầu đến cuối cây giao diện người dùng. Một thành phần kết hợp là "lượt truy cập" khi sự kiện con trỏ xảy ra trong giới hạn của thành phần kết hợp đó. Quá trình này dẫn đến một chuỗi thành phần kết hợp đạt được thử nghiệm tích cực.
  • Theo mặc định, khi có nhiều thành phần kết hợp đủ điều kiện ở cùng một cấp độ cây, chỉ thành phần kết hợp có chỉ mục z cao nhất là "hit". Ví dụ: khi bạn thêm 2 thành phần kết hợp Button chồng chéo vào một Box, thì chỉ thành phần được vẽ ở trên cùng mới nhận được mọi sự kiện con trỏ. Về mặt lý thuyết, bạn có thể ghi đè hành vi này bằng cách tạo phương thức triển khai PointerInputModifierNode của riêng mình và đặt sharePointerInputWithSiblings thành true.
  • Các sự kiện khác cho cùng một con trỏ được gửi đến cùng một chuỗi thành phần kết hợp và tiến hành theo logic truyền sự kiện. Hệ thống không thực hiện thêm thử nghiệm nhấn nào cho con trỏ này nữa. Điều này có nghĩa là mỗi thành phần kết hợp trong chuỗi sẽ nhận được tất cả sự kiện cho con trỏ đó, ngay cả khi các sự kiện đó xảy ra bên ngoài các ranh giới của thành phần kết hợp đó. Các thành phần kết hợp không nằm trong chuỗi không bao giờ nhận được các sự kiện con trỏ, ngay cả khi con trỏ nằm trong giới hạn của chúng.

Các sự kiện di chuột, được kích hoạt bằng cách di chuột hoặc bút cảm ứng, là một ngoại lệ đối với các quy tắc được xác định tại đây. Các sự kiện di chuột được gửi đến bất kỳ thành phần kết hợp nào mà các sự kiện đó nhấn vào. Vì vậy, khi người dùng di con trỏ từ ranh giới của một thành phần kết hợp sang thành phần kết hợp tiếp theo, thay vì gửi các sự kiện đến thành phần kết hợp đầu tiên đó, các sự kiện sẽ được gửi đến thành phần kết hợp mới.

Mức sử dụng sự kiện

Khi nhiều thành phần kết hợp được chỉ định một trình xử lý cử chỉ, các trình xử lý đó sẽ không xung đột. Ví dụ: hãy xem giao diện người dùng dưới đây:

Mục danh sách có một Hình ảnh, một Cột có hai văn bản và một Nút.

Khi người dùng nhấn vào nút dấu trang, hàm lambda onClick của nút sẽ xử lý cử chỉ đó. Khi người dùng nhấn vào bất kỳ phần nào khác của mục danh sách, ListItem sẽ xử lý cử chỉ đó và chuyển đến bài viết. Về mặt nhập con trỏ, Nút phải sử dụng sự kiện này để thành phần mẹ của nút biết không nên phản ứng với sự kiện đó nữa. Các cử chỉ có trong các thành phần có sẵn và các đối tượng sửa đổi cử chỉ phổ biến cũng bao gồm hành vi tiêu thụ này, nhưng nếu đang viết cử chỉ tuỳ chỉnh của riêng mình thì bạn phải sử dụng các sự kiện theo cách thủ công. Bạn thực hiện việc này bằng phương thức PointerInputChange.consume:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

Việc sử dụng một sự kiện không dừng quá trình truyền sự kiện tới các thành phần kết hợp khác. Thay vào đó, một thành phần kết hợp cần bỏ qua các sự kiện đã tiêu thụ một cách rõ ràng. Khi viết các cử chỉ tuỳ chỉnh, bạn nên kiểm tra xem một sự kiện đã được một phần tử khác sử dụng hay chưa:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

Truyền bá sự kiện

Như đã đề cập trước đó, các thay đổi về con trỏ được truyền đến từng thành phần kết hợp mà nó truy cập vào. Nhưng nếu nhiều thành phần kết hợp như vậy tồn tại, thì các sự kiện sẽ diễn ra theo thứ tự nào? Nếu bạn lấy ví dụ ở phần cuối, giao diện người dùng này sẽ chuyển đổi sang cây giao diện người dùng sau, trong đó chỉ ListItemButton phản hồi các sự kiện con trỏ:

Cấu trúc cây. Lớp trên cùng là ListItem, lớp thứ hai có Hình ảnh, Cột và Nút và Cột được chia thành hai Văn bản. ListItem và Button được làm nổi bật.

Các sự kiện con trỏ truyền qua từng thành phần kết hợp này 3 lần, trong 3 "lượt truyền":

  • Trong Lượt chuyển ban đầu, sự kiện sẽ di chuyển từ đầu cây giao diện người dùng xuống dưới cùng. Luồng này cho phép phần tử mẹ chặn một sự kiện trước khi phần tử con có thể sử dụng sự kiện đó. Ví dụ: chú giải công cụ cần chặn thao tác nhấn và giữ thay vì truyền đến phần tử con. Trong ví dụ của chúng ta, ListItem nhận được sự kiện trước Button.
  • Trong Đường truyền chính, sự kiện di chuyển từ các nút lá của cây giao diện người dùng đến gốc của cây giao diện người dùng. Giai đoạn này là nơi bạn thường sử dụng các cử chỉ và là chế độ truyền mặc định khi nghe các sự kiện. Việc xử lý các cử chỉ trong lượt truyền này có nghĩa là các nút lá được ưu tiên hơn các nút gốc. Đây là hành vi hợp lý nhất đối với hầu hết các cử chỉ. Trong ví dụ của chúng ta, Button nhận được sự kiện trước ListItem.
  • Trong Thẻ cuối cùng, sự kiện sẽ truyền thêm một lần nữa từ đầu cây giao diện người dùng đến các nút lá. Quy trình này cho phép các phần tử cao hơn trong ngăn xếp phản hồi việc phần tử mẹ được sử dụng sự kiện. Ví dụ: một nút sẽ xoá chỉ báo gợn sóng khi thao tác nhấn chuyển thành thao tác kéo thành phần mẹ có thể cuộn.

Rõ ràng, luồng sự kiện có thể được trình bày như sau:

Sau khi thay đổi đầu vào được sử dụng, thông tin này sẽ được chuyển từ điểm đó trong luồng trở đi:

Trong mã, bạn có thể chỉ định thẻ/vé mà bạn quan tâm:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

Trong đoạn mã này, cùng một sự kiện giống hệt nhau được trả về bởi từng lệnh gọi phương thức chờ này, mặc dù dữ liệu về mức tiêu thụ có thể đã thay đổi.

Kiểm thử cử chỉ

Trong phương thức kiểm thử, bạn có thể gửi các sự kiện con trỏ theo cách thủ công bằng phương thức performTouchInput. Nhờ vậy, bạn có thể thực hiện các cử chỉ đầy đủ ở cấp cao hơn (như chụm hoặc nhấp và giữ) hoặc các cử chỉ cấp thấp (chẳng hạn như di chuyển con trỏ theo một lượng pixel nhất định):

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

Xem tài liệu performTouchInput để biết thêm ví dụ.

Tìm hiểu thêm

Bạn có thể tìm hiểu thêm về các cử chỉ trong Jetpack Compose từ các tài nguyên sau: