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.
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:
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 instancjeModifier.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 elementuModifier.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ą elementyInteraction
, ale mogą też generować efekty wizualne związane z generowanymi przez nie komponentamiInteraction
. W prostych przypadkach możesz więc używaćModifier.clickable
bez konieczności użyciaModifier.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:
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 odbiornikaContentDrawScope
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 implementacjeIndication
zawsze wywoływały funkcjędrawContent()
. W przeciwnym razie komponent, do którego stosujesz komponentIndication
, 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() } } }
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 }
Funkcja
Modifier.clickable
używa wewnętrznieModifier.indication
, więc aby utworzyć klikalny komponent zScaleIndication
, musisz tylko podaćIndication
jako parametr doclickable
: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!") }
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:
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.
Polecane dla Ciebie
- Uwaga: tekst linku jest wyświetlany, gdy JavaScript jest wyłączony
- Gesty
- Kotlin dla Jetpack Compose
- Komponenty i układy Material Design