Obsługa interakcji użytkowników

Komponenty interfejsu przekazują użytkownikowi informacje informacje w swoim sposobie reagowania na interakcje z użytkownikiem. Każdy komponent ma własny sposób reagowania na interakcje, co pomaga użytkownikowi zorientować się, co robi jego interakcja. Jeśli na przykład użytkownik dotknie przycisku na ekranie dotykowym urządzenia, ten przycisk może się w jakiś sposób zmienić, na przykład po dodaniu koloru podświetlenia. Dzięki tej zmianie użytkownik będzie wiedzieć, że kliknął przycisk. Jeśli użytkownik nie będzie chciał tego zrobić, będzie wiedział, że trzeba odsunąć palec od przycisku, zanim go zwolni. W przeciwnym razie przycisk zostanie aktywowany.

Rysunek 1. Przyciski, które zawsze są włączone, bez fali.
Rysunek 2. Przyciski z naciskami, które odzwierciedlają stan włączenia.

Dokumentacja Gesty w komponencie tworzenia map opisuje sposób obsługi zdarzeń wskaźnika niskiego poziomu, takich jak ruchy wskaźnika i kliknięcia. Funkcja tworzenia domyślnie wyodrębnia te zdarzenia niskiego poziomu w interakcje wyższego poziomu. Na przykład seria zdarzeń wskaźnika może składać się z naciśnij i zwolnij przycisk. Poznanie tych ogólnych abstrakcji pomoże Ci dostosować sposób, w jaki UI reaguje na użytkowników. Możesz na przykład dostosować sposób zmiany wyglądu komponentu, gdy użytkownik wejdzie z nim w interakcję, albo po prostu rejestrować te działania. Znajdziesz w nim informacje potrzebne do zmodyfikowania standardowych elementów interfejsu lub zaprojektowania własnego.

Interakcje

W wielu przypadkach nie musisz wiedzieć, jak komponent „Tworzenie wiadomości” interpretuje interakcje użytkowników. Na przykład Button określa za pomocą parametru Modifier.clickable, czy użytkownik kliknął przycisk. Jeśli dodajesz do aplikacji typowy przycisk, możesz zdefiniować jego kod onClick, a Modifier.clickable będzie go uruchamiać w razie potrzeby. Oznacza to, że nie musisz wiedzieć, czy użytkownik kliknął ekran, czy też wybrał przycisk za pomocą klawiatury. Modifier.clickable ustala, że użytkownik kliknął reklamę, i odpowiada, uruchamiając Twój kod onClick.

Jeśli jednak chcesz dostosować reakcję komponentu UI na zachowanie użytkownika, musisz wiedzieć więcej o tym, co kryje się za maską. Ta sekcja zawiera niektóre informacje.

Gdy użytkownik wchodzi w interakcję z komponentem interfejsu, system odzwierciedla jego działanie, generując liczbę zdarzeń Interaction. Jeśli na przykład użytkownik dotknie przycisku, zostanie wygenerowany PressInteraction.Press. Jeśli użytkownik uniesie palec do przycisku, zostanie wygenerowany PressInteraction.Release, informujący o zakończeniu kliknięcia. Z drugiej strony, jeśli użytkownik wysunie palec poza przycisk, a potem uniesie go, spowoduje to wygenerowanie przycisku PressInteraction.Cancel. Oznacza on, że jego naciśnięcie zostało anulowane, a nie ukończone.

Interakcje te są niepowiązane. Oznacza to, że te niskopoziomowe zdarzenia interakcji nie mają na celu interpretowania znaczenia działań użytkownika ani ich konsekwencji. Nie interpretują też, które działania użytkownika mogą mieć wyższy priorytet.

Interakcje te zwykle występują w parach, z początkiem i końcem. Druga interakcja zawiera odniesienie do pierwszej. Jeśli na przykład użytkownik dotknie przycisku, a potem uniesie palec, kliknięcie spowoduje wygenerowanie interakcji PressInteraction.Press, a wersja – PressInteraction.Release. Element Release zawiera właściwość press identyfikującą inicjał PressInteraction.Press.

Aby zobaczyć interakcje związane z konkretnym komponentem, obserwuj jego InteractionSource. Komponent InteractionSource powstał na podstawie schematu Kotlin, więc możesz zbierać z niego interakcje tak samo jak w przypadku innych przepływów. Więcej informacji na temat tej decyzji znajdziesz w poście na blogu Illuminating Interactions (Świetne interakcje).

