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 rõ khi xử lý cử chỉ trong một ứng dụng. Trang này giải thích các điều khoản con trỏ, sự kiện con trỏ và cử chỉ, đồng thời giới thiệu các mô hình trừu tượng cấp độ cho cử chỉ. Báo cáo này cũng đi sâu vào việc tiêu thụ sự kiện và truyền tải.

Đị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ố khái niệm 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 bạn tương tác với màn hình cảm ứng. Hoặc, 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ể sử 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ỏ" tại một toạ độ để được coi là con trỏ, vì vậy, chẳng hạn như bàn phím, bàn phím không thể được coi là một con trỏ 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ả 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. Bất kỳ tương tác nào của con trỏ, chẳng hạn như đặt một ngón tay trên màn hình hoặc kéo chuột, sẽ kích hoạ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 hiểu là một thao tác đơn hành động. Ví dụ: cử chỉ nhấn có thể được coi là một chuỗi thao tác nhấn xuống sự kiện diễn ra theo sau là sự kiện lên. Có những cử chỉ phổ biến được nhiều người sử dụng các ứng 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ác ứng dụng tuỳ chỉnh của riêng mình cử chỉ 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 bao gồm tính năng hỗ trợ cử chỉ. Để thêm tính năng hỗ trợ cử chỉ vào danh sách tuỳ chỉnh thành phần, bạn có thể thêm đối tượng sửa đổi cử chỉ như clickable vào thành phần kết hợp. 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 ứng dụng ở mức độ trừu tượng cao nhất, chức năng 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 trong lớp. Ví dụ: Button chứa nhiều thông tin ngữ nghĩa hơn dùng cho khả năng truy cập, hơn clickable, chứa nhiều thông tin hơn so với dữ liệu thô Triển khai pointerInput.

Hỗ trợ thành phần

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

Bên cạnh tính năng 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 lượt nhấn và kích hoạt một sự kiện nhấp chuột. Bạn truyền một hàm lambda onClick vào Button để thể hiện cảm xúc với cử chỉ. Tương tự, bạn thêm hàm lambda onValueChange vào Slider để phản ứng với việc người dùng kéo ô điều khiển thanh trượt.

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

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 các đối tượng sửa đổi cử chỉ cho bất kỳ thành phần kết hợp tuỳ ý nào để thực hiện thành phần kết hợp nghe cử chỉ. Ví dụ: bạn có thể để Box chung xử lý các cử chỉ nhấn bằng cách làm cho ứng dụng đó 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ý nhiều loại cử chỉ:

Theo quy tắc, hãy ưu tiên sử dụng các đối tượng sửa đổi cử chỉ ngay lập tức thay vì cách xử lý cử chỉ tuỳ chỉnh. Đối tượng sửa đổi bổ sung thêm 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 thao tác nhấn và mà còn thêm thông tin ngữ nghĩa, chỉ báo trực quan về hoạt động tương tác, di chuột, lấy nét và hỗ trợ bàn phím. Bạn có thể xem mã nguồn của clickable để xem chức năng đang đượ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 thao tác kéo sau khi nhấn và giữ, nhấn giữ phím Ctrl hoặc nhấn bằng 3 ngón tay. Thay vào đó, bạn có thể viết cử chỉ của riêng mình trình xử lý để xác định các cử chỉ tùy 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 con trỏ thô các sự kiệ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 là:

  • Đối tượng sửa đổi pointerInput. Bạn truyền một hoặc nhiều khoá. Khi giá trị của một trong các khoá đó thay đổi, thì hàm lambda nội dung của đối tượng sửa đổi là 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ẽ là 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 tạm ngưng coroutine cho đến khi xảy ra một sự kiện con trỏ tiếp theo xảy ra.

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

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ể theo dõi các cử chỉ cụ thể xảy ra và phản hồi thích hợp. AwaitPointerEventScope cung cấp để nghe:

Đâ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 cùng một trình phát hiện Đố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 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" }
            }
    )
}

Trong nội bộ, phương thức detectTapGestures sẽ chặn coroutine này, còn phương thức thứ hai trình phát hiện không bao giờ tiếp cận được. Nếu bạn cần thêm nhiều trình nghe cử chỉ để một thành phần kết hợp, hãy sử dụng các thực thể 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ác cử chỉ bắt đầu bằng một sự kiện trỏ xuống. Bạn có thể sử dụng awaitEachGesture thay vì vòng lặp while(true) chuyể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ỏ đã đượ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()
                    }
                }
            }
    )
}

