Obsługa interakcji użytkowników

Komponenty interfejsu użytkownika przekazują informacje zwrotne użytkownikowi urządzenia, reagując na jego interakcje. Każdy komponent reaguje na interakcje na swój sposób, co pomaga użytkownikowi zrozumieć, co się dzieje. 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ć, powinien odsunąć palec od przycisku przed jego zwolnieniem. W przeciwnym razie przycisk zostanie aktywowany.

Rysunek 1. Przyciski, które zawsze są włączone i nie mają efektu fali po naciśnięciu.
Rysunek 2. Przyciski z efektem fali po naciśnięciu, który odzwierciedla ich stan.

Dokumentacja dotycząca gestów w Compose zawiera informacje o tym, jak komponenty Compose obsługują zdarzenia wskaźnika niskiego poziomu, takie jak ruchy wskaźnika i kliknięcia. Od razu po wyjęciu z pudełka Compose przekształca zdarzenia niskiego poziomu w interakcje wyższego poziomu – np. seria zdarzeń wskaźnika może oznaczać naciśnięcie i zwolnienie przycisku. Zrozumienie tych abstrakcji wyższego poziomu może pomóc Ci dostosować sposób, w jaki interfejs użytkownika reaguje na działania użytkownika. Możesz na przykład dostosować sposób zmiany wyglądu komponentu, gdy użytkownik wchodzi z nim w interakcję, lub po prostu prowadzić dziennik tych działań. Z tego dokumentu dowiesz się, jak modyfikować standardowe elementy interfejsu lub projektować własne.

Interakcje

W wielu przypadkach nie musisz wiedzieć, jak komponent Compose interpretuje interakcje użytkownika. Na przykład Button korzysta z Modifier.clickable, aby sprawdzić, czy użytkownik kliknął przycisk. Jeśli dodajesz do aplikacji typowy przycisk, możesz zdefiniować jego onClick kod, a Modifier.clickable będzie go uruchamiać w odpowiednich sytuacjach. Oznacza to, że nie musisz wiedzieć, czy użytkownik kliknął ekran, czy wybrał przycisk za pomocą klawiatury. Modifier.clickable wykrywa, że użytkownik wykonał kliknięcie, i reaguje, uruchamiając Twój kod onClick.

Jeśli jednak chcesz dostosować reakcję komponentu interfejsu do zachowania użytkownika, może być konieczne poznanie szczegółów jego działania. 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 Interactionzdarzeń. Jeśli na przykład użytkownik dotknie przycisku, wygeneruje on PressInteraction.Press. Jeśli użytkownik podniesie palec w obrębie przycisku, wygeneruje to zdarzenie PressInteraction.Release, które poinformuje przycisk o zakończeniu kliknięcia. Jeśli użytkownik przesunie palec poza przycisk, a następnie go uniesie, 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ć wyższy priorytet niż inne.

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

Interakcje z określonym komponentem możesz sprawdzić, obserwując jego InteractionSource. InteractionSource jest zbudowany na bazie przepływów Kotlin, 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 (Interakcje w świetle).

Stan interakcji

Możesz rozszerzyć wbudowaną funkcjonalność komponentów, śledząc interakcje samodzielnie. Możesz na przykład chcieć, aby przycisk zmieniał kolor po naciśnięciu. Najprostszym sposobem śledzenia interakcji jest obserwowanie odpowiedniego stanu interakcji. InteractionSource oferuje kilka 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() funkcja Utwórz zapewnia też collectIsFocusedAsState(), collectIsDraggedAsState() i collectIsHoveredAsState(). Te metody są w rzeczywistości metodami pomocniczymi opartymi na interfejsach API InteractionSource niższego poziomu. W niektórych przypadkach możesz chcieć używać bezpośrednio tych funkcji niższego poziomu.

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

W sekcji poniżej opisano, jak wykorzystywać interakcje i emitować je odpowiednio za pomocą InteractionSourceMutableInteractionSource.

Zużycie i emisja Interaction

InteractionSource to strumień tylko do odczytu Interactions – nie można wysyłać Interaction do InteractionSource. Aby emitować Interaction, musisz użyć MutableInteractionSource, który rozciąga się od InteractionSource.

Modyfikatory i komponenty mogą wykorzystywać, emitować lub wykorzystywać i emitować Interactions. W sekcjach poniżej opisaliśmy, jak korzystać z interakcji i je emitować zarówno w przypadku modyfikatorów, jak i komponentów.

Przykład użycia modyfikatora

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

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

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

Przykład modyfikatora produktu

W przypadku modyfikatora, który obsługuje zdarzenia wywoływane najechaniem kursorem, np. 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 znajduje się nad nim lub nie.

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

Komponenty wyższego poziomu, takie jak ButtonMateriał, pełnią rolę zarówno producentów, jak i konsumentów. Obsługują zdarzenia związane z wprowadzaniem danych i skupieniem, a także zmieniają swój wygląd w odpowiedzi na te zdarzenia, np. wyświetlają efekt fali lub animują podniesienie. 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() */ }

Umożliwia to podniesienie MutableInteractionSource z komponentu i obserwowanie wszystkich Interaction wygenerowanych przez komponent. Możesz użyć tego do kontrolowania wyglądu tego komponentu lub dowolnego innego komponentu w interfejsie.

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

Compose wykorzystuje architekturę warstwową, więc komponenty Material wyższego poziomu są zbudowane na podstawie podstawowych elementów, które generują Interaction potrzebne do kontrolowania efektów falowania i innych efektów wizualnych. Biblioteka podstawowa udostępnia modyfikatory interakcji wysokiego poziomu, takie jak Modifier.hoverable, Modifier.focusable i Modifier.draggable.

Aby utworzyć komponent reagujący na zdarzenia najechania kursorem, wystarczy użyć Modifier.hoverable i przekazać MutableInteractionSource jako parametr. Gdy najedziesz kursorem na komponent, wyemituje on sygnał HoverInteraction. Możesz go użyć, aby 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 ten komponent był też elementem, na którym można ustawić fokus, 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ż hoverablefocusable – aby komponent był klikalny, musi być też najechany, a komponenty, które można kliknąć, powinny być też możliwe do zaznaczenia. Możesz użyć Modifier.clickable, aby utworzyć komponent, który obsługuje interakcje wywoływane najeżdżaniem kursora, zaznaczaniem i klikaniem bez konieczności łączenia interfejsów API niższego poziomu. Jeśli chcesz, aby komponent był klikalny, możesz zastąpić hoverablefocusable 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 szczegółowych informacji 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 prowadzić listę interakcji naciśnięcia i przeciągnięcia w przypadku elementu InteractionSource. Ten kod wykonuje połowę pracy, dodając nowe prasy 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, które się kończą (np. gdy użytkownik odrywa palec od 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 sprawdzić, czy interactions jest pusty:

val isPressedOrDragged = interactions.isNotEmpty()

Jeśli chcesz się dowiedzieć, jakie było ostatnie działanie, po prostu sprawdź ostatni element na liście. Na przykład w ten sposób implementacja efektu fali w Compose określa 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"
}

Ponieważ wszystkie Interaction mają taką samą strukturę, nie ma dużej różnicy w kodzie podczas pracy z różnymi typami interakcji użytkowników – ogólny wzorzec jest taki sam.

Pamiętaj, że poprzednie przykłady w tej sekcji przedstawiają Flow interakcji z użyciem State. Ułatwia to obserwowanie zaktualizowanych wartości, ponieważ odczytanie wartości stanu automatycznie spowoduje ponowne skomponowanie. Kompozycja jest jednak grupowana przed klatką. Oznacza to, że jeśli stan zmieni się, a potem wró do poprzedniej wartości w tej samej klatce, komponenty obserwujące stan nie zobaczą zmiany.

Jest to ważne w przypadku interakcji, ponieważ mogą się one regularnie rozpoczynać i kończyć w tej samej klatce. Na przykład w poprzednim przykładzie z użyciem 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 nigdy nie wyświetli się jako „Naciśnięto!”. W większości przypadków nie stanowi to problemu – 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 natychmiast go zatrzymywać, gdy przycisk nie jest już naciskany. Aby to zrobić, możesz bezpośrednio uruchamiać i zatrzymywać animacje w funkcji lambda collect, zamiast zapisywać je w stanie. Przykład tego wzorca znajdziesz w sekcji Tworzenie zaawansowanego Indication z animowaną ramką.

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

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

Animacja przycisku, 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 komponent oparty na Button i dodaj do niego parametr icon, który będzie służyć do rysowania ikony (w tym przypadku koszyka). Wywołujesz funkcję collectIsPressedAsState(), aby śledzić, czy użytkownik najeżdża kursorem na przycisk. Jeśli tak, dodajesz ikonę. Oto jak wygląda 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()
    }
}

