Tìm hiểu cử chỉ

Có một số thuật ngữ và khái niệm quan trọng cần hiểu khi xử lý cử chỉ trong một ứng dụng. Trang này giải thích các thuật ngữ con trỏ, sự kiện con trỏ và cử chỉ, đồng thời giới thiệu các mức độ trừu tượng khác nhau cho cử chỉ. Bài viết này cũng đi sâu vào việc xử lý và truyền 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 sử dụng:

  • Con trỏ: Một đối tượng thực mà bạn có thể dùng để tương tác với ứng dụng. Đố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 cho 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ỏ, vì vậy, ví dụ: 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ả một lượt 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 bằng con trỏ, chẳng hạn như đặt ngón tay lên màn hình hoặc kéo chuột, đều sẽ kích hoạt một sự kiện. Trong Compose, tất cả thông tin liên quan đến sự kiện như vậy đều có trong lớp PointerEvent.
  • Cử chỉ: Một chuỗi các sự kiện con trỏ có thể được diễn giải dưới dạng một hành động duy nhất. Ví dụ: cử chỉ nhấn có thể được coi là một chuỗi sự kiện nhấn xuống, theo sau là sự kiện nhấc 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, nhưng bạn cũng có thể tạo cử chỉ tuỳ chỉnh của riêng mình khi cần.

Các mức độ 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 tự động hỗ trợ cử chỉ. Để thêm tính năng hỗ trợ cử chỉ vào các thành phần tuỳ chỉnh, bạn có thể thêm công cụ 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 hệ số sửa đổi pointerInput.

Theo nguyên tắc, hãy xây dựng trên mức độ trừu tượng cao nhất cung cấp chức năng bạn cần. Bằng cách này, bạn có thể 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, được dùng cho tính nă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 có một số loại xử lý cử chỉ nội bộ. Ví dụ: LazyColumn phản hồi cử chỉ kéo bằng cách cuộn nội dung, Button cho thấy hiệu ứng gợn sóng khi bạn nhấn vào thành phần đó và thành phần SwipeToDismiss chứa logic vuốt để đóng một phần tử. Loại xử lý cử chỉ này hoạt động tự động.

Bên cạnh việc 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 tự động phát hiện các lần nhấn và kích hoạt sự kiện nhấp chuột. Bạn truyền một hàm lambda onClick đến Button để phản ứng với cử chỉ. Tương tự, bạn thêm một lambda onValueChange vào Slider để phản ứng với việc người dùng kéo tay điều khiển 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ó trong thành phần vì các cử chỉ này có sẵn tính năng hỗ trợ cho tiêu điểm và hỗ trợ tiếp cận, đồng thời đã được kiểm thử kỹ lưỡng. Ví dụ: Button được đánh dấu theo cách đặc biệt để các dịch vụ hỗ trợ tiếp cận mô tả chính xác thành phần này là một nút, thay vì chỉ là một thành phần có thể nhấp:

// 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ử 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 modifier cử chỉ cho bất kỳ composable tuỳ ý nào để composable đó lắng nghe 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 thành clickable hoặc cho phép Column xử lý thao tác cuộn 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ỉ:

Theo quy tắc, hãy ưu tiên các đối tượng sửa đổi cử chỉ có sẵn hơn là xử lý cử chỉ tuỳ chỉnh. Các đối tượng sửa đổi bổ sung thêm chức năng cho 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 thao tác nhấn và nhấn nhanh, mà còn thêm thông tin ngữ nghĩa, chỉ báo trực quan về các hoạt động 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 thêm chức năng.

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 một đố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 thao tác kéo sau khi nhấn và giữ, nhấp chuột trong khi nhấn phím Control 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 trình xử lý cử chỉ bằng công cụ sửa đổi pointerInput, cho phép bạn truy cập vào các sự kiện con trỏ thô.

Đoạn mã sau đây theo dõi 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, các thành phần cốt lõi sẽ là:

  • Đối tượng sửa đổi pointerInput. Bạn truyền một hoặc nhiều khoá vào hàm này. 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. Đoạn nhạc truyền một bộ lọc không bắt buộc đến composable. 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 đúng sự kiện.
  • awaitPointerEventScope tạo một phạm vi coroutine có thể dùng để chờ các sự kiện con trỏ.
  • awaitPointerEvent tạm ngưng coroutine cho đến khi sự kiện con trỏ tiếp theo xảy ra.

Mặc dù việc lắng nghe các sự kiện đầu vào thô rất hiệu quả, 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. Để đơn giản hoá việc tạo cử chỉ tuỳ chỉnh, bạn có thể sử dụng nhiều phương thức tiện ích.

Phát hiện cử chỉ đầy đủ

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ể để xảy ra và phản hồi một cách 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 đây chỉ phát hiện các lần nhấn chứ không phát hiện các lần 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" }
            }
    )
}

Trong nội bộ, phương thức detectTapGestures chặn coroutine và trình phát hiện thứ hai không bao giờ được truy cập. Nếu bạn cần thêm nhiều trình nghe cử chỉ vào một composable, 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 cho mỗi cử chỉ

Theo định nghĩa, cử chỉ bắt đầu bằng 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 khởi động lại khối chứa khi tất cả con trỏ đã được nhấc 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()
                    }
                }
            }
    )
}

