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 đề cập đến 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ư thao tác 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.
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 trên blog Illu Interactions (Làm nổi bật các tương tác).
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 bạn tự theo dõi các hoạt động tương tác với 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 các tương tác lần lượt với InteractionSource
và MutableInteractionSource
.
Tiêu thụ và phát Interaction
InteractionSource
đại diện cho luồng Interactions
chỉ đọc – 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
.
Các đối tượng sửa đổi và thành phần có thể sử dụng, phát hoặc tiêu thụ và phát Interactions
.
Các phần sau đây mô tả cách sử dụng và phát ra các tương tác từ cả đối tượng sửa đổi và thành phần.
Ví dụ về cách sử dụng đối tượng sửa đổi
Đối với đối tượng sửa đổi vẽ đường viền cho trạng thái được lấy làm tâm điểm, bạn chỉ cần quan sát Interactions
để có thể chấp nhận InteractionSource
:
fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier { // ... }
Chữ ký hàm cho thấy rõ rằng đối tượng sửa đổi này là đối tượng tiêu dùng – có thể sử dụng Interaction
, nhưng không thể phát các đối tượng đó.
Ví dụ về việc tạo đối tượng sửa đổi
Đối với một đố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
dưới dạng 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 – có thể sử dụng MutableInteractionSource
được cung cấp để phát HoverInteractions
khi di chuột hoặc di chuyển.
Tạo các thành phần tiêu thụ và tạo ra
Các thành phần cấp cao như Material Button
đóng vai trò vừa là nhà sản xuất vừa là người tiêu dùng. Các API này xử lý các sự kiện đầu vào và tiêu điểm, cũng như 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 độ nâng của các sự kiện đó. Do đó, các đối tượng 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 chuyển MutableInteractionSource
ra khỏi thành phần và quan sát tất cả các Interaction
do thành phần tạo ra. Bạn có thể sử dụng tuỳ chọn 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. Bên cạnh các phương pháp hay nhất về việc chuyển trạng thái lên trên, điều này còn 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ư cách đọc và kiểm soát bất kỳ loại trạng thái nào khác (chẳng hạn như trạng thái đã bật).
Compose tuân theo phương pháp kiến trúc phân lớp, do đó, các thành phần Material cấp cao được xây dựng trên các khối bản dựng cơ bản 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 thông số.
Bất cứ khi nào thành phần được di chuột, thành phần đó sẽ phát ra HoverInteraction
và bạn có thể sử dụng tính năng 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!") }
Để làm cho thành phần này có thể làm tâm điểm, bạn có thể thêm Modifier.focusable
và truyền cùng MutableInteractionSource
làm tham số. Giờ đây, cả HoverInteraction.Enter/Exit
và FocusInteraction.Focus/Unfocus
đều được phát 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à đối tượng trừu tượng ở cấp độ cao hơn so với hoverable
và focusable
— để một thành phần có thể nhấp, thành phần đó ngầm ẩn có thể di chuột được 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ó thể nhấp vào đượ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ụ: dưới đây là cách triển khai hiệu ứng gợn sóng Compose tìm ra 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 khác biệt về mã khi xử lý nhiều kiểu tương tác của người dùng — mẫu tổng thể đều 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 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 tạo ra các quá trình kết hợp lại. Tuy nhiên, quá trình kết hợp được phân lô trước khung hình. Điều này có nghĩa là nếu trạng thái thay đổi và sau đó thay đổi trở lại trong cùng một khung, thì các thành phần quan sát trạng thái sẽ không thấy thay đổi đó.
Chỉ số 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. 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 một lượt nhấn bắt đầu và kết thúc trong cùng một khung, thì văn bản sẽ không bao giờ hiển thị là "Pressed!" (Đã 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 nhấp nháy và sẽ không dễ nhận thấy
người dùng. Đối với 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 không còn nhấn nút. Để thực hiện việc này, bạn có thể trực tiếp bắt đầu và dừng ảnh động từ bên trong lambda thu thập, thay vì ghi vào một trạng thái. Bạn có thể xem một ví dụ về mẫu này trong phần Tạo Indication
nâng cao bằng đường viền động.
Ví dụ: Thành phần bản dựng có 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 một thành phần để phản hồi nhiều Interaction
, chẳng hạn như hiển thị một biểu tượng khi được nhấn. Bạn có thể sử dụng phương pháp tương tự 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, ứng dụng hoặc hệ thống thiết kế sẽ có một hệ thống chung cho hiệu ứng hình ảnh trạng thái – một hiệu ứng phải được áp dụng nhất quán cho tất cả thành phần.
Nếu đang xây dựng loại hệ thống thiết kế này, thì bạn có thể gặp khó khăn khi tuỳ chỉnh một thành phần và sử dụng lại chế độ tuỳ chỉnh này cho các thành phần khác vì những lý do sau:
- Mọi thành phần trong hệ thống thiết kế đều cần có 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 được tạo và các thành phần tuỳ chỉnh có thể nhấp
- 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 mở rộng quy mô của một thành phần tuỳ chỉnh trên hệ thống, bạn có thể sử dụng Indication
.
Indication
đại diện cho hiệu ứng hình ảnh có thể sử dụng lại, có thể áp dụng trên các thành phần trong ứng dụng hoặc hệ thống thiết kế. Indication
được chia thành 2 phần:
IndicationNodeFactory
: Một nhà máy tạo ra các thực thểModifier.Node
kết xuất hiệu ứng hình ảnh cho một thành phần. Đối với những cách triển khai đơn giản hơn mà không thay đổi trên 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.Những trường hợp 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 lớp này có thể truy xuất các giá trị từ
CompositionLocal
để thay đổi cách chúng 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.Node
khác.Modifier.indication
: Một đối tượng sửa đổi vẽIndication
cho một thành phần.Modifier.clickable
và 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 raInteraction
mà còn có thể vẽ các hiệu ứng hình ảnh choInteraction
mà chúng phát ra. Vì vậy, đối với các trường hợp đơn giản, bạn có thể chỉ cần sử dụngModifier.clickable
mà 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 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.
Mã sau đây tạo một nút thu nhỏ xuống 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.Node
chịu trách nhiệm áp dụng hiệu ứng tỷ lệ. Khi được đính kèm, nút 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à trực tiếp khởi chạy ảnh động thay vì chuyển đổi các Tương tác đến sang trạng thái.Nút cần triển khai
DrawModifierNode
để có thể ghi đèContentDrawScope#draw()
và hiển thị hiệu ứng tỷ lệ bằng cách sử dụng các lệnh vẽ tương tự như với mọi API đồ hoạ khác trong Compose.Việc gọi
drawContent()
có sẵn từ trình thu nhậnContentDrawScope
sẽ vẽ thành phần thực tế màIndication
sẽ đượ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ệ. Hãy đảm bảo quá trình triển khaiIndication
luô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ụngIndication
sẽ 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 bạn là tạo một thực thể nút mới cho nguồn tương tác đã cung cấp. Vì không có tham số để đị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.clickable
sử dụngModifier.indication
nội bộ. Vì vậy, để tạo một thành phần có thể nhấp bằngScaleIndication
, bạn chỉ cần cung cấpIndication
dướ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 xây dựng các thành phần cấp cao, có thể sử dụng lại bằng cách sử dụng
Indication
tuỳ chỉnh – 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ể 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!") }
Tạo Indication
nâng cao bằng đườ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 theo tỷ lệ một thành phần. Vì IndicationNodeFactory
trả về một 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ẽ một đường viền động xung quanh thành phần và một lớp phủ ở đầu thành phần khi nhấn:
Việc triển khai Indication
ở đây rất giống với ví dụ trước,
chỉ tạo một nút với 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 chứa Indication
, nên quá trình triển khai Indication
cũng yêu cầu cung cấp hình dạng và chiều 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 ) } }
Về mặt lý thuyết, việc triển khai Modifier.Node
cũng tương 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, khởi chạy ảnh động và triển khai DrawModifierNode
để vẽ hiệu ứng 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à ảnh động bằng hàm animateToResting()
hiện có thời lượng tối thiểu, vì vậy, ngay cả khi thao tác nhấn được nhả ra ngay lập tức, ảnh động nhấn sẽ tiếp tục. Ngoài ra, còn có cách xử lý nhiều lần nhấn nhanh ở đầu animateToPressed
– nếu một thao tác nhấn xảy ra trong một ảnh động nhấn hoặc dừng hiện có, thì ảnh động trước đó sẽ bị huỷ và ảnh động nhấn sẽ bắt đầu lại từ đầu. Để hỗ trợ nhiều hiệu ứng đồng thời (chẳng hạn như với các hiệu ứng gợn sóng, trong đó ảnh động gợn sóng mới sẽ vẽ lên trên các hiệu ứng gợn sóng khác), bạn có thể theo dõi các ảnh động trong danh sách, thay vì huỷ các ảnh động hiện có và bắt đầu các ả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