Trên thực tế, hầu như lúc nào bạn cũng muốn sử dụng awaitEachGesture, trừ phi bạn muố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 trỏ xuống hoặc lên — mà chỉ cần biết khi nào một con trỏ tiến vào hoặc thoát 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 phương pháp 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 cử chỉ nhiều điểm chạm bằng nhiều con trỏ, nên việc hiểu phép biến đổi cần thiết dựa trên các giá trị thô rất phức tạp. Nếu đối tượng sửa đổi transformable hoặc detectTransformGestures có thể 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 dựa trên các sự kiện đó. Sau đây là các phương thức trợ giúp là calculateCentroid, calculateCentroidSize, calculatePan, calculateRotationcalculateZoom.

Điều phố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. Sự kiện công việc điều phối 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. Khoảnh khắc mà con trỏ mới kích hoạt sự kiện con trỏ đầu tiên, hệ thống bắt đầu kiểm thử nhấn trạng thái "đủ điều kiện" thành phần kết hợp. Một thành phần kết hợp được coi là đủ điều kiện khi khả năng xử lý phương thức nhập con trỏ. Quy trình kiểm thử lượt truy cập từ đầu giao diện người dùng xuống dưới cùng. Thành phần kết hợp là "lượt truy cập" thời điểm xảy ra sự kiện con trỏ 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 giúp kiểm thử 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 cấp độ cây này, chỉ thành phần kết hợp có chỉ mục z cao nhất là "lượt truy cập". Để ví dụ: khi bạn thêm hai thành phần kết hợp Button trùng lặp vào một Box, chỉ mục được vẽ ở trên cùng sẽ 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 PointerInputModifierNode của riêng bạn triển khai và đặt sharePointerInputWithSiblings thành true.
  • Các sự kiện khác cho cùng một con trỏ sẽ được gửi đến cùng một chuỗi thành phần kết hợp và luồng theo logic truyền sự kiện. Hệ thống sẽ không thực hiện thêm bất kỳ thử nghiệm nhấn nào đối với con trỏ này. Điều này có nghĩa là mỗi thành phần kết hợp trong chuỗi nhận tất cả sự kiện cho con trỏ đó, ngay cả khi những giá trị nằm ngoài giới hạn của thành phần kết hợp đó. Các thành phần kết hợp không được trong chuỗi không bao giờ nhận 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 thao tác di chuột bằng chuột hoặc bút cảm ứng là một trường hợp ngoại lệ đối với quy tắ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 đó nhấn vào. Loại đối thủ sau lượt đánh bóng khi người dùng di con trỏ từ ranh giới của một thành phần kết hợp đến thành phần kết hợp tiếp theo, thay vì gửi sự kiện đến thành phần kết hợp đầu tiên, sự kiện sẽ được gửi đến thành phần kết hợp mới.

Tiêu thụ 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ý không nên xung đột. 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ó 2 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 này 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 xử lý cử chỉ đó và chuyển đến bài viết. Về phương thức nhập con trỏ, Nút phải sử dụng sự kiện này để phần tử mẹ của nút biết không phản ứng với nó nữa. 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 bao gồm hành vi sử dụng này, nhưng nếu bạn viết cử chỉ tuỳ chỉnh của riêng mình, bạn phải sử dụng 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 tiêu thụ một sự kiện sẽ không dừng việc truyền sự kiện sang các thành phần kết hợp khác. Đáp thay vào đó, thành phần kết hợp cần bỏ qua các sự kiện đã sử dụng một cách rõ ràng. Khi viết cử chỉ tuỳ chỉnh, bạn nên kiểm tra xem một sự kiện đã được người dùng khác sử dụng hay chưa phần tử:

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

Quảng 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. Nhưng nếu có nhiều thành phần kết hợp như vậy tồn tại, các sự kiện này diễn ra theo thứ tự nào phổ biến? Nếu bạn lấy ví dụ từ phần trước, giao diện người dùng này sẽ được dịch thành cây giao diện người dùng sau đây, trong đó chỉ ListItemButton phản hồi 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, còn 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ỏ sẽ truyền qua mỗi thành phần kết hợp này 3 lần, trong 3 lần "thẻ":

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

Trực quan, luồng sự kiện có thể được biểu thị như sau:

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

Trong đoạn 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, mỗi sự kiện giống nhau trả về cùng một sự kiện các lệnh gọi phương thức này đang chờ các lệnh gọi, mặc dù dữ liệu về việc tiêu thụ có thể đã thay đổi.

Kiểm thử cử chỉ

Trong phương thức kiểm thử của mình, bạn có thể gửi các sự kiện con trỏ theo cách thủ công bằng cách sử dụng Phương thức performTouchInput. Điều này cho phép bạn thực hiện cấp cao hơn cử chỉ toàn bộ (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 lượng pixel nhất định):

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

Hãy xem tài liệu về 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 đường liên kết sau tài nguyên: