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 đang 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 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 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()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 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 InteractionSourceMutableInteractionSource.

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.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 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/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 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 hoverablefocusable — để 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ế 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ụ: 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:

Ảnh động về một nút tự động thêm biểu tượng giỏ hàng khi người dùng 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 để 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ọi Modifier.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 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 có thể 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 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:

  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 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ậ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 một phép biến đổi tỷ lệ. Hãy đảm bảo 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 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 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
    }

  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 dưới dạng 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ể 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!")
}

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

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:

Một 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.

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.