Trên thực tế, bạn hầu như luôn muốn sử dụng awaitEachGesture trừ phi bạn đang phản hồi các sự kiện con trỏ mà không xác định cử chỉ. Ví dụ về điều này là hoverable, không phản hồi các sự kiện con trỏ lên hoặc xuống – chỉ cần biết khi nào con trỏ vào hoặc ra khỏi giới hạn của nó.

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

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

Áp dụng các phép tính cho sự kiện đa điểm chạm

Khi người dùng thực hiện cử chỉ nhiều điểm chạm bằng nhiều con trỏ, việc hiểu được phép biến đổi cần thiết dựa trên các giá trị thô là 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 cho những sự kiện đó. Các phương thức trợ giúp này là calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation và calculateZoom.

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

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

  • Sự kiện con trỏ được gửi đến một hệ phân cấp thành phần kết hợp. Thời điểm 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 thử nghiệm nhấn các thành phần kết hợp "đủ điều kiện". Một composable được coi là đủ điều kiện khi có khả năng xử lý dữ thông tin đầu vào của con trỏ. Quy trình thử nghiệm nhấn từ trên xuống dưới của cây giao diện người dùng. Một thành phần kết hợp được "nhấn" 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 tạo ra một chuỗi thành phần kết hợp có kết quả thử nghiệm nhấn là dương tính.
  • Theo mặc định, khi có nhiều composable đủ điều kiện ở cùng một cấp của cây, thì chỉ composable có Chỉ mục Z cao nhất mới được "nhấn". Ví dụ: khi bạn thêm hai thành phần kết hợp Button chồng chéo vào một Box, 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 bạn 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à di chuyển theo logic truyền sự kiện. Hệ thống không thực hiện thêm bất kỳ thử nghiệm nhấn nào cho con trỏ này. Điều này có nghĩa là mỗi composable 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 giới hạn của composable đó. Các thành phần kết hợp không nằm trong chuỗi sẽ không bao giờ nhận được sự kiện con trỏ, ngay cả khi con trỏ nằm trong giới hạn của chúng.

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. Sự kiện di chuột được gửi đến bất kỳ thành phần kết hợp nào mà sự kiện đó tác động. Vì vậy, khi người dùng di chuyển con trỏ từ ranh giới của một composable này sang composable tiếp theo, thay vì gửi sự kiện đến composable đầu tiên, các sự kiện sẽ được gửi đến composable mới.

Mức tiêu thụ theo sự kiện

Khi có nhiều thành phần kết hợp được chỉ định trình xử lý cử chỉ, các trình xử lý đó không được xung đột với nhau. Ví dụ: hãy xem giao diện người dùng nà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, 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 trong danh sách, ListItem sẽ xử lý cử chỉ đó và chuyển đến bài viết. Về dữ liệu đầu vào của con trỏ, Nút phải sử dụng sự kiện này để thành phần mẹ biết rằng không cần phản ứng với sự kiện đó nữa. Các cử chỉ có trong thành phần có sẵn và đối tượng sửa đổi cử chỉ phổ biến 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, bạn phải tiêu thụ 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 làm dừng quá trình truyền sự kiện đến các thành phần kết hợp khác. Thay vào đó, một composable cần bỏ qua một cách rõ ràng các sự kiện đã sử dụng. Khi viết 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 sự kiện

Như đã đề cập trước đó, các thay đổi về con trỏ được truyền đến từng composable mà con trỏ chạm vào. Nhưng nếu có nhiều composable như vậy, thì các sự kiện sẽ truyền theo thứ tự nào? Nếu lấy ví dụ từ phần trước, giao diện người dùng này sẽ chuyển thành cây giao diện người dùng sau, trong đó chỉ có ListItem và Button 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ó Image, Column và Button, còn Column được chia thành hai Text. ListItem và Button được làm nổi bật.

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":

  • Trong Lượt truyề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. Quy trình này cho phép thành phần hiển thị cha chặn một sự kiện trước khi thành phần hiển thị 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 thao tác đó đến các thành phần con. Trong ví dụ của chúng tôi, ListItem nhận được sự kiện trước Button.
  • Trong Main pass (Lượt chính), sự kiện sẽ di chuyển từ các nút lá của cây giao diện người dùng lên đến gốc của cây giao diện người dùng. Đây là giai đoạn bạn thường sử dụng cử chỉ và là giai đoạn mặc định khi nghe sự kiện. Việc xử lý cử chỉ trong lượt này có nghĩa là các nút lá được ưu tiên hơn nút mẹ, đâ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 tôi, Button nhận được sự kiện trước ListItem.
  • Trong Lượt cuối cùng, sự kiện sẽ chạy 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á. Luồng này cho phép các phần tử ở vị trí cao hơn trong ngăn xếp phản hồi việc sử dụng sự kiện của phần tử mẹ. 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.

Về mặt hình ảnh, luồng sự kiện có thể được biểu thị như sau:

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

Trong mã, bạn có thể chỉ định lượt truyền 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, mỗi lệnh gọi phương thức await sẽ trả về cùng một sự kiện, mặc dù dữ liệu về mức tiêu thụ có thể đã thay đổi.

Thử nghiệm cử chỉ

Trong các phương thức kiểm thử, bạn có thể gửi sự kiện con trỏ theo cách thủ công bằng phương thức performTouchInput. Điều này cho phép bạn thực hiện cử chỉ đầy đủ ở cấp cao hơn (chẳng hạn như chụm hoặc nhấp và giữ) hoặc cử chỉ ở cấp thấp (chẳng hạn như di chuyển con trỏ theo một số 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ử chỉ trong Jetpack Compose qua các tài nguyên sau: