Xử lý các hoạt động tương tác của người dùng

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 làm để họ biết cách kéo ngón tay ra khỏi nút trước khi nếu không, nút này sẽ kích hoạt.

Hình 1. Các nút luôn hiển thị là được kích hoạt mà không có gợn sóng khi nhấn.
Hình 2. Các nút có gợn sóng nhấn phản ánh trạng thái bật tương ứng.

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 về Lượt tương tác làm sáng tỏ.

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()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()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 theo dõi các lượt tương tác chính bạn 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 với InteractionSourceMutableInteractionSource tương ứng.

Tiêu thụ và phát thải Interaction

InteractionSource biểu thị luồng Interactions chỉ đọc chứ không phải có 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 hoặc sử dụng và phát Interactions. Các phần sau đây mô tả cách tiếp nhận và phát các lượt tương tác từ cả đối tượng sửa đổi và thành phần.

Ví dụ về việc 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 nên bạn có thể chấp nhận một InteractionSource:

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

Chữ ký hàm rõ ràng cho thấy đối tượng sửa đổi này là người tiêu dùng (consumer) có thể tiêu thụ Interaction nhưng không thể phát chúng.

Ví dụ về quá trình 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 ra Interactions và chấp nhận MutableInteractionSource làm một thay vào đó:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

Đối tượng sửa đổi này là một thực thể sản xuất — có thể sử dụng MutableInteractionSource để phát HoverInteractions khi di chuột hoặc không di chuột.

Xây dựng các thành phần tiêu thụ và sản xuất

Các thành phần cấp cao như Button của Material đóng vai trò vừa là nhà sản xuất và người tiêu dùng. Chúng xử lý các sự kiện nhập và tiêu điểm, đồng thời thay đổi giao diện của mình để phản hồi các sự kiện này, chẳng hạn như hiển thị một hiệu ứng gợn sóng hoặc tạo ảnh động cho độ cao. Do đó, chúng trực tiếp hiển thị MutableInteractionSource dưới dạng một để bạn có thể cung cấp phiên bản đã 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ả Interaction do thành phần tạo ra. Bạn có thể sử dụng tính năng 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 cấp cao có tính tương tác của riêng mình, bạn nên bạn hiển thị MutableInteractionSource dưới dạng tham số theo cách này. Bên cạnh thực hiện theo các phương pháp hay nhất để chuyển trạng thái lên trên, điều này cũng giúp người dùng dễ dàng đọc điều khiển trạng thái hiển thị của một thành phần theo cách tương tự như bất kỳ loại Trạng thái (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 tiếp cận cấu 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 bản dựng nền tảng khối tạo ra Interaction chúng cần để kiểm soát gợn sóng và các hiệu ứng hình ảnh. 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 chẳng hạn như Modifier.hoverable, Modifier.focusableModifier.draggable.

Để tạo một thành phần phản hồi 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, thành phần đó sẽ tạo ra HoverInteraction và bạn có thể sử dụng thao tác 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ó thể làm tâm điểm, bạn có thể thêm Modifier.focusable và truyền MutableInteractionSource tương tự với tham số. Giờ đây, cả hai HoverInteraction.Enter/ExitFocusInteraction.Focus/Unfocus được phát ra thông qua cùng một MutableInteractionSource, và bạn có thể tuỳ chỉnh 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 thậm chí còn cao hơn trừu tượng ở cấp độ cao hơn hoverablefocusable – để một thành phần trở thành có thể nhấp, nó hoàn toàn có thể di chuột được và các thành phần có thể nhấp vào được cũng có thể làm tâm điểm. Bạn có thể dùng Modifier.clickable để tạo một thành phần xử lý thao tác di chuột, lấy tiêu điểm và nhấn mà không cần phải kết hợp các tương tác cấp độ. Nếu bạn cũng muốn làm cho thành phần của mình có thể nhấp vào, bạn có thể thay thế hoverablefocusable 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 trong danh sách. Ví dụ: đây là cách triển khai hiệu ứng gợn sóng trong Compose chỉ ra lớp phủ trạng thái thích hợp để sử dụng cho 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ả các Interaction đều theo cùng một cấu trúc, nên sẽ 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 — 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 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 cập nhật, vì việc đọc giá trị trạng thái sẽ tự động dẫn đến các quá trình kết hợp lại. Tuy nhiên, cấu trúc được nhóm trước khung hình. Điều này có nghĩa là nếu trạng thái thay đổi và thì sẽ quay lại trong cùng một khung, các thành phần quan sát trạng thái sẽ không xem 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 hoạt động 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 thao tác 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ờ xuất hiện dưới dạng "Đã nhấn!". Trong hầu hết các trường hợp, đây không phải là vấn đề — hiển thị hiệu ứng hình ảnh cho khoảng thời gian ngắn như vậy sẽ dẫn đến nhấp nháy và sẽ không đáng chú ý với 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 lượng tối thiểu thay vì dừng ngay lập tức nếu không còn nhấn nút. Người nhận bạn có thể trực tiếp bắt đầu và dừng ảnh động từ bên trong bộ sưu tập lambda, 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 bằng đường viền động.

Ví dụ: Tạo thành phần có khả 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:

Ảnh động về nút tự động thêm biểu tượng giỏ hàng tạp hoá khi được nhấp vào
Hình 3. Nút tự động thêm biểu tượng khi được nhấp vào.

Để 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") }
)

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 trong phản hồi cho nhiều Interaction, chẳng hạn như hiện một biểu tượng khi được nhấn. Cũng như vậy phương pháp tiếp cận có thể được sử dụng để thay đổi giá trị của các thông số mà bạn cung cấp thành 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 điều này chỉ áp dụng theo từng thành phần. Thông thường, một hệ thống ứng dụng hoặc 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 nên được áp dụng nhất quán cho tất cả các thành phần.

