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:
- Xử lý các thao tác nhấn và nhấn bằng đối tượng sửa đổi
clickable
,combinedClickable
,selectable
,toggleable
vàtriStateToggleable
. - Xử lý thao tác cuộn bằng các đối tượng sửa đổi
horizontalScroll
,verticalScroll
và các đối tượng sửa đổiscrollable
chung khác. - Xử lý thao tác kéo bằng đối tượng sửa đổi
draggable
vàswipeable
. - Xử lý các cử chỉ nhiều điểm chạm như kéo (hình ảnh), xoay và thu phóng bằng đối tượng sửa đổi
transformable
.
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:
- Nhấn, nhấn, nhấn đúp rồi nhấn và giữ:
detectTapGestures
- Kéo:
detectHorizontalDragGestures
,detectVerticalDragGestures
,detectDragGestures
vàdetectDragGesturesAfterLongPress
- Biến đổi:
detectTransformGestures
Đâ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ỉ:
- Tạm ngưng cho đến khi một con trỏ giảm xuống bằng
awaitFirstDown
hoặc chờ tất cả các con trỏ đi lên bằngwaitForUpOrCancellation
. - Tạo trình nghe kéo cấp thấp bằng cách sử dụng
awaitTouchSlopOrCancellation
vàawaitDragOrCancellation
. Trước tiên, trình xử lý cử chỉ sẽ tạm ngưng cho đến khi con trỏ chạm đến khoảng thời gian chạm, sau đó tạm ngưng cho đến khi sự kiện kéo đầu tiên đi qua. Nếu bạn chỉ muốn kéo theo một trục, hãy sử dụngawaitHorizontalTouchSlopOrCancellation
cùng vớiawaitHorizontalDragOrCancellation
hoặcawaitVerticalTouchSlopOrCancellation
+awaitVerticalDragOrCancellation
thay vào đó. - Tạm ngưng cho đến khi bạn nhấn và giữ với
awaitLongPressOrCancellation
. - Sử dụng phương thức
drag
để liên tục theo dõi các sự kiện kéo hoặchorizontalDrag
hayverticalDrag
để theo dõi các sự kiện kéo trên một trục.
Á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
, calculateRotation
và calculateZoom
.
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ộtBox
, 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 khaiPointerInputModifierNode
của riêng mình và đặtsharePointerInputWithSiblings
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:
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ỉ ListItem
và Button
phản hồi các sự kiện con trỏ:
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ướcButton
. - 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ướcListItem
. - 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:
Đề xuất cho bạn
- Lưu ý: văn bản có đường liên kết sẽ hiện khi JavaScript tắt
- Hỗ trợ tiếp cận trong Compose
- Cuộn
- Nhấn và nhấn