Stan interakcji

Możesz rozszerzyć wbudowane funkcje komponentów, śledząc też interakcje samodzielnie. Może to być np. zmiana koloru po naciśnięciu. Najprostszym sposobem śledzenia interakcji jest obserwowanie odpowiedniego stanu interakcji. InteractionSource udostępnia wiele metod, które pokazują różne stany interakcji jako stan. Jeśli na przykład chcesz sprawdzić, czy dany przycisk został naciśnięty, możesz wywołać jego metodę InteractionSource.collectIsPressedAsState():

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Oprócz collectIsPressedAsState() funkcja Utwórz umożliwia też collectIsFocusedAsState(), collectIsDraggedAsState() i collectIsHoveredAsState(). Są to tak naprawdę wygodne metody oparte na interfejsach API InteractionSource niższego poziomu. W niektórych przypadkach warto użyć tych funkcji niższego poziomu bezpośrednio.

Załóżmy na przykład, że musisz wiedzieć, czy przycisk jest naciśnięty i także, czy to jest przeciągane. Jeśli używasz zarówno funkcji collectIsPressedAsState(), jak i collectIsDraggedAsState(), funkcja tworzenia wykona wiele powielonych zadań i nie ma gwarancji, że wszystkie interakcje pojawią się we właściwej kolejności. W takich przypadkach zalecamy bezpośrednią współpracę z InteractionSource. Więcej informacji o samodzielnym śledzeniu interakcji z narzędziem InteractionSource znajdziesz w artykule Praca z usługą InteractionSource.

Z tej sekcji dowiesz się, jak wykorzystywać i emitować interakcje odpowiednio z elementami InteractionSource i MutableInteractionSource.

Zużycie i emisja: Interaction

InteractionSource reprezentuje strumień tylko do odczytu Interactions – nie można wysłać Interaction do InteractionSource. Aby emitować Interaction s, musisz użyć MutableInteractionSource, który wykracza poza InteractionSource.

Modyfikatory i komponenty mogą wykorzystywać, emitować oraz wykorzystywać i emitować Interactions. Sekcje poniżej pokazują, jak wykorzystywać i generować interakcje z modyfikatorów i komponentów.

Przykład modyfikatora wykorzystania

W przypadku modyfikatora, który rysuje obramowanie w stanie skupienia, wystarczy obserwować tylko Interactions. Możesz więc zaakceptować InteractionSource:

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

Z podpisu funkcji wynika, że ten modyfikator jest konsumentem – może korzystać z funkcji Interaction, ale nie może ich emitować.

Przykład modyfikatora produkcji

W przypadku modyfikatora, który obsługuje zdarzenia najechania kursorem, np. Modifier.hoverable, musisz wygenerować Interactions i zaakceptować MutableInteractionSource jako parametr:

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

Ten modyfikator jest producentem – może używać podanego MutableInteractionSource, aby emitować parametr HoverInteractions, gdy użytkownik najedzie na niego kursorem lub go najedzie.

Tworzenie komponentów, które wykorzystują i produkują

Komponenty wysokiego poziomu, takie jak materiał Button, działają zarówno jako producenci, jak i klienci. Obsługują one zdarzenia wejściowe i fokusu, a także w odpowiedzi na te zdarzenia zmieniają swój wygląd, na przykład pokazują fale lub animowanie wysokości. W rezultacie udostępniają bezpośrednio parametr MutableInteractionSource, dzięki czemu możesz podać własną zapamiętaną instancję:

@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() */ }

Dzięki temu możesz podnieść MutableInteractionSource z komponentu i obserwować wszystkie utworzone przez niego Interaction. Za jego pomocą możesz kontrolować wygląd danego komponentu lub dowolnego innego komponentu w interfejsie.

Jeśli tworzysz własne interaktywne komponenty wysokiego poziomu, zalecamy udostępnienie w ten sposób parametru MutableInteractionSource. Poza stosowaniem sprawdzonych metod zbierania danych ułatwia to również odczyt i kontrolowanie wizualnego stanu komponentu w taki sam sposób, w jaki można odczytywać i kontrolować każdy inny stan (np. stan włączenia).

Komponowanie opiera się na warstwowym podejściu architektonicznym, dlatego wysoki poziom komponentów Material Design jest oparty na podstawowych blokach, które tworzą Interaction potrzebne do kontrolowania echa i innych efektów wizualnych. Biblioteka podstawowa zawiera ogólne modyfikatory interakcji, takie jak Modifier.hoverable, Modifier.focusable i Modifier.draggable.

Aby utworzyć komponent, który reaguje na zdarzenia najechania kursorem, możesz użyć parametru Modifier.hoverable i przekazać MutableInteractionSource jako parametr. Po najechaniu kursorem na komponent wydaje HoverInteraction s. Za pomocą tego ustawienia możesz zmienić wygląd komponentu.

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Aby umożliwić także zaznaczenie tego komponentu, możesz dodać obiekt Modifier.focusable i przekazać jako parametr ten sam parametr MutableInteractionSource. Teraz zarówno sygnał HoverInteraction.Enter/Exit, jak i FocusInteraction.Focus/Unfocus są wysyłane w tym samym miejscu (MutableInteractionSource). Możesz dostosować wygląd obu rodzajów interakcji w tym samym miejscu:

// 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 to abstrakcja na wyższym poziomie niż hoverable i focusable. Aby komponent można było kliknąć, można go było domyślnie najeżdżać kursorem na komponent, a komponenty, które można klikać, powinny też dać się zaznaczyć. Za pomocą Modifier.clickable możesz utworzyć komponent, który obsługuje najeżdżanie, zaznaczanie i naciskanie interakcji bez konieczności łączenia interfejsów API niższego poziomu. Jeśli chcesz, aby komponent także był klikalny, zastąp hoverable i focusable elementem 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!")
}

Praca z: InteractionSource

Jeśli potrzebujesz informacji niskiego poziomu o interakcjach z komponentem, możesz użyć standardowych interfejsów API Flow dla atrybutu InteractionSource komponentu. Załóżmy np., że chcesz prowadzić listę interakcji naciśnięć i przeciągnięcia w przypadku elementu InteractionSource. Ten kod wykonuje połowę zadania, dodając nowe naciśnięcia do listy na bieżąco:

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)
            }
        }
    }
}

Poza dodaniem nowych interakcji musisz też usuwać te, które się zakończą (np. gdy użytkownik zsunie palec z komponentu). To proste, ponieważ końcowe interakcje zawsze odwołują się do powiązanej z nimi interakcji początkowej. Ten kod pokazuje, jak usunąć zakończone interakcje:

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)
            }
        }
    }
}

Jeśli chcesz wiedzieć, czy komponent jest aktualnie naciskany czy przeciągany, musisz tylko sprawdzić, czy element interactions jest pusty:

val isPressedOrDragged = interactions.isNotEmpty()

Aby dowiedzieć się, jaka była ostatnia interakcja, spójrz na ostatni element na liście. Tak na przykład implementacja echa w widoku tworzenia tworzy odpowiednią nakładkę stanu do użycia w przypadku ostatniej interakcji:

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

Wszystkie elementy Interaction mają tę samą strukturę, więc nie ma większego różnicy w kodzie podczas pracy z różnymi typami interakcji użytkownika – ogólny wzorzec jest taki sam.

Poprzednie przykłady w tej sekcji przedstawiają Flow interakcji z użyciem parametru State – ułatwia to obserwowanie zaktualizowanych wartości, ponieważ odczyt wartości stanu spowoduje automatyczne zmiany kompozycji. Kompozycja jest jednak zbiorczo wstępnie kadrowana. Oznacza to, że jeśli stan zmieni się, a następnie zmieni się z powrotem w tej samej ramce, komponenty obserwujące ten stan nie zobaczą tej zmiany.

To ważne w przypadku interakcji, ponieważ interakcje mogą regularnie rozpoczynać się i kończyć w tej samej klatce. Na przykład w poprzednim przykładzie z parametrem Button:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Jeśli naciśnięcie zaczyna się i kończy w tej samej ramce, tekst nigdy nie wyświetli się jako „Pressed!”. W większości przypadków nie jest to problemem – wyświetlanie efektu wizualnego przez tak krótki czas spowoduje migotanie i niezauważalne dla użytkownika. W niektórych przypadkach, takich jak efekt fali lub podobnej animacji, możesz pokazywać efekt przez co najmniej minimalny czas, zamiast od razu go zatrzymywać, gdy nie będziesz naciskać przycisku. W tym celu możesz bezpośrednio uruchamiać i zatrzymywać animacje w obiekcie zbierania danych lambda, zamiast zapisywać dane o stanie. Przykład tego wzorca znajdziesz w sekcji Tworzenie zaawansowanego elementu Indication z animowanym obramowaniem.

Przykład: komponent kompilacji z niestandardową obsługą interakcji

Oto przykład zmodyfikowanego przycisku, aby zobaczyć, jak utworzyć komponenty z niestandardową odpowiedzią na dane wejściowe. W tym przypadku załóżmy, że chcesz, aby przycisk odpowiadał na naciśnięcia, zmieniając jego wygląd:

Animacja przycisku, który po kliknięciu dynamicznie dodaje ikonę koszyka z produktami spożywczymi
Rysunek 3. Przycisk, który po kliknięciu dynamicznie dodaje ikonę.

Aby to zrobić, utwórz niestandardową funkcję kompozycyjną na podstawie parametru Button i skonfiguruj ją, używając dodatkowego parametru icon (w tym przypadku koszyka na zakupy). Wywołujesz funkcję collectIsPressedAsState(), aby sprawdzać, czy użytkownik najeżdża na przycisk. Gdy to się stanie, dodajesz ikonę. Oto jak wygląda ten kod:

@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()
    }
}

Oto jak wygląda korzystanie z tego nowego elementu kompozycyjnego:

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

Nowy PressIconButton jest oparty na istniejącym materiale Button, więc reaguje na interakcje użytkowników w normalny sposób. Gdy użytkownik naciśnie przycisk, jego przezroczystość lekko się zmienia, tak jak w przypadku zwykłego materiału Button.

Tworzenie i stosowanie efektu niestandardowego wielokrotnego użytku za pomocą funkcji Indication

W poprzednich sekcjach omówiliśmy, jak zmieniać część komponentu w odpowiedzi na różne parametry Interaction, np. wyświetlanie ikony po naciśnięciu. To samo podejście można zastosować do zmiany wartości parametrów podawanych dla danego komponentu lub do zmiany treści wyświetlanej w komponencie. Dotyczy to jednak tylko poszczególnych komponentów. Często aplikacja lub system projektowania obejmuje ogólny system stanowych efektów wizualnych – efekt, który należy stosować do wszystkich komponentów w spójny sposób.

Jeśli tworzysz tego typu system projektowania, dostosowanie jednego z nich i ponowne użycie tego dostosowania do innych może być trudne z następujących powodów:

  • Każdy element systemu projektowania musi być taki sam
  • Łatwo zapomnieć o zastosowaniu tego efektu do nowo utworzonych komponentów i komponentów, które można kliknąć,
  • Łączenie efektu niestandardowego z innymi efektami może być trudne

Aby uniknąć tych problemów i łatwo przeskalować komponent niestandardowy w systemie, możesz użyć Indication. Indication to efekt wizualny wielokrotnego użytku, który można stosować między komponentami aplikacji lub systemu projektowania. Indication jest podzielony na 2 części:

  • IndicationNodeFactory: fabryka, która tworzy instancje Modifier.Node renderujące efekty wizualne komponentu. W przypadku prostszych implementacji, które nie zmieniają się w poszczególnych komponentach, może to być pojedynczy obiekt (obiekt) do stosowania w całej aplikacji.

    Te instancje mogą być stanowe lub bezstanowe. Ponieważ są one tworzone dla poszczególnych komponentów, mogą pobierać wartości z kolumny CompositionLocal, aby zmieniać ich wygląd i zachowanie w obrębie określonego komponentu, tak jak w przypadku każdego innego elementu Modifier.Node.

  • Modifier.indication: modyfikator, który pobiera wartość Indication w komponencie. Modifier.clickable i inne modyfikatory interakcji wysokiego poziomu akceptują bezpośrednio parametr wskaźnika, więc nie tylko emitują elementy Interaction, ale mogą też generować efekty wizualne związane z generowanymi przez nie komponentami Interaction. W prostych przypadkach możesz więc używać Modifier.clickable bez konieczności użycia Modifier.indication.

Zastąp efekt Indication

W tej sekcji opisujemy, jak zastąpić ręczny efekt skalowania zastosowany do konkretnego przycisku odpowiednikiem, którego można używać wielokrotnie w wielu komponentach.

Ten kod tworzy przycisk, który skaluje się w dół po naciśnięciu:

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

Aby przekonwertować efekt skali we fragmencie kodu powyżej na Indication, wykonaj te czynności:

  1. Utwórz element Modifier.Node odpowiedzialny za stosowanie efektu skali. Po dołączeniu węzeł obserwuje źródło interakcji, podobnie jak w poprzednich przykładach. Jedyną różnicą jest to, że bezpośrednio uruchamia animacje, zamiast konwertować przychodzące interakcje na stan.

    Węzeł musi zaimplementować DrawModifierNode, aby zastąpić ContentDrawScope#draw() i wyrenderować efekt skalowania za pomocą tych samych poleceń rysowania co dowolny inny interfejs graficzny w interfejsie Compose.

    Wywołanie funkcji drawContent() dostępne z odbiornika ContentDrawScope spowoduje narysowanie rzeczywistego komponentu, do którego należy zastosować właściwość Indication. Wystarczy więc wywołać tę funkcję w ramach przekształcenia skali. Zadbaj o to, aby Twoje implementacje Indication zawsze wywoływały funkcję drawContent(). W przeciwnym razie komponent, do którego stosujesz komponent Indication, nie będzie rysowany.

    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. Utwórz IndicationNodeFactory. Odpowiada tylko za utworzenie nowej instancji węzła dla podanego źródła interakcji. Nie ma parametrów do skonfigurowania wskaźnika, więc fabryka może być obiektem:

    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. Funkcja Modifier.clickable używa wewnętrznie Modifier.indication, więc aby utworzyć klikalny komponent z ScaleIndication, musisz tylko podać Indication jako parametr do clickable:

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    Ułatwia to też tworzenie wysokopoziomowych komponentów wielokrotnego użytku za pomocą niestandardowych elementów Indication. Przycisk może wyglądać tak:

    @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
        )
    }

Możesz użyć przycisku w następujący sposób:

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

Animacja przedstawiająca przycisk z ikoną koszyka z produktami spożywczymi, który po naciśnięciu zmniejsza się
Rysunek 4. Przycisk utworzony na podstawie niestandardowego elementu Indication.

Utwórz zaawansowany element Indication z animowanym obramowaniem

Indication nie ogranicza się tylko do efektów transformacji, takich jak skalowanie komponentu. Ponieważ IndicationNodeFactory zwraca element Modifier.Node, nad lub pod treścią możesz narysować dowolny efekt, tak jak w przypadku innych interfejsów API rysowania. Możesz np. narysować animowane obramowanie wokół komponentu i nakładkę na jego wierzch po kliknięciu:

Przycisk z fantazyjnym efektem tęczy po naciśnięciu
Rysunek 5. Animowany efekt obramowania narysowany w formacie Indication.

Implementacja Indication jest bardzo podobna do poprzedniego przykładu – tworzy tylko węzeł z pewnymi parametrami. Animowane obramowanie zależy od kształtu i obramowania komponentu, w którym jest używany element Indication, dlatego implementacja Indication wymaga też podania kształtu i szerokości obramowania jako parametrów:

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
        )
    }
}

Implementacja Modifier.Node również jest koncepcyjnie taka sama nawet wtedy, gdy rysunek jest bardziej skomplikowany. Tak jak wcześniej, po dołączeniu monitoruje dyrektywę InteractionSource, uruchamia animacje i implementuje DrawModifierNode, by rysować efekt na treści:

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

Główna różnica polega na tym, że istnieje teraz minimalny czas trwania animacji z funkcją animateToResting(), więc nawet jeśli naciśnięty przycisk zostanie natychmiast zwolniony, animacja prasy będzie kontynuowana. Można też obsługiwać wiele szybkich naciśnięć na początku animateToPressed – jeśli naciśnięcie ma miejsce w trakcie dotychczasowej animacji naciśnięcia lub spoczynku, poprzednia animacja zostanie anulowana, a animacja naciśnięcia rozpocznie się od początku. Aby obsługiwać wiele równoczesnych efektów (np. z falami, gdzie nowa animacja z falami będzie wyświetlana nad innymi echami), możesz śledzić animacje na liście, zamiast anulować istniejące animacje i tworzyć nowe.