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.
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ą InteractionSource i MutableInteractionSource.
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ż hoverable i focusable – 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ć 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 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:
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 instancjeModifier.Noderenderują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 innegoModifier.Node.Modifier.indication: modyfikator, który rysujeIndicationdla komponentu.Modifier.clickablei 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 siebieInteraction. W prostszych przypadkach możesz więc użyć tylkoModifier.clickablebez konieczności używaniaModifier.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:
Utwórz
Modifier.Nodeodpowiedzialny 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 odbiornikuContentDrawScopespowoduje 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 stosujeszIndication, nie zostanie narysowany.Indicationprivate 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. 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 }
Modifier.clickablewewnętrznie używaModifier.indication, więc aby utworzyć klikalny komponent zScaleIndication, wystarczy przekazaćIndicationjako 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 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!") }
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:
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.
Polecane dla Ciebie
- Uwaga: tekst linku jest wyświetlany, gdy język JavaScript jest wyłączony.
- Informacje o gestach
- Kotlin w Jetpack Compose
- Komponenty i układy Material