Obsługa interakcji użytkowników

Komponenty interfejsu przekazują informacje zwrotne użytkownikowi urządzenia przez sposób, w jaki reagują na jego interakcje. Każdy komponent ma własny sposób reagowania na interakcje, dzięki czemu użytkownik wie, co robią jego interakcje. Jeśli na przykład użytkownik dotknie przycisku na ekranie dotykowym urządzenia, przycisk prawdopodobnie zmieni się w jakiś sposób, np. zostanie podświetlony. Ta zmiana informuje użytkownika, że dotknął przycisku. Jeśli użytkownik nie chce tego zrobić, będzie wiedział, że musi odsunąć palec od przycisku, zanim go puści – w przeciwnym razie przycisk zostanie aktywowany.

Rysunek 1. Przyciski, które zawsze wyglądają na włączone, bez efektu fali po naciśnięciu.
Rysunek 2. Przyciski z efektem fali po naciśnięciu, który odzwierciedla ich stan włączenia.

Dokumentacja Gestures w Compose opisuje, jak komponenty Compose obsługują zdarzenia wskaźnika niskiego poziomu, takie jak ruchy wskaźnika i kliknięcia. Compose od razu przekształca te zdarzenia niskiego poziomu w interakcje wyższego poziomu – na przykład seria zdarzeń wskaźnika może składać się na naciśnięcie i zwolnienie przycisku. Zrozumienie tych abstrakcji wyższego poziomu może pomóc Ci dostosować sposób, w jaki interfejs reaguje na działania użytkownika. Możesz na przykład dostosować sposób, w jaki zmienia się wygląd komponentu, gdy użytkownik wchodzi z nim w interakcję, lub po prostu prowadzić dziennik działań użytkownika. W tym dokumencie znajdziesz informacje potrzebne do modyfikowania standardowych elementów interfejsu lub projektowania własnych.

Interakcje

W wielu przypadkach nie musisz wiedzieć, jak komponent Compose interpretuje interakcje użytkownika. Na przykład Button korzysta z Modifier.clickable, aby ustalić, czy użytkownik kliknął przycisk. Jeśli dodajesz do aplikacji typowy przycisk, możesz zdefiniować jego kod onClick, a Modifier.clickable uruchomi go w odpowiednim momencie. Oznacza to, że nie musisz wiedzieć, czy użytkownik kliknął ekran, czy wybrał przycisk za pomocą klawiatury. Modifier.clickable wykrywa, że użytkownik kliknął, i reaguje, uruchamiając kod onClick.

Jeśli jednak chcesz dostosować reakcję komponentu interfejsu na zachowanie użytkownika, musisz dowiedzieć się więcej o tym, co dzieje się pod maską. W tej sekcji znajdziesz niektóre z tych informacji.

Gdy użytkownik wchodzi w interakcję z komponentem interfejsu, system reprezentuje jego zachowanie, generując szereg zdarzeń Interaction. Jeśli na przykład użytkownik dotknie przycisku, przycisk wygeneruje zdarzenie PressInteraction.Press. Jeśli użytkownik podniesie palec, gdy znajduje się on na przycisku, wygeneruje to zdarzenie PressInteraction.Release, które informuje przycisk, że kliknięcie zostało zakończone. Jeśli natomiast użytkownik przesunie palec poza przycisk, a następnie go podniesie, przycisk wygeneruje zdarzenie PressInteraction.Cancel, aby wskazać, że naciśnięcie przycisku zostało anulowane, a nie zakończone.

Te interakcje są neutralne. Oznacza to, że te zdarzenia interakcji niskiego poziomu nie mają na celu interpretowania znaczenia działań użytkownika ani ich sekwencji. Nie interpretują też, które działania użytkowników mogą mieć pierwszeństwo przed innymi.

Te interakcje zwykle występują parami, z początkiem i końcem. Druga interakcja zawiera odniesienie do pierwszej. Jeśli na przykład użytkownik dotknie przycisku, a następnie podniesie palec, dotknięcie wygeneruje interakcję PressInteraction.Press, a podniesienie palca – PressInteraction.Release. Release ma właściwość press, która identyfikuje początkowe PressInteraction.Press.

Interakcje dotyczące konkretnego komponentu możesz zobaczyć, obserwując jego InteractionSource. InteractionSource jest oparty na przepływach Kotlina, więc możesz zbierać z niego interakcje w taki sam sposób, jak w przypadku każdego innego przepływu. Więcej informacji o tej decyzji projektowej znajdziesz w poście na blogu Illuminating Interactions.

Stan interakcji

Możesz rozszerzyć wbudowane funkcje komponentów, samodzielnie śledząc interakcje. Możesz na przykład chcieć, aby przycisk zmieniał kolor po naciśnięciu. Najprostszym sposobem śledzenia interakcji jest obserwowanie odpowiedniego stanu interakcji. InteractionSource udostępnia szereg metod, które ujawniają różne stany interakcji. Jeśli na przykład chcesz sprawdzić, czy dany przycisk jest 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() Compose udostępnia też collectIsFocusedAsState(), collectIsDraggedAsState() i collectIsHoveredAsState(). Te metody są w rzeczywistości metodami ułatwiającymi, które są oparte na interfejsach API InteractionSource niższego poziomu. W niektórych przypadkach możesz chcieć używać tych funkcji niższego poziomu bezpośrednio.

Załóżmy na przykład, że chcesz wiedzieć, czy przycisk jest naciskany, a także czy jest przeciągany. Jeśli używasz zarówno collectIsPressedAsState(), jak i collectIsDraggedAsState(), Compose wykonuje wiele powielonych działań i nie ma gwarancji, że wszystkie interakcje zostaną zarejestrowane we właściwej kolejności. W takich sytuacjach możesz skontaktować się bezpośrednio z InteractionSource. Więcej informacji o samodzielnym śledzeniu interakcji za pomocą InteractionSource znajdziesz w artykule Praca z InteractionSource.

W sekcji poniżej opisano, jak wykorzystywać i emitować interakcje odpowiednio z InteractionSource i MutableInteractionSource.

Wykorzystaj i wyemituj Interaction

InteractionSource reprezentuje strumień tylko do odczytu Interactions – nie można wyemitować Interaction do InteractionSource. Aby emitować obiekty Interaction, musisz użyć klasy MutableInteractionSource, która rozszerza klasę InteractionSource.

Modyfikatory i komponenty mogą wykorzystywać, emitować lub wykorzystywać i emitować Interactions. W sekcjach poniżej opisano, jak wykorzystywać i emitować interakcje zarówno z modyfikatorów, jak i komponentów.

Przykład modyfikatora konsumpcji

W przypadku modyfikatora, który rysuje obramowanie stanu aktywnego, wystarczy obserwować Interactions, więc możesz zaakceptować InteractionSource:

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

Z sygnatury funkcji jasno wynika, że ten modyfikator jest konsumentem – może konsumować obiekty Interaction, ale nie może ich emitować.

Przykład modyfikatora producenta

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

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

Ten modyfikator jest producentem – może używać podanego MutableInteractionSource do emitowania HoverInteractions, gdy kursor jest nad nim lub nie.

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

Komponenty wysokiego poziomu, takie jak Material Button, działają zarówno jako producenci, jak i konsumenci. Obsługują zdarzenia wejściowe i zdarzenia zaznaczenia, a także zmieniają swój wygląd w odpowiedzi na te zdarzenia, np. wyświetlają efekt fali lub animują wysokość. W rezultacie bezpośrednio udostępniają MutableInteractionSource jako parametr, 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żna wyciągnąć MutableInteractionSource z komponentu i obserwować wszystkie Interaction generowane przez komponent. Możesz go użyć do kontrolowania wyglądu tego komponentu lub dowolnego innego komponentu w interfejsie.

Jeśli tworzysz własne interaktywne komponenty wysokiego poziomu, zalecamy, aby w ten sposób udostępnić MutableInteractionSource jako parametr. Oprócz stosowania sprawdzonych metod przenoszenia stanu ułatwia to też odczytywanie i kontrolowanie stanu wizualnego komponentu w taki sam sposób, jak w przypadku każdego innego stanu (np. stanu włączenia).

Compose wykorzystuje warstwowe podejście architektoniczne, więc komponenty Material wysokiego poziomu są tworzone na podstawie podstawowych elementów, które generują obiekty Interaction potrzebne do kontrolowania efektów fali i innych efektów wizualnych. Biblioteka Foundation udostępnia modyfikatory interakcji wysokiego poziomu, takie jak Modifier.hoverable, Modifier.focusable i Modifier.draggable.

Aby utworzyć komponent, który reaguje na zdarzenia najechania kursorem, możesz po prostu użyć Modifier.hoverable i przekazać MutableInteractionSource jako parametr. Gdy komponent jest najechany, emituje HoverInteractions, co pozwala zmienić sposób jego wyświetlania.

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

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

Aby ten komponent również mógł być zaznaczany, możesz dodać Modifier.focusable i przekazać ten sam MutableInteractionSource jako parametr. Teraz zarówno HoverInteraction.Enter/Exit, jak i FocusInteraction.Focus/Unfocus są emitowane przez ten sam MutableInteractionSource, a wygląd obu typów interakcji możesz dostosować 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 jeszcze wyższy poziom abstrakcji niż hoverable i focusable – aby komponent był klikalny, musi być domyślnie najeżdżalny, a komponenty, które można kliknąć, powinny być również zaznaczalne. Możesz użyć Modifier.clickable, aby utworzyć komponent, który obsługuje interakcje najechania, zaznaczenia i naciśnięcia, bez konieczności łączenia interfejsów API niższego poziomu. Jeśli chcesz, aby komponent był również klikalny, możesz zastąpić 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 przepływu dla InteractionSource tego komponentu. Załóżmy na przykład, że chcesz zachować listę interakcji polegających na naciśnięciu i przeciągnięciu dla elementu InteractionSource. Ten kod wykonuje połowę pracy, dodając nowe naciśnięcia do listy w miarę ich pojawiania się:

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

Oprócz dodawania nowych interakcji musisz też usuwać interakcje, gdy się zakończą (np. gdy użytkownik podniesie palec z komponentu). Jest to łatwe, ponieważ interakcje końcowe zawsze zawierają odniesienie do powiązanej interakcji początkowej. Ten kod pokazuje, jak usunąć interakcje, które się zakończyły:

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 sprawdzić, czy komponent jest obecnie naciskany lub przeciągany, wystarczy, że sprawdzisz, czy interactions jest pusty:

val isPressedOrDragged = interactions.isNotEmpty()

Jeśli chcesz dowiedzieć się, jaka była ostatnia interakcja, po prostu spójrz na ostatni element na liście. Oto przykład, jak implementacja efektu fali w Compose określa odpowiednią nakładkę stanu dla ostatniej interakcji:

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

Ponieważ wszystkie Interaction mają tę samą strukturę, nie ma dużej różnicy w kodzie, gdy pracujesz z różnymi typami interakcji użytkownika – ogólny wzorzec jest taki sam.

Pamiętaj, że poprzednie przykłady w tej sekcji przedstawiają Flow interakcji za pomocą State – ułatwia to obserwowanie zaktualizowanych wartości, ponieważ odczytanie wartości stanu automatycznie spowoduje rekompozycje. Kompozycja jest jednak grupowana przed klatką. Oznacza to, że jeśli stan się zmieni, a następnie powróci do poprzedniego w tej samej klatce, komponenty obserwujące stan nie zauważą zmiany.

Jest to ważne w przypadku interakcji, ponieważ mogą one regularnie rozpoczynać się i kończyć w tej samej klatce. Na przykład, używając poprzedniego przykładu z 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 rozpocznie się i zakończy w tej samej klatce, tekst „Naciśnięto!” nigdy się nie wyświetli. W większości przypadków nie jest to problem – wyświetlanie efektu wizualnego przez tak krótki czas spowoduje migotanie i nie będzie zbyt zauważalne dla użytkownika. W niektórych przypadkach, np. gdy chcesz wyświetlić efekt fali lub podobną animację, możesz chcieć, aby efekt był widoczny przez co najmniej minimalny czas, zamiast natychmiastowego zatrzymania, gdy przycisk nie jest już naciśnięty. Aby to zrobić, możesz bezpośrednio uruchamiać i zatrzymywać animacje z poziomu funkcji lambda collect, zamiast zapisywać je w stanie. Przykład tego wzorca znajdziesz w sekcji Tworzenie zaawansowanego komponentu Indication z animowanym obramowaniem.

Przykład: tworzenie komponentu z niestandardową obsługą interakcji

Aby zobaczyć, jak można tworzyć komponenty z niestandardową reakcją na dane wejściowe, zapoznaj się z przykładem zmodyfikowanego przycisku. Załóżmy, że chcesz utworzyć przycisk, który reaguje na naciśnięcia, zmieniając swój wygląd:

Animacja przedstawiająca przycisk, który po kliknięciu dynamicznie dodaje ikonę koszyka na zakupy
Rysunek 3. Przycisk, który po kliknięciu dynamicznie dodaje ikonę.

Aby to zrobić, utwórz niestandardowy element kompozycyjny na podstawie Button i dodaj do niego parametr icon, który będzie rysować ikonę (w tym przypadku koszyk na zakupy). Wywołujesz funkcję collectIsPressedAsState(), aby sprawdzić, czy użytkownik najeżdża kursorem na przycisk. Jeśli tak, dodajesz ikonę. Kod wygląda tak:

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

A tak wygląda użycie tego nowego elementu kompozycyjnego:

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

Ponieważ nowy element PressIconButton jest oparty na istniejącym elemencie Material Button, reaguje na interakcje użytkownika w standardowy sposób. Gdy użytkownik naciśnie przycisk, jego przezroczystość nieznacznie się zmieni, tak jak w przypadku zwykłego elementu Material Button.

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

W poprzednich sekcjach dowiedziałeś się, jak zmienić część komponentu w odpowiedzi na różne Interaction, np. wyświetlić ikonę po naciśnięciu. To samo podejście można zastosować do zmiany wartości parametrów przekazywanych do komponentu lub zmiany treści wyświetlanych w komponencie, ale dotyczy to tylko poszczególnych komponentów. Często aplikacja lub system projektowania ma ogólny system efektów wizualnych związanych ze stanem, który powinien być stosowany do wszystkich komponentów w spójny sposób.

Jeśli tworzysz taki system projektowania, dostosowanie jednego komponentu i ponowne użycie tego dostosowania w przypadku innych komponentów może być trudne z tych powodów:

  • Każdy komponent w systemie projektowania wymaga tego samego szablonu.
  • Łatwo zapomnieć o zastosowaniu tego efektu do nowo utworzonych komponentów i niestandardowych komponentów klikalnych.
  • Połączenie efektu niestandardowego z innymi efektami może być trudne.

Aby uniknąć tych problemów i łatwo skalować komponent niestandardowy w całym systemie, możesz użyć Indication. Indication reprezentuje efekt wizualny wielokrotnego użytku, który można zastosować do komponentów w aplikacji lub systemie 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 różnych komponentach, może to być singleton (obiekt) i można go używać w całej aplikacji.

    Instancje te mogą być stanowe lub bezstanowe. Ponieważ są tworzone dla poszczególnych komponentów, mogą pobierać wartości z CompositionLocal, aby zmieniać sposób, w jaki wyglądają lub zachowują się w danym komponencie, tak jak w przypadku innych Modifier.Node.

  • Modifier.indication: modyfikator, który rysuje Indication dla komponentu. Modifier.clickable i inne modyfikatory interakcji wysokiego poziomu akceptują parametr wskazania bezpośrednio, więc nie tylko emitują Interaction, ale mogą też rysować efekty wizualne dla emitowanych Interaction. W prostych przypadkach możesz więc używać Modifier.clickable bez Modifier.indication.

Zastąp efekt elementem Indication

W tej sekcji opisujemy, jak zastąpić efekt ręcznego skalowania zastosowany do jednego konkretnego przycisku odpowiednikiem wskazania, który można ponownie wykorzystać w wielu komponentach.

Poniższy kod tworzy przycisk, który zmniejsza się 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 skalowania we fragmencie kodu powyżej na Indication, wykonaj te czynności:

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

    Węzeł musi implementować interfejs DrawModifierNode, aby mógł zastąpić funkcję ContentDrawScope#draw() i renderować efekt skalowania za pomocą tych samych poleceń rysowania co w przypadku innych interfejsów API grafiki w Compose.

    Wywołanie funkcji drawContent() dostępnej z odbiornika ContentDrawScope spowoduje narysowanie rzeczywistego komponentu, do którego należy zastosować Indication, więc wystarczy wywołać tę funkcję w ramach przekształcenia skali. Upewnij się, że implementacje Indication zawsze wywołują drawContent() w pewnym momencie. W przeciwnym razie komponent, do którego stosujesz Indication, nie zostanie narysowany.

    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. Jego jedynym zadaniem jest utworzenie nowej instancji węzła dla podanego źródła interakcji. Ponieważ nie ma parametrów do skonfigurowania wskazania, 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. Modifier.clickable używa wewnętrznie Modifier.indication, więc aby utworzyć komponent klikalny za pomocą ScaleIndication, wystarczy podać Indication jako parametr dla 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ą niestandardowego elementu 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 go używać w ten 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 na zakupy, który zmniejsza się po naciśnięciu
Rysunek 4. Przycisk utworzony za pomocą niestandardowego elementu Indication.

Tworzenie zaawansowanego komponentu Indication z animowanym obramowaniem

Indication nie ogranicza się tylko do efektów przekształcania, takich jak skalowanie komponentu. Ponieważ IndicationNodeFactory zwraca Modifier.Node, możesz narysować dowolny efekt nad lub pod treścią, tak jak w przypadku innych interfejsów API do rysowania. Możesz na przykład narysować animowaną ramkę wokół komponentu i nakładkę na nim, gdy zostanie naciśnięty:

Przycisk z efektownym tęczowym efektem po naciśnięciu
Rysunek 5. Animowany efekt obramowania narysowany za pomocą Indication.

Implementacja Indication jest tutaj bardzo podobna do poprzedniego przykładu – po prostu tworzy węzeł z pewnymi parametrami. Ponieważ animowane obramowanie zależy od kształtu i obramowania komponentu, dla którego używany jest element Indication, implementacja elementu Indication wymaga również 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 jest również koncepcyjnie taka sama, nawet jeśli kod rysowania jest bardziej skomplikowany. Podobnie jak wcześniej, po dołączeniu obserwuje InteractionSource, uruchamia animacje i implementuje DrawModifierNode, aby narysować 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 teraz animacja ma minimalny czas trwania dzięki funkcji animateToResting(), więc nawet jeśli naciśnięcie zostanie natychmiast zwolnione, animacja naciśnięcia będzie kontynuowana. Obsługiwane jest też wielokrotne szybkie naciśnięcie na początku animateToPressed – jeśli naciśnięcie nastąpi podczas istniejącego naciśnięcia lub animacji spoczynku, poprzednia animacja zostanie anulowana, a animacja naciśnięcia rozpocznie się od początku. Aby obsługiwać wiele jednoczesnych efektów (np. w przypadku fal, gdzie nowa animacja fali będzie nakładać się na inne fale), możesz śledzić animacje na liście, zamiast anulować istniejące animacje i rozpoczynać nowe.