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

Hình 1. Các nút luôn xuất hiện ở trạng thái bật, không có hiệu ứng gợn sóng khi nhấn.
Hình 2. Các nút có hiệu ứng gợn sóng khi nhấn, phản ánh trạng thái được 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 Illuminating Interactions (Làm nổi bật các hoạt động 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()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 tự theo dõi các lượt tương tác bằng InteractionSource, hãy xem phần Làm việc với InteractionSource.

Phần sau đây mô tả cách sử dụng và phát ra các hoạt động tương tác với InteractionSourceMutableInteractionSource, tương ứng.

Tiêu thụ và phát ra Interaction

InteractionSource đại diện cho một luồng chỉ đọc của Interactions – không thể phát một Interaction đến một 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 sử dụng và phát ra các hoạt động tương tác từ cả thành phần và đối tượng sửa đổi.

Ví dụ về đối tượng sửa đổi sử dụng

Đối với một đối tượng sửa đổi vẽ đường viền cho trạng thái được lấy tiêu điểm, bạn chỉ cần quan sát Interactions, vì vậy, bạn có thể chấp nhận một InteractionSource:

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

Rõ ràng từ chữ ký hàm rằng đối tượng sửa đổi này là một consumer (người dùng) – đối tượng này có thể sử dụng Interaction nhưng không thể phát ra chúng.

Ví dụ về công cụ sửa đổi sản phẩm

Đố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 làm tham số thay thế:

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

Đối tượng sửa đổi này là một producer (trình tạo) – đối tượng này có thể dùng MutableInteractionSource đã cung cấp để phát HoverInteractions khi được di chuột hoặc không được di chuột.

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ư Button Material đóng vai trò là cả nhà sản xuất và người tiêu dùng. Chúng xử lý các sự kiện đầu vào và 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 hiệu ứng cho độ nâng của chúng. Do đó, chúng trực tiếp hiển thị MutableInteractionSource dưới dạng một tham số để bạn có thể cung cấp phiên bản đã 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ể dùng thuộc tính 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 tạo các thành phần cấp cao tương tác của riêng mình, bạn nên hiển thị MutableInteractionSource dưới dạng một tham số theo cách này. Ngoài việc tuân theo các phương pháp hay nhất về việc nâng trạng thái, điều này cũng giúp bạn dễ dàng đọc và kiểm soát trạng thái trực quan của một thành phần theo cách mà bạn có thể đọc và kiểm soát mọi loại trạng thái khác (chẳng hạn như trạng thái đã bật).

Compose tuân theo phương pháp tiếp cận theo 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 các khối xây 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, chẳng hạn như Modifier.hoverable, Modifier.focusableModifier.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 làm tham số. Bất cứ khi nào người dùng di chuột lên thành phần, thành phần đó sẽ phát ra HoverInteraction và bạn có thể dùng sự kiệ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 cùng MutableInteractionSource làm tham số. Giờ đây, cả HoverInteraction.Enter/ExitFocusInteraction.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 hoạt động 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 mức độ trừu tượng cao hơn nữa so với hoverablefocusable – để một thành phần có thể nhấp vào, thành phần đó sẽ có thể di chuột một cách ngầm định và các thành phần có thể nhấp vào cũng phải có thể lấy tiêu điểm. Bạn có thể dùng Modifier.clickable để tạo một thành phần xử lý các lượt tương tác khi 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 muốn làm cho thành phần của bạn cũng 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 cuối cùng trong danh sách. Ví dụ: đây là cách triển khai hiệu ứng gợn sóng của 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 khác biệt về mã khi làm việc với các loại hoạt động tương tác của người dùng – mẫu tổng thể là như 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 cập nhật, vì việc đọc giá trị trạng thái sẽ tự động gây ra quá trình kết hợp lại. Tuy nhiên, thành phần được xử lý 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 sự 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 một 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ư khi hiển thị hiệu ứng gợn sóng hoặc một ảnh động tương tự, bạn có thể muố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. Để làm việc này, bạn có thể bắt đầu và dừng trực tiếp các ảnh động từ bên trong 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 bằng đườ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:

Ảnh động của một nút sẽ tự động thêm biểu tượng giỏ hàng tạp hoá khi được nhấp vào
Hình 3. Một nút sẽ 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 thành phần để phản hồi các Interaction khác nhau, chẳng hạn như hiện biểu tượng khi nhấn. Bạn có thể dùng chính 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 đang xây dựng hệ thống thiết kế kiểu này, bạn có thể gặp khó khăn khi tuỳ chỉnh một thành phần và 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 đoạn mã mẫu
  • Bạn có thể dễ dàng 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 có thể nhấp tuỳ chỉnh
  • Bạn có thể 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ô một thành phần tuỳ chỉnh trên hệ thống của bạn, 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 và có thể áp dụng cho các thành phần trong một ứ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 các cách triển khai đơn giản hơn và không thay đổi giữa các thành phần, đây có thể là một singleton (đối tượng) và được dùng lại trên toàn bộ ứng dụng.

    Các phiên bản này có thể có trạng thái hoặc không có trạng thái. Vì được tạo cho mỗi thành phần, nên các thành phần này có thể truy xuất giá trị từ mộ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ỳ Modifier.Node nào 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 một tham số chỉ báo, vì vậy, chúng không chỉ phát ra Interaction mà còn có thể vẽ các 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 chỉ cần sử dụng Modifier.clickable mà không 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 thu phóng 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ể được dùng lại trên nhiều thành phần.

Đoạn mã sau đây tạo một nút có thể 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 thu phóng trong đoạn mã ở trên thành một 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 thu phóng. 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ó khởi chạy trực tiếp các ả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à 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ừ bộ nhận ContentDrawScope 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 quá trình biến đổi tỷ lệ. Đảm bảo rằng các phương thức 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 mà 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 lớp này là tạo một thực thể nút mới cho 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 nhà máy 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 nội bộ Modifier.indication, vì vậy, để tạo một thành phần có thể nhấp bằng ScaleIndication, tất cả những gì bạn cần làm là cung cấp Indication dưới dạng một 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 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 Indication tuỳ chỉnh – một nút có thể trô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!")
}

Ảnh động về một nút có biểu tượng giỏ hàng tạp hoá, biểu tượng này sẽ nhỏ đi khi được nhấn
Hình 4. Một nút được tạo bằng 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ư việc mở rộng 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ủ trên cùng của thành phần khi thành phần đó được nhấn:

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

Cách triển khai Indication ở đây rất giống với ví dụ trước – chỉ cần tạo một nút có một số tham số. Vì đường viền có hiệu ứng phụ thuộc vào hình dạng và đường viền của thành phần mà Indication được dùng, nên việc 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 các 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
        )
    }
}

Việc triển khai Modifier.Node cũng giống nhau về mặt khái niệm, ngay cả khi mã vẽ phức tạp hơn. Như trước đây, thành phần này sẽ quan sát InteractionSource khi được đính kèm, khởi 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à giờ đây, có một khoảng thời gian tối thiểu cho ảnh động bằng hàm animateToResting(), vì vậy, ngay cả khi bạn nhả nút nhấn ngay lập tức, ảnh động nhấn sẽ vẫn tiếp tục. Ngoài ra, còn có cơ chế xử lý cho nhiều lần nhấn nhanh khi bắt đầu animateToPressed – nếu một lần nhấn xảy ra trong quá trình nhấn hiện tại hoặc ảnh động ở trạng thái 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ư hiệu ứng gợn sóng, trong đó một ả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 các ảnh động trong một danh sách thay vì huỷ các ảnh động hiện có và bắt đầu ảnh động mới.