A tak wygląda użycie tego nowego komponentu:

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

Ponieważ ten nowy PressIconButton jest oparty na istniejącym MaterialuButton, reaguje na interakcje użytkowników w zwykły sposób. Gdy użytkownik naciśnie przycisk, jego krycie 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 pokazaliśmy, jak zmieniać część komponentu w odpowiedzi na różne Interaction, np. wyświetlać ikonę po naciśnięciu. Takie 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 z zachowaniem stanu – efekt, który powinien być stosowany do wszystkich komponentów w spójny sposób.

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

  • Każdy komponent w systemie projektowania musi mieć ten sam kod standardowy.
  • Ł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 to efekt wizualny, którego można używać wielokrotnie w różnych komponentach aplikacji lub systemu projektowania. Indication jest podzielony na 2 części:

  • IndicationNodeFactory: fabryka, która tworzy instancje Modifier.Node renderujące efekty wizualne dla komponentu. W przypadku prostszych implementacji, które nie zmieniają się w różnych komponentach, może to być singleton (obiekt) używany ponownie w całej aplikacji.

    Te instancje mogą mieć stan lub nie. Ponieważ są one tworzone dla każdego komponentu, mogą pobierać wartości z CompositionLocal, aby zmieniać sposób wyświetlania lub działania w określonym komponencie, tak jak w przypadku każdego innego Modifier.Node.

  • Modifier.indication: modyfikator, który rysuje Indication dla komponentu. Modifier.clickable i inne modyfikatory interakcji wyższego poziomu przyjmują parametr wskazania bezpośrednio, więc nie tylko emitują Interaction, ale mogą też rysować efekty wizualne dla emitowanych przez siebie Interaction. W prostszych przypadkach możesz więc użyć tylko Modifier.clickable bez konieczności używania Modifier.indication.

Zastąp efekt elementem Indication

W tej sekcji opisujemy, jak zastąpić ręczny efekt skalowania zastosowany do jednego konkretnego przycisku równoważnym wskaźnikiem, którego można używać w wielu komponentach.

Poniższy kod tworzy przycisk, który po naciśnięciu zmniejsza się:

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 w powyższym fragmencie kodu na Indication, wykonaj te czynności:

  1. Utwórz Modifier.Node odpowiedzialny za zastosowanie 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 przekształcać przychodzące interakcje w stan.

    Węzeł musi implementować interfejs DrawModifierNode, aby mógł zastąpić 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 w odbiorniku ContentDrawScope spowoduje narysowanie rzeczywistego komponentu, do którego należy zastosować Indication, więc wystarczy wywołać tę funkcję w ramach transformacji skalowania. Upewnij się, że Twoje wdrożenia zawsze w pewnym momencie wywołują drawContent(); w przeciwnym razie komponent, do którego stosujesz Indication, nie zostanie narysowany.Indication

    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 wewnętrznie używa Modifier.indication, więc aby utworzyć klikalny komponent z ScaleIndication, wystarczy przekazać 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 komponentów wysokiego poziomu do 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 użyć przycisku 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 Indication.

Tworzenie zaawansowanego Indication z animowaną ramką

Indication nie ogranicza się tylko do efektów przekształcenia, takich jak skalowanie komponentu. Ponieważ IndicationNodeFactory zwraca Modifier.Node, możesz rysować dowolne efekty 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 jest on 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 tu bardzo podobna do poprzedniego przykładu – tworzy tylko węzeł z pewnymi parametrami. Animowana ramka zależy od kształtu i ramki komponentu, dla którego jest używana funkcja Indication, dlatego implementacja Indication wymaga też podania kształtu i szerokości ramki 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, obserwuje InteractionSource moment dołączenia, uruchamia animacje i wdraża 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 animacja z funkcją animateToResting() ma teraz minimalny czas trwania, więc nawet jeśli przycisk zostanie natychmiast zwolniony, animacja naciśnięcia będzie kontynuowana. W przypadku wielu szybkich naciśnięć na początku animateToPressed – jeśli naciśnięcie nastąpi podczas trwającego naciśnięcia lub animacji spoczynkowej, poprzednia animacja zostanie anulowana, a animacja naciśnięcia rozpocznie się od początku. Aby obsługiwać wiele efektów jednocześnie (np. w przypadku fal, gdy nowa animacja fali jest rysowana na innych falach), możesz śledzić animacje na liście, zamiast anulować istniejące animacje i rozpoczynać nowe.