Các thành phần giao diện người dùng cung cấp phản hồi cho người dùng thiết bị theo cách chúng phản hồi với tương tác của người dùng. Mỗi thành phần đều có cách phản hồi tương tác riêng, giúp người dùng biết hoạt động tương tác của họ là gì. Ví dụ: nếu người dùng nhấn vào một nút trên màn hình cảm ứng của thiết bị, nút đó có thể thay đổi theo cách nào đó, có thể là thêm màu đánh dấu chẳng hạn. Thay đổi này cho người dùng biết rằng họ đã nhấn vào nút. Nếu người dùng không muốn thực hiện thao tác đó, họ sẽ biết cách kéo ngón tay ra khỏi nút trước khi nhả tay, nếu không, nút này sẽ được kích hoạt.
Tài liệu về Cử chỉ trong Compose bao gồm cách các thành phần Compose xử lý sự kiện con trỏ cấp thấp, chẳng hạn như di chuyển con trỏ và lượt nhấp. Ngoài ra, Compose tóm tắt những sự kiện cấp thấp đó thành các lượt tương tác cấp cao hơn – ví dụ: một loạt các sự kiện con trỏ có thể thêm vào một lần nhấn và thả nút. Việc hiểu các khái niệm trừu tượng cấp cao hơn có thể giúp bạn tùy chỉnh cách giao diện người dùng phản hồi cho người dùng. Ví dụ: bạn có thể muốn tùy chỉnh cách giao diện của một thành phần thay đổi khi người dùng tương tác với thành phần đó hoặc có thể bạn chỉ muốn duy trì nhật ký của những hoạt động người dùng đó. Tài liệu này cung cấp cho bạn thông tin cần thiết để sửa đổi các thành phần giao diện người dùng tiêu chuẩn hoặc thiết kế của riêng bạn.
Lượt tương tác
Trong nhiều trường hợp, bạn không cần phải biết thành phần Compose đang diễn giải lượt tương tác của người dùng như thế nào. Ví dụ: Button dựa vào Modifier.clickable để tìm hiểu xem người dùng có nhấp vào nút này hay không. Nếu thêm một nút thông thường vào ứng dụng, bạn có thể xác định mã onClick của nút đó và Modifier.clickable sẽ chạy mã đó khi thích hợp. Điều đó có nghĩa là bạn không cần phải biết người dùng đã nhấn vào màn hình hay chọn nút bằng bàn phím; Modifier.clickable nhận thấy người dùng đã thực hiện lượt nhấp và phản hồi bằng cách chạy mã onClick của bạn.
Tuy nhiên, nếu muốn tùy chỉnh phản hồi của thành phần giao diện người dùng đối với hành vi của người dùng, bạn có thể cần biết thêm về những gì đang diễn ra. Phần này cung cấp cho bạn một vài thông tin đó.
Khi người dùng tương tác với một thành phần giao diện người dùng, hệ thống đại diện cho hành vi của họ bằng cách tạo một số sự kiện Interaction. Ví dụ: nếu người dùng nhấn vào một nút, thì nút đó sẽ tạo raPressInteraction.Press.
Nếu người dùng nhấc ngón tay ra khỏi nút, nút này sẽ tạo ra PressInteraction.Release, cho nút đó nhận biết lượt nhấp đã hoàn tất. Tuy nhiên, nếu người dùng kéo ngón tay ra khỏi nút rồi nhấc ngón tay lên, nút đó sẽ tạo ra PressInteraction.Cancel để cho biết Thao tác nhấn trên nút đã bị hủy, chưa hoàn tất.
Những lượt tương tác này không hợp lý. Điều này có nghĩa là các sự kiện tương tác cấp thấp không có ý định diễn giải ý nghĩa của hành động của người dùng hoặc trình tự của chúng. Chúng cũng không giải thích hành động nào của người dùng có thể được ưu tiên hơn các hành động khác.
Các hoạt động tương tác này thường đi theo cặp, bắt đầu và kết thúc. Lượt tương tác thứ hai chứa một thông tin tham chiếu đến bản tương tác đầu tiên. Ví dụ: nếu người dùng nhấn vào một nút rồi nhấc ngón tay lên, thì thao tác nhấn đó sẽ tạo ra một lượt tương tác PressInteraction.Press và bản phát hành tạo ra PressInteraction.Release; Release có thuộc tính press xác định tên viết tắt PressInteraction.Press ban đầu.
Bạn có thể xem các lượt tương tác của một thành phần cụ thể bằng cách quan sát InteractionSource. InteractionSource được xây dựng dựa trên Kotlin flows, vì thế bạn có thể thu thập các lượt tương tác từ luồng này giống như cách bạn làm việc với bất kỳ luồng nào khác. Để biết thêm thông tin về quyết định thiết kế này, hãy xem bài đăng Illuminating Interactions (Làm rõ các hoạt động tương tác) trên blog.
Trạng thái tương tác
Bạn nên mở rộng chức năng tích hợp sẵn của các thành phần bằng cách tự theo dõi các hoạt động tương tác đó. Ví dụ: có thể bạn muốn một nút thay đổi màu khi bạn nhấn vào. Cách đơn giản nhất để theo dõi các tương tác là quan sát trạng thái tương tác thích hợp. InteractionSource cung cấp một số phương thức để biết nhiều trạng thái tương tác dưới dạng trạng thái. Ví dụ: nếu muốn xem liệu một nút cụ thể có được nhấn hay không, bạn có thể gọi phương thức InteractionSource.collectIsPressedAsState():
val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() Button( onClick = { /* do something */ }, interactionSource = interactionSource ) { Text(if (isPressed) "Pressed!" else "Not pressed") }
Ngoài collectIsPressedAsState(), tính năng Compose còn cung cấp collectIsFocusedAsState(), collectIsDraggedAsState() và collectIsHoveredAsState(). Các phương thức này thực sự tiện dụng được xây dựng dựa trên các API InteractionSource cấp thấp hơn. Trong một số trường hợp, bạn có thể muốn sử dụng các hàm cấp thấp hơn đó.
Ví dụ: giả sử bạn cần biết liệu một nút có đang được nhấn hay không, cũng như liệu nút đó có đang được kéo hay không. Nếu bạn sử dụng cả collectIsPressedAsState() và collectIsDraggedAsState(), thì Compose có nhiều tác vụ trùng lặp và không có gì đảm bảo bạn sẽ nhận được tất cả các lượt tương tác theo đúng thứ tự. Đối với các tình huống như thế này, bạn nên làm việc trực tiếp với InteractionSource. Để biết thêm thông tin về cách tự theo dõi các lượt tương tác bằng InteractionSource, hãy xem bài viết Làm việc với InteractionSource.
Phần sau đây mô tả cách sử dụng và phát ra các lượt tương tác với
InteractionSource và MutableInteractionSource tương ứng.
Sử dụng và phát ra Interaction
InteractionSource đại diện cho một luồng chỉ đọc của Interactions — không thể phát Interaction đến InteractionSource. Để phát ra Interaction, bạn cần sử dụng MutableInteractionSource, mở rộng từ InteractionSource.
Đối tượng sửa đổi và thành phần có thể sử dụng, phát ra hoặc sử dụng và phát ra Interactions.
Các phần sau đây mô tả cách sử dụng và phát ra các hoạt động tương tác từ cả đối tượng sửa đổi và thành phần.
Ví dụ về đối tượng sửa đổi tiêu thụ
Đối với đối tượng sửa đổi vẽ đường viền cho trạng thái được làm nổi bật, bạn chỉ cần quan sát Interactions để có thể chấp nhận InteractionSource:
fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier { // ... }
Rõ ràng là từ chữ ký hàm, đối tượng sửa đổi này là một consumer (đối tượng tiêu thụ) — đối tượng này có thể tiêu thụ Interaction, nhưng không thể phát ra chúng.
Ví dụ về cách tạo đối tượng sửa đổi
Đối với đối tượng sửa đổi xử lý các sự kiện di chuột như Modifier.hoverable, bạn cần phát Interactions và chấp nhận MutableInteractionSource làm tham số:
fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier { // ... }
Đối tượng sửa đổi này là một nhà sản xuất — đối tượng này có thể sử dụng MutableInteractionSource được cung cấp để phát ra HoverInteractions khi được di chuột qua hoặc không được di chuột qua.
Tạo các thành phần sử dụng và tạo ra
Các thành phần cấp cao như Material Button đóng vai trò là cả nhà sản xuất và người tiêu dùng. Các thành phần này xử lý các sự kiện nhập và lấy tiêu điểm, đồng thời thay đổi giao diện để phản hồi các sự kiện này, chẳng hạn như hiển thị hiệu ứng gợn sóng hoặc tạo ảnh động cho độ cao. Do đó, các thành phần này trực tiếp hiển thị MutableInteractionSource dưới dạng một tham số để bạn có thể cung cấp thực thể đã ghi nhớ của riêng mình:
@Composable fun Button( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, // exposes MutableInteractionSource as a parameter interactionSource: MutableInteractionSource? = null, elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(), shape: Shape = MaterialTheme.shapes.small, border: BorderStroke? = null, colors: ButtonColors = ButtonDefaults.buttonColors(), contentPadding: PaddingValues = ButtonDefaults.ContentPadding, content: @Composable RowScope.() -> Unit ) { /* content() */ }
Điều này cho phép nâng MutableInteractionSource ra khỏi thành phần và quan sát tất cả Interaction do thành phần tạo ra. Bạn có thể sử dụng trạng thái này để kiểm soát giao diện của thành phần đó hoặc bất kỳ thành phần nào khác trong giao diện người dùng.
Nếu đang xây dựng các thành phần tương tác cấp cao của riêng mình, bạn nên hiển thị MutableInteractionSource dưới dạng tham số theo cách này. Ngoài việc tuân theo các phương pháp hay nhất về state hoisting, điều này cũng giúp bạn dễ dàng đọc và kiểm soát trạng thái hình ảnh của một thành phần theo cách tương tự như bất kỳ loại trạng thái nào khác (chẳng hạn như trạng thái bật) có thể được đọc và kiểm soát.
Compose tuân theo phương pháp kiến trúc phân lớp,
vì vậy, các thành phần Material cấp cao được xây dựng dựa trên các khối xây dựng nền tảng tạo ra Interaction mà chúng cần để kiểm soát hiệu ứng gợn sóng và các hiệu ứng hình ảnh khác. Thư viện nền tảng cung cấp các đối tượng sửa đổi tương tác cấp cao như Modifier.hoverable, Modifier.focusable và Modifier.draggable.
Để tạo một thành phần phản hồi các sự kiện di chuột, bạn chỉ cần sử dụng Modifier.hoverable và truyền MutableInteractionSource dưới dạng tham số.
Bất cứ khi nào thành phần được di chuột qua, thành phần đó sẽ phát ra HoverInteraction và bạn có thể sử dụng thành phần này để thay đổi cách thành phần xuất hiện.
// This InteractionSource will emit hover interactions val interactionSource = remember { MutableInteractionSource() } Box( Modifier .size(100.dp) .hoverable(interactionSource = interactionSource), contentAlignment = Alignment.Center ) { Text("Hello!") }
Để thành phần này cũng có thể lấy tiêu điểm, bạn có thể thêm Modifier.focusable và truyền MutableInteractionSource giống nhau làm tham số. Giờ đây, cả HoverInteraction.Enter/Exit và FocusInteraction.Focus/Unfocus đều được phát ra thông qua cùng một MutableInteractionSource và bạn có thể tuỳ chỉnh giao diện cho cả hai loại tương tác ở cùng một nơi:
// This InteractionSource will emit hover and focus interactions val interactionSource = remember { MutableInteractionSource() } Box( Modifier .size(100.dp) .hoverable(interactionSource = interactionSource) .focusable(interactionSource = interactionSource), contentAlignment = Alignment.Center ) { Text("Hello!") }
Modifier.clickable là một lớp trừu tượng cấp cao hơn cả hoverable và focusable. Để một thành phần có thể nhấp vào, thành phần đó phải có thể di chuột qua một cách ngầm ẩn và các thành phần có thể nhấp vào cũng phải có thể làm tâm điểm. Bạn có thể sử dụng Modifier.clickable để tạo một thành phần xử lý các hoạt động tương tác di chuột, lấy tiêu điểm và nhấn mà không cần kết hợp các API cấp thấp hơn. Nếu cũng muốn thành phần của mình có thể nhấp được, bạn có thể
thay thế hoverable và focusable bằng clickable:
// This InteractionSource will emit hover, focus, and press interactions val interactionSource = remember { MutableInteractionSource() } Box( Modifier .size(100.dp) .clickable( onClick = {}, interactionSource = interactionSource, // Also show a ripple effect indication = ripple() ), contentAlignment = Alignment.Center ) { Text("Hello!") }
Làm việc với InteractionSource
Nếu cần thông tin cấp thấp về các lượt tương tác với một thành phần, bạn có thể sử dụng API luồng chuẩn cho InteractionSource của thành phần đó.
Ví dụ: giả sử bạn muốn duy trì một danh sách các tương tác nhấn và kéo vào InteractionSource. Mã này thực hiện một nửa công việc, thêm các thao tác nhấn mới vào danh sách khi chúng xuất hiện:
val interactionSource = remember { MutableInteractionSource() } val interactions = remember { mutableStateListOf<Interaction>() } LaunchedEffect(interactionSource) { interactionSource.interactions.collect { interaction -> when (interaction) { is PressInteraction.Press -> { interactions.add(interaction) } is DragInteraction.Start -> { interactions.add(interaction) } } } }
Tuy nhiên, ngoài việc thêm các lượt tương tác mới, bạn cũng phải xóa các lượt tương tác khi nó kết thúc (ví dụ: khi người dùng nhấc ngón tay lên khỏi thành phần). Điều này rất dễ thực hiện, vì lượt tương tác cuối cùng luôn tham chiếu đến lượt tương tác bắt đầu được liên kết. Mã này cho biết cách bạn sẽ xóa những lượt tương tác đã kết thúc:
val interactionSource = remember { MutableInteractionSource() } val interactions = remember { mutableStateListOf<Interaction>() } LaunchedEffect(interactionSource) { interactionSource.interactions.collect { interaction -> when (interaction) { is PressInteraction.Press -> { interactions.add(interaction) } is PressInteraction.Release -> { interactions.remove(interaction.press) } is PressInteraction.Cancel -> { interactions.remove(interaction.press) } is DragInteraction.Start -> { interactions.add(interaction) } is DragInteraction.Stop -> { interactions.remove(interaction.start) } is DragInteraction.Cancel -> { interactions.remove(interaction.start) } } } }
Bây giờ, nếu muốn biết thành phần hiện đang được nhấn hay kéo, bạn chỉ cần kiểm tra xem interactions có trống hay không:
val isPressedOrDragged = interactions.isNotEmpty()
Nếu muốn biết lượt tương tác gần đây nhất, bạn chỉ cần xem mục cuối cùng trong danh sách. Ví dụ: đây là cách triển khai hiệu ứng gợn sóng trong Compose tính toán lớp phủ trạng thái thích hợp để sử dụng cho lượt tương tác gần đây nhất:
val lastInteraction = when (interactions.lastOrNull()) { is DragInteraction.Start -> "Dragged" is PressInteraction.Press -> "Pressed" else -> "No state" }
Vì tất cả Interaction đều tuân theo cùng một cấu trúc, nên không có nhiều sự khác biệt về mã khi làm việc với các loại tương tác khác nhau của người dùng — mẫu tổng thể là giống nhau.
Xin lưu ý rằng các ví dụ trước trong phần này thể hiện Flow của các lượt tương tác bằng cách sử dụng State — điều này giúp bạn dễ dàng quan sát các giá trị đã cập nhật, vì việc đọc giá trị trạng thái sẽ tự động kích hoạt quá trình kết hợp lại. Tuy nhiên,
quá trình kết hợp được gom theo lô trước khung hình. Điều này có nghĩa là nếu trạng thái thay đổi rồi thay đổi lại trong cùng một khung hình, thì các thành phần quan sát trạng thái sẽ không thấy thay đổi.
Điều này rất quan trọng đối với các lượt tương tác, vì các lượt tương tác có thể thường xuyên bắt đầu và kết thúc trong cùng một khung hình. Ví dụ: sử dụng ví dụ trước với Button:
val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() Button(onClick = { /* do something */ }, interactionSource = interactionSource) { Text(if (isPressed) "Pressed!" else "Not pressed") }
Nếu thao tác nhấn bắt đầu và kết thúc trong cùng một khung hình, thì văn bản sẽ không bao giờ hiển thị là "Đã nhấn!". Trong hầu hết các trường hợp, đây không phải là vấn đề — việc hiển thị hiệu ứng hình ảnh trong một khoảng thời gian ngắn như vậy sẽ dẫn đến hiện tượng nhấp nháy và người dùng sẽ không nhận thấy rõ. Trong một số trường hợp, chẳng hạn như hiển thị hiệu ứng gợn sóng hoặc ảnh động tương tự, bạn nên hiển thị hiệu ứng trong ít nhất một khoảng thời gian tối thiểu, thay vì dừng ngay lập tức nếu nút không còn được nhấn. Để làm việc này, bạn có thể trực tiếp bắt đầu và dừng ảnh động từ bên trong hàm lambda thu thập, thay vì ghi vào một trạng thái. Có một ví dụ về mẫu này trong phần Tạo Indication nâng cao có đường viền động.
Ví dụ: Tạo thành phần có tính năng xử lý tương tác tuỳ chỉnh
Để xem cách bạn có thể tạo thành phần với một phản hồi tùy chỉnh cho mục nhập, dưới đây là ví dụ về nút đã sửa đổi. Trong trường hợp này, giả sử bạn muốn có một nút phản hồi về các lần nhấn bằng cách thay đổi giao diện của nút:
Để làm việc này, hãy tạo một thành phần kết hợp tùy chỉnh dựa trên Button và yêu cầu tham số icon bổ sung để vẽ biểu tượng (trong trường hợp này là giỏ hàng). Bạn gọi collectIsPressedAsState() để theo dõi xem người dùng có di chuột qua nút hay không; khi nút được nhấn, bạn sẽ thêm biểu tượng vào. Dưới đây là giao diện của mã:
@Composable fun PressIconButton( onClick: () -> Unit, icon: @Composable () -> Unit, text: @Composable () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource? = null ) { val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false Button( onClick = onClick, modifier = modifier, interactionSource = interactionSource ) { AnimatedVisibility(visible = isPressed) { if (isPressed) { Row { icon() Spacer(Modifier.size(ButtonDefaults.IconSpacing)) } } } text() } }
Dưới đây là giao diện khi sử dụng thành phần kết hợp mới:
PressIconButton( onClick = {}, icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) }, text = { Text("Add to cart") } )
Vì PressIconButton mới này được tạo dựa trên Tài liệu Button hiện có, nó này phản ứng với các hành động tương tác của người dùng theo mọi cách thông thường. Khi người dùng nhấn nút, nút này sẽ thay đổi độ mờ một chút, giống như một Tài liệu thông thường Button.
Tạo và áp dụng hiệu ứng tuỳ chỉnh có thể sử dụng lại bằng Indication
Trong các phần trước, bạn đã tìm hiểu cách thay đổi một phần của thành phần để phản hồi các Interaction khác nhau, chẳng hạn như hiển thị biểu tượng khi được nhấn. Bạn có thể sử dụng cùng một phương pháp này để thay đổi giá trị của các tham số mà bạn cung cấp cho một thành phần hoặc thay đổi nội dung hiển thị bên trong một thành phần, nhưng phương pháp này chỉ áp dụng cho từng thành phần. Thông thường, một ứng dụng hoặc hệ thống thiết kế sẽ có một hệ thống chung cho các hiệu ứng hình ảnh có trạng thái — một hiệu ứng cần được áp dụng cho tất cả các thành phần theo cách nhất quán.
Nếu bạn đang xây dựng loại hệ thống thiết kế này, thì việc tuỳ chỉnh một thành phần và sử dụng lại thành phần tuỳ chỉnh này cho các thành phần khác có thể gặp khó khăn vì những lý do sau:
- Mọi thành phần trong hệ thống thiết kế đều cần cùng một mã nguyên mẫu
- Bạn rất dễ quên áp dụng hiệu ứng này cho các thành phần mới tạo và thành phần tuỳ chỉnh có thể nhấp vào
- Có thể khó kết hợp hiệu ứng tuỳ chỉnh với các hiệu ứng khác
Để tránh những vấn đề này và dễ dàng điều chỉnh quy mô của một thành phần tuỳ chỉnh trên toàn hệ thống,
bạn có thể sử dụng Indication.
Indication đại diện cho một hiệu ứng hình ảnh có thể sử dụng lại, áp dụng được cho nhiều thành phần trong một ứng dụng hoặc hệ thống thiết kế. Indication được chia thành hai
phần:
IndicationNodeFactory: Một nhà máy tạo ra các thực thểModifier.Nodekết xuất hiệu ứng hình ảnh cho một thành phần. Đối với các phương thức triển khai đơn giản hơn không thay đổi giữa các thành phần, đây có thể là một singleton (đối tượng) và được sử dụng lại trên toàn bộ ứng dụng.Các thực thể này có thể có trạng thái hoặc không có trạng thái. Vì được tạo theo từng thành phần, nên các thành phần này có thể truy xuất giá trị từ
CompositionLocalđể thay đổi cách xuất hiện hoặc hoạt động bên trong một thành phần cụ thể, giống như mọiModifier.Nodekhác.Modifier.indication: Đối tượng sửa đổi vẽIndicationcho một thành phần.Modifier.clickablevà các đối tượng sửa đổi tương tác cấp cao khác chấp nhận trực tiếp tham số chỉ báo, vì vậy, chúng không chỉ phát raInteractionmà còn có thể vẽ hiệu ứng hình ảnh choInteractionmà chúng phát ra. Vì vậy, đối với các trường hợp đơn giản, bạn chỉ cần sử dụngModifier.clickablemà không cầnModifier.indication.
Thay thế hiệu ứng bằng Indication
Phần này mô tả cách thay thế hiệu ứng điều chỉnh tỷ lệ thủ công được áp dụng cho một nút cụ thể bằng một chỉ báo tương đương có thể sử dụng lại trên nhiều thành phần.
Đoạn mã sau đây tạo một nút thu nhỏ khi nhấn:
val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale") Button( modifier = Modifier.scale(scale), onClick = { }, interactionSource = interactionSource ) { Text(if (isPressed) "Pressed!" else "Not pressed") }
Để chuyển đổi hiệu ứng tỷ lệ trong đoạn mã trên thành Indication, hãy làm theo các bước sau:
Tạo
Modifier.Nodechịu trách nhiệm áp dụng hiệu ứng điều chỉnh tỷ lệ. Khi được đính kèm, nút này sẽ quan sát nguồn tương tác, tương tự như các ví dụ trước. Điểm khác biệt duy nhất ở đây là nó trực tiếp chạy ảnh động thay vì chuyển đổi các Tương tác đến thành trạng thái.Nút này cần triển khai
DrawModifierNodeđể có thể ghi đèContentDrawScope#draw()và kết xuất hiệu ứng tỷ lệ bằng cách sử dụng cùng một lệnh vẽ như với bất kỳ API đồ hoạ nào khác trong Compose.Việc gọi
drawContent()có sẵn từ trình nhậnContentDrawScopesẽ vẽ thành phần thực tế màIndicationsẽ được áp dụng, vì vậy, bạn chỉ cần gọi hàm này trong một phép biến đổi tỷ lệ. Đảm bảo các phương thức triển khaiIndicationluôn gọidrawContent()tại một thời điểm nào đó; nếu không, thành phần mà bạn đang áp dụngIndicationsẽ không được vẽ.private class ScaleNode(private val interactionSource: InteractionSource) : Modifier.Node(), DrawModifierNode { var currentPressPosition: Offset = Offset.Zero val animatedScalePercent = Animatable(1f) private suspend fun animateToPressed(pressPosition: Offset) { currentPressPosition = pressPosition animatedScalePercent.animateTo(0.9f, spring()) } private suspend fun animateToResting() { animatedScalePercent.animateTo(1f, spring()) } override fun onAttach() { coroutineScope.launch { interactionSource.interactions.collectLatest { interaction -> when (interaction) { is PressInteraction.Press -> animateToPressed(interaction.pressPosition) is PressInteraction.Release -> animateToResting() is PressInteraction.Cancel -> animateToResting() } } } } override fun ContentDrawScope.draw() { scale( scale = animatedScalePercent.value, pivot = currentPressPosition ) { this@draw.drawContent() } } }
Tạo
IndicationNodeFactory. Trách nhiệm duy nhất của lớp này là tạo một thực thể nút mới cho một nguồn tương tác được cung cấp. Vì không có tham số nào để định cấu hình chỉ báo, nên factory có thể là một đối tượng:object ScaleIndication : IndicationNodeFactory { override fun create(interactionSource: InteractionSource): DelegatableNode { return ScaleNode(interactionSource) } override fun equals(other: Any?): Boolean = other === ScaleIndication override fun hashCode() = 100 }
Modifier.clickablesử dụng nội bộModifier.indication, vì vậy, để tạo một thành phần có thể nhấp bằngScaleIndication, bạn chỉ cần cung cấpIndicationdưới dạng tham số choclickable:Box( modifier = Modifier .size(100.dp) .clickable( onClick = {}, indication = ScaleIndication, interactionSource = null ) .background(Color.Blue), contentAlignment = Alignment.Center ) { Text("Hello!", color = Color.White) }
Điều này cũng giúp bạn dễ dàng tạo các thành phần cấp cao, có thể sử dụng lại bằng cách sử dụng
Indicationtuỳ chỉnh — một nút có thể có dạng như sau:@Composable fun ScaleButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, interactionSource: MutableInteractionSource? = null, shape: Shape = CircleShape, content: @Composable RowScope.() -> Unit ) { Row( modifier = modifier .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp) .clickable( enabled = enabled, indication = ScaleIndication, interactionSource = interactionSource, onClick = onClick ) .border(width = 2.dp, color = Color.Blue, shape = shape) .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = content ) }
Sau đó, bạn có thể sử dụng nút này theo cách sau:
ScaleButton(onClick = {}) { Icon(Icons.Filled.ShoppingCart, "") Spacer(Modifier.padding(10.dp)) Text(text = "Add to cart!") }
Indication tuỳ chỉnh.Tạo Indication nâng cao có đường viền động
Indication không chỉ giới hạn ở các hiệu ứng biến đổi, chẳng hạn như điều chỉnh tỷ lệ của một thành phần. Vì IndicationNodeFactory trả về Modifier.Node, nên bạn có thể vẽ bất kỳ loại hiệu ứng nào ở trên hoặc dưới nội dung như với các API vẽ khác. Ví dụ: bạn có thể vẽ đường viền động xung quanh thành phần và lớp phủ trên đầu thành phần khi nhấn vào thành phần đó:
Indication.Cách triển khai Indication ở đây rất giống với ví dụ trước — chỉ tạo một nút có một số tham số. Vì đường viền động phụ thuộc vào hình dạng và đường viền của thành phần mà Indication được sử dụng, nên việc triển khai Indication cũng yêu cầu cung cấp hình dạng và độ rộng đường viền dưới dạng tham số:
data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory { override fun create(interactionSource: InteractionSource): DelegatableNode { return NeonNode( shape, // Double the border size for a stronger press effect borderWidth * 2, interactionSource ) } }
Cách triển khai Modifier.Node cũng giống nhau về mặt lý thuyết, ngay cả khi mã vẽ phức tạp hơn. Như trước đây, lớp này quan sát InteractionSource khi được đính kèm, chạy ảnh động và triển khai DrawModifierNode để vẽ hiệu ứng lên trên nội dung:
private class NeonNode( private val shape: Shape, private val borderWidth: Dp, private val interactionSource: InteractionSource ) : Modifier.Node(), DrawModifierNode { var currentPressPosition: Offset = Offset.Zero val animatedProgress = Animatable(0f) val animatedPressAlpha = Animatable(1f) var pressedAnimation: Job? = null var restingAnimation: Job? = null private suspend fun animateToPressed(pressPosition: Offset) { // Finish any existing animations, in case of a new press while we are still showing // an animation for a previous one restingAnimation?.cancel() pressedAnimation?.cancel() pressedAnimation = coroutineScope.launch { currentPressPosition = pressPosition animatedPressAlpha.snapTo(1f) animatedProgress.snapTo(0f) animatedProgress.animateTo(1f, tween(450)) } } private fun animateToResting() { restingAnimation = coroutineScope.launch { // Wait for the existing press animation to finish if it is still ongoing pressedAnimation?.join() animatedPressAlpha.animateTo(0f, tween(250)) animatedProgress.snapTo(0f) } } override fun onAttach() { coroutineScope.launch { interactionSource.interactions.collect { interaction -> when (interaction) { is PressInteraction.Press -> animateToPressed(interaction.pressPosition) is PressInteraction.Release -> animateToResting() is PressInteraction.Cancel -> animateToResting() } } } } override fun ContentDrawScope.draw() { val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition( currentPressPosition, size ) val brush = animateBrush( startPosition = startPosition, endPosition = endPosition, progress = animatedProgress.value ) val alpha = animatedPressAlpha.value drawContent() val outline = shape.createOutline(size, layoutDirection, this) // Draw overlay on top of content drawOutline( outline = outline, brush = brush, alpha = alpha * 0.1f ) // Draw border on top of overlay drawOutline( outline = outline, brush = brush, alpha = alpha, style = Stroke(width = borderWidth.toPx()) ) } /** * Calculates a gradient start / end where start is the point on the bounding rectangle of * size [size] that intercepts with the line drawn from the center to [pressPosition], * and end is the intercept on the opposite end of that line. */ private fun calculateGradientStartAndEndFromPressPosition( pressPosition: Offset, size: Size ): Pair<Offset, Offset> { // Convert to offset from the center val offset = pressPosition - size.center // y = mx + c, c is 0, so just test for x and y to see where the intercept is val gradient = offset.y / offset.x // We are starting from the center, so halve the width and height - convert the sign // to match the offset val width = (size.width / 2f) * sign(offset.x) val height = (size.height / 2f) * sign(offset.y) val x = height / gradient val y = gradient * width // Figure out which intercept lies within bounds val intercept = if (abs(y) <= abs(height)) { Offset(width, y) } else { Offset(x, height) } // Convert back to offsets from 0,0 val start = intercept + size.center val end = Offset(size.width - start.x, size.height - start.y) return start to end } private fun animateBrush( startPosition: Offset, endPosition: Offset, progress: Float ): Brush { if (progress == 0f) return TransparentBrush // This is *expensive* - we are doing a lot of allocations on each animation frame. To // recreate a similar effect in a performant way, it would be better to create one large // gradient and translate it on each frame, instead of creating a whole new gradient // and shader. The current approach will be janky! val colorStops = buildList { when { progress < 1 / 6f -> { val adjustedProgress = progress * 6f add(0f to Blue) add(adjustedProgress to Color.Transparent) } progress < 2 / 6f -> { val adjustedProgress = (progress - 1 / 6f) * 6f add(0f to Purple) add(adjustedProgress * MaxBlueStop to Blue) add(adjustedProgress to Blue) add(1f to Color.Transparent) } progress < 3 / 6f -> { val adjustedProgress = (progress - 2 / 6f) * 6f add(0f to Pink) add(adjustedProgress * MaxPurpleStop to Purple) add(MaxBlueStop to Blue) add(1f to Blue) } progress < 4 / 6f -> { val adjustedProgress = (progress - 3 / 6f) * 6f add(0f to Orange) add(adjustedProgress * MaxPinkStop to Pink) add(MaxPurpleStop to Purple) add(MaxBlueStop to Blue) add(1f to Blue) } progress < 5 / 6f -> { val adjustedProgress = (progress - 4 / 6f) * 6f add(0f to Yellow) add(adjustedProgress * MaxOrangeStop to Orange) add(MaxPinkStop to Pink) add(MaxPurpleStop to Purple) add(MaxBlueStop to Blue) add(1f to Blue) } else -> { val adjustedProgress = (progress - 5 / 6f) * 6f add(0f to Yellow) add(adjustedProgress * MaxYellowStop to Yellow) add(MaxOrangeStop to Orange) add(MaxPinkStop to Pink) add(MaxPurpleStop to Purple) add(MaxBlueStop to Blue) add(1f to Blue) } } } return linearGradient( colorStops = colorStops.toTypedArray(), start = startPosition, end = endPosition ) } companion object { val TransparentBrush = SolidColor(Color.Transparent) val Blue = Color(0xFF30C0D8) val Purple = Color(0xFF7848A8) val Pink = Color(0xFFF03078) val Orange = Color(0xFFF07800) val Yellow = Color(0xFFF0D800) const val MaxYellowStop = 0.16f const val MaxOrangeStop = 0.33f const val MaxPinkStop = 0.5f const val MaxPurpleStop = 0.67f const val MaxBlueStop = 0.83f } }
Điểm khác biệt chính ở đây là hiện có thời lượng tối thiểu cho ảnh động có hàm animateToResting(), vì vậy, ngay cả khi thao tác nhấn được nhả ngay lập tức, ảnh động nhấn vẫn sẽ tiếp tục. Ngoài ra, còn có cách xử lý cho nhiều lần nhấn nhanh ở đầu animateToPressed — nếu một lần nhấn xảy ra trong một lần nhấn hiện có hoặc ảnh động nghỉ, thì ảnh động trước đó sẽ bị huỷ và ảnh động nhấn sẽ bắt đầu từ đầu. Để hỗ trợ nhiều hiệu ứng đồng thời (chẳng hạn như với hiệu ứng gợn sóng, trong đó ảnh động gợn sóng mới sẽ vẽ lên trên các gợn sóng khác), bạn có thể theo dõi ảnh động trong danh sách, thay vì huỷ ảnh động hiện có và bắt đầu ảnh động mới.
Đề xuất cho bạn
- Lưu ý: văn bản có đường liên kết sẽ hiện khi JavaScript tắt
- Tìm hiểu về cử chỉ
- Kotlin cho Jetpack Compose
- Thành phần và bố cục Material