Nếu bạn đang xây dựng loại hệ thống thiết kế này, hãy tuỳ chỉnh một thành phần và Việc sử dụng lại tuỳ chỉnh này cho các thành phần khác có thể gây khó khăn cho các 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 có thể dễ quên áp dụng hiệu ứng này cho các thành phần mới xây dựng và thành phần có thể nhấp
  • Có thể bạn sẽ gặp khó khăn khi 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ô thành phần tuỳ chỉnh trên hệ thống của bạn, bạn có thể dùng Indication. Indication thể hiện hiệu ứng hình ảnh có thể tái sử dụng, có thể áp dụng cho các thành phần trong một hệ thống thiết kế hoặc ứng dụng. Indication được chia thành hai phần:

  • IndicationNodeFactory: Nhà máy tạo ra các thực thể Modifier.Node hiển thị 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 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ì chúng được tạo trên mỗi thành phần, chúng 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ể, như với bất kỳ thành phần nào khác Modifier.Node

  • Modifier.indication: Một đối tượng sửa đổi vẽ Indication cho một thành phần. Modifier.clickable và các hệ số sửa đổi tương tác cấp cao khác trực tiếp chấp nhận thông số chỉ báo để chúng không chỉ phát Interaction, nhưng cũng có thể vẽ hiệu ứng hình ảnh cho Interaction 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ụng Modifier.clickable mà không cần cần Modifier.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 hiệu ứng nút cụ thể có chỉ báo tương đương có thể được sử dụng lại trên nhiều thành phần.

Đoạn mã sau đây sẽ tạo ra một nút thu nhỏ khi nhấn vào:

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:

  1. 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 này sẽ quan sát nguồn tương tác, tương tự như trước ví dụ. Đ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 Lượt tương tác sắp tới sang trạng thái.

    Nút này cần triển khai DrawModifierNode để có thể ghi đè ContentDrawScope#draw() rồi kết xuất hiệu ứng tỷ lệ bằng cùng một bản vẽ giống như mọi API đồ hoạ khác trong Compose.

    Việc gọi drawContent() có từ bộ nhận ContentDrawScope sẽ vẽ thành phần thực tế sẽ áp dụng Indication. cần gọi hàm này trong phép biến đổi tỷ lệ. Hãy đảm bảo rằng Quá trình triển khai Indication luôn gọi drawContent() tại một thời điểm nào đó; nếu không, thành phần bạn đang áp dụng Indication 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()
            }
        }
    }

  2. Tạo IndicationNodeFactory. Trách nhiệm duy nhất của đơn vị quảng cáo này là tạo bản sao nút mới cho nguồn tương tác đã cung cấp. Vì không có các tham số để định cấu hình chỉ báo, 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
    }

  3. Modifier.clickable sử dụng Modifier.indication nội bộ, vì vậy để tạo một thành phần có thể nhấp bằng ScaleIndication, bạn chỉ cần cung cấp Indication làm tham số cho clickable:

    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ể tái sử dụng nhờ sử dụng một Indication — 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ể 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!")
}

Ảnh động về nút có biểu tượng giỏ hàng tạp hoá trở nên nhỏ hơn khi nhấn vào
Hình 4. Một nút được tạo bằng Indication tuỳ chỉnh.

Tạo một Indication nâng cao với đường viền động

Indication không chỉ giới hạn ở hiệu ứng chuyển đổi, chẳng hạn như điều chỉnh tỷ lệ 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 bên trên hoặc bên dưới nội dung cũng như đối với các API vẽ khác. Cho 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 được nhấn:

Nút có hiệu ứng cầu vồng lạ mắt khi nhấn
Hình 5. Hiệu ứng đường viền động được vẽ bằng Indication.

Cách triển khai Indication ở đây rất giống với ví dụ trước — nó chỉ tạo một nút với một số tham số. Vì đường viền động phụ thuộc trên hình dạng và đường viền của thành phần mà Indication được sử dụng, 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 giống nhau, ngay cả khi mã vẽ phức tạp hơn. Như trước, nó quan sát InteractionSource khi đính kèm, khởi chạy ảnh động và triển khai DrawModifierNode để vẽ tác động lê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 tại có một thời lượng tối thiểu cho ảnh động bằng hàm animateToResting(), vì vậy, ngay cả khi thao tác nhấn là phát hành ngay lập tức, ảnh động báo chí sẽ tiếp tục. Ngoài ra còn có quy trình xử lý cho nhiều lần nhấn nhanh ở đầu animateToPressed – nếu một lần nhấn xảy ra trong khi ảnh động đang nhấn hoặc nghỉ, hoạt ảnh trước đó là bị huỷ và hoạt ảnh nhấn sẽ bắt đầu từ đầu. Để hỗ trợ nhiều hiệu ứng đồng thời (chẳng hạn như hiệu ứng gợn sóng, trong đó ảnh động gợn sóng mới sẽ vẽ bên trên các hiệu ứng gợn sóng khác), bạn có thể theo dõi ảnh động trong một danh sách, thay vì việc huỷ các hoạt ảnh hiện có và bắt đầu các hoạt ảnh mới.