I componenti dell'interfaccia utente forniscono un feedback all'utente del dispositivo in merito al modo in cui rispondono alle interazioni dell'utente. Ogni componente ha un modo specifico di rispondere alle interazioni, che aiuta l'utente a sapere cosa stanno facendo le loro interazioni. Ad esempio, se un utente tocca un pulsante sul touchscreen di un dispositivo, è probabile che il pulsante cambi in qualche modo, magari aggiungendo un colore di evidenziazione. Questa modifica comunica all'utente di aver toccato il pulsante. Se l'utente non vuole farlo, saprà di trascinare il dito lontano dal pulsante prima di rilasciarlo, altrimenti il pulsante si attiverà.
![](https://developer.android.com/static/develop/ui/compose/images/touchinput/button-always-enabled.gif?authuser=8&hl=it)
![](https://developer.android.com/static/develop/ui/compose/images/touchinput/button-reflects-state.gif?authuser=8&hl=it)
La documentazione relativa ai Gesti di Scrivi spiega come i componenti di Scrivi gestiscono gli eventi di puntatore di basso livello, ad esempio gli spostamenti del puntatore e i clic. All'istante, Compose estrae questi eventi di basso livello in interazioni di livello superiore. Ad esempio, una serie di eventi puntatore potrebbe essere sommata alla pressione di un pulsante. Capire queste astrazioni di livello superiore può aiutarti a personalizzare il modo in cui la tua UI risponde all'utente. Ad esempio, potresti voler personalizzare il modo in cui cambia l'aspetto di un componente quando l'utente vi interagisce oppure potresti voler semplicemente conservare un log di queste azioni utente. Questo documento fornisce le informazioni necessarie per modificare gli elementi standard dell'interfaccia utente o progettarne di nuovi.
Interazioni
In molti casi, non è necessario sapere esattamente come il componente Scrivi interpreta
le interazioni degli utenti. Ad esempio, Button
si basa su
Modifier.clickable
per determinare se l'utente ha fatto clic sul pulsante. Se aggiungi un pulsante tipico alla tua app, puoi definire il codice onClick
del pulsante e Modifier.clickable
lo eseguirà quando opportuno. Ciò significa che non devi sapere se l'utente ha toccato lo schermo o selezionato il pulsante con una tastiera; Modifier.clickable
capisce che l'utente ha eseguito un clic e risponde eseguendo il codice onClick
.
Tuttavia, se vuoi personalizzare la risposta del componente dell'interfaccia utente al comportamento degli utenti, potrebbe essere necessario saperne di più su cosa sta succedendo in fondo. Questa sezione ti fornisce alcune di queste informazioni.
Quando un utente interagisce con un componente dell'interfaccia utente, il sistema rappresenta il suo comportamento generando una serie di eventi Interaction
. Ad esempio, se un utente tocca un pulsante, questo genera
PressInteraction.Press
.
Se l'utente solleva il dito all'interno del pulsante, viene generato un evento PressInteraction.Release
, che informa il pulsante che il clic è stato completato. Se invece l'utente trascina il dito fuori dal pulsante e poi lo solleva, il pulsante genera PressInteraction.Cancel
, a indicare che la pressione del pulsante è stata annullata, non completata.
Queste interazioni sono non mappate. In altre parole, questi eventi di interazione di basso livello non intendono interpretare il significato o la sequenza delle azioni dell'utente. Inoltre, non interpretano le azioni dell'utente che potrebbero avere la priorità su altre.
Queste interazioni generalmente sono in coppia, con un inizio e una fine. La seconda interazione contiene un riferimento alla prima. Ad esempio, se un utente
tocca un pulsante e poi solleva il dito, il tocco genera un'interazione
PressInteraction.Press
e la release genera un
PressInteraction.Release
;
il Release
ha una proprietà press
che identifica la
PressInteraction.Press
iniziale.
Puoi vedere le interazioni di un determinato componente osservandone
InteractionSource
. InteractionSource
è basato su Flussi di Kotlin, in modo da poter raccogliere le interazioni da questi flussi come faresti con qualsiasi altro flusso. Per ulteriori informazioni su questa decisione di progettazione,
consulta il post del blog Interazioni illuminazione.
Stato dell'interazione
È possibile che tu voglia estendere la funzionalità integrata dei tuoi componenti monitorando anche le interazioni personalmente. Ad esempio, potresti voler cambiare colore
di un pulsante quando viene premuto. Il modo più semplice per monitorare le interazioni è osservare lo stato di interazione appropriato. InteractionSource
offre una serie di metodi che consentono di visualizzare vari stati delle interazioni come stato. Ad esempio, se vuoi vedere se un determinato pulsante è stato premuto, puoi chiamare il suo metodo InteractionSource.collectIsPressedAsState()
:
val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() Button( onClick = { /* do something */ }, interactionSource = interactionSource ) { Text(if (isPressed) "Pressed!" else "Not pressed") }
Oltre a collectIsPressedAsState()
, Compose contiene anche
collectIsFocusedAsState()
, collectIsDraggedAsState()
e
collectIsHoveredAsState()
. Si tratta in realtà di metodi pratici
basati su API InteractionSource
di livello inferiore. In alcuni casi, potresti voler usare direttamente le funzioni di livello inferiore.
Ad esempio, supponi di dover sapere se un pulsante viene premuto e
anche se viene trascinato. Se utilizzi sia collectIsPressedAsState()
sia collectIsDraggedAsState()
, Compose eseguirà molte operazioni duplicate e
non vi è alcuna garanzia che tutte le interazioni saranno disponibili nell'ordine corretto. In
situazioni come questa, è consigliabile lavorare direttamente con
InteractionSource
, Per ulteriori informazioni sul monitoraggio autonomo delle interazioni con InteractionSource
, consulta Utilizzare InteractionSource
.
La sezione seguente descrive come utilizzare ed emettere interazioni con
InteractionSource
e MutableInteractionSource
, rispettivamente.
Consuma ed emetti Interaction
InteractionSource
rappresenta un flusso di sola lettura di Interactions
. Non è
possibile emettere Interaction
in un elemento InteractionSource
. Per emettere Interaction
, devi utilizzare MutableInteractionSource
, che si estende da InteractionSource
.
I modificatori e i componenti possono consumare, emettere o consumare ed emettere Interactions
.
Le seguenti sezioni descrivono come utilizzare ed emettere interazioni dai modificatori e dai componenti.
Esempio di applicazione di un modificatore
Per un modificatore che traccia un bordo per lo stato attivo, devi solo osservare Interactions
, quindi puoi accettare un InteractionSource
:
fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier { // ... }
Dalla firma della funzione è chiaro che questo modificatore è un consumatore: può consumare Interaction
, ma non può emetterli.
Esempio di produzione di modificatore
Per un modificatore che gestisce gli eventi di passaggio del mouse come Modifier.hoverable
, devi emettere Interactions
e accettare MutableInteractionSource
come parametro:
fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier { // ... }
Questo modificatore è un producer: può utilizzare l'elemento
MutableInteractionSource
fornito per emettere HoverInteractions
quando viene passato il mouse sopra
o non viene passato il puntatore del mouse.
Crea componenti che consumano e producono
I componenti di alto livello, come un elemento Material Button
, agiscono sia da produttori che
consumatori. Gestiscono eventi di input e di messa a fuoco e cambiano anche il loro aspetto
in risposta a questi eventi, ad esempio mostrando un'onda o animando la loro
elevazione. Di conseguenza, espongono direttamente MutableInteractionSource
come parametro, in modo da poter fornire la tua istanza memorizzata:
@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() */ }
In questo modo è possibile sollevare
MutableInteractionSource
dal componente e osservare tutti i
Interaction
prodotti dal componente. Puoi usare questa opzione per controllare l'aspetto
di quel componente o di qualsiasi altro componente nell'interfaccia utente.
Se stai creando componenti interattivi di alto livello, ti consigliamo di esporre MutableInteractionSource
come parametro in questo modo. Oltre a seguire le best practice per la gestione dello stato, è anche più semplice leggere e controllare lo stato visivo di un componente, esattamente come è possibile leggere e controllare qualsiasi altro tipo di stato (come lo stato Attivato).
Compose segue un approccio all'architettura a più livelli,
per cui i componenti Material di alto livello sono costruiti su componenti
di base che producono gli Interaction
necessari per controllare increspature e altri effetti visivi. La libreria di base offre modificatori di interazione di alto livello come Modifier.hoverable
, Modifier.focusable
e Modifier.draggable
.
Per creare un componente che risponda agli eventi di passaggio del mouse, puoi semplicemente utilizzare Modifier.hoverable
e passare MutableInteractionSource
come parametro.
Ogni volta che viene passato il mouse sopra il componente, questo emette HoverInteraction
s. Puoi utilizzare questa funzione per modificare l'aspetto del componente.
// This InteractionSource will emit hover interactions val interactionSource = remember { MutableInteractionSource() } Box( Modifier .size(100.dp) .hoverable(interactionSource = interactionSource), contentAlignment = Alignment.Center ) { Text("Hello!") }
Per rendere attivabile questo componente, puoi aggiungere Modifier.focusable
e trasmettere
lo stesso MutableInteractionSource
come parametro. Ora entrambi i tipi di interazione HoverInteraction.Enter/Exit
e FocusInteraction.Focus/Unfocus
vengono emessi tramite lo stesso MutableInteractionSource
e puoi personalizzare l'aspetto per entrambi i tipi di interazione nello stesso punto:
// 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
è un'astrazione di livello ancora più elevato rispetto a hoverable
e focusable
: affinché un componente sia selezionabile, l'utente può passare implicitamente con il mouse e anche i componenti su cui è possibile fare clic devono essere attivabili. Puoi utilizzare Modifier.clickable
per creare un componente che
gestisca le interazioni di passaggio del mouse, dello stato attivo e della stampa, senza dover combinare API
di livello inferiore. Per rendere cliccabile anche il componente, puoi
sostituire hoverable
e focusable
con 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!") }
Collabora con InteractionSource
Se hai bisogno di informazioni di basso livello sulle interazioni con un componente, puoi utilizzare API di flusso standard per InteractionSource
di quel componente.
Ad esempio, supponi di voler gestire un elenco delle interazioni di stampa e di trascinamento per un InteractionSource
. Questo codice fa metà del lavoro, aggiungendo le nuove pressioni all'elenco man mano che arrivano:
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) } } } }
Tuttavia, oltre ad aggiungere le nuove interazioni, devi anche rimuoverle alla fine, ad esempio quando l'utente solleva il dito dal componente. Questa operazione è semplice, poiché le interazioni finali hanno sempre un riferimento all'interazione iniziale associata. Questo codice mostra come rimuovere le interazioni terminate:
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) } } } }
Ora, se vuoi sapere se il componente viene attualmente premuto o trascinato,
non devi fare altro che controllare se interactions
è vuoto:
val isPressedOrDragged = interactions.isNotEmpty()
Per sapere qual è stata l'interazione più recente, controlla l'ultima voce dell'elenco. Ad esempio, l'implementazione dell'eco di Compose determina l'overlay di stato appropriato da utilizzare per l'interazione più recente:
val lastInteraction = when (interactions.lastOrNull()) { is DragInteraction.Start -> "Dragged" is PressInteraction.Press -> "Pressed" else -> "No state" }
Poiché tutti gli elementi Interaction
seguono la stessa struttura, non c'è molta differenza nel codice quando si lavora con tipi diversi di interazioni degli utenti: il pattern generale è lo stesso.
Tieni presente che gli esempi precedenti in questa sezione rappresentano Flow
delle interazioni con State
. Ciò semplifica l'osservazione dei valori aggiornati, poiché la lettura del valore dello stato causerà automaticamente ricomposizioni. Tuttavia, la composizione è pre-frame in batch. Ciò significa che se lo stato cambia e poi torna indietro nello stesso frame, i componenti che osservano lo stato non noteranno la modifica.
Questo è importante per le interazioni, poiché queste possono iniziare e terminare regolarmente all'interno dello stesso frame. Ad esempio, utilizzando l'esempio precedente con Button
:
val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() Button(onClick = { /* do something */ }, interactionSource = interactionSource) { Text(if (isPressed) "Pressed!" else "Not pressed") }
Se una pressione inizia e termina all'interno dello stesso frame, il testo non verrà mai visualizzato come "Premuto!". Nella maggior parte dei casi, non si tratta di un problema: mostrare un effetto visivo per un periodo di tempo così breve comporterà uno sfarfallio e non sarà molto percepibile per l'utente. In alcuni casi, ad esempio per mostrare un effetto a onde o un'animazione simile, è consigliabile mostrare l'effetto per almeno un periodo di tempo minimo, anziché interromperlo immediatamente se il pulsante non viene più premuto. Per farlo, puoi avviare e interrompere le animazioni direttamente dall'interno del lambda collect, anziché scrivere in uno stato. Trovi un esempio di questo pattern nella sezione Crea un Indication
avanzato con bordo animato.
Esempio: creare un componente con una gestione delle interazioni personalizzata
Per scoprire come creare componenti con una risposta personalizzata all'input, ecco un esempio di pulsante modificato. In questo caso, supponi di volere un pulsante che risponda alle pressioni modificandone l'aspetto:
![Animazione di un pulsante che aggiunge dinamicamente un'icona a forma di carrello quando l'utente fa clic](https://developer.android.com/static/develop/ui/compose/images/handling-interactions-iconbutton.gif?authuser=8&hl=it)
A questo scopo, crea un componibile personalizzato basato su Button
e richiedi un
parametro icon
aggiuntivo per disegnare l'icona (in questo caso, un carrello degli acquisti). Chiama collectIsPressedAsState()
per monitorare se l'utente sta passando il mouse sopra il pulsante; quando lo sa, aggiungi l'icona. Ecco come si presenta il codice:
@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() } }
Ecco come si presenta con il nuovo componibile:
PressIconButton( onClick = {}, icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) }, text = { Text("Add to cart") } )
Poiché questo nuovo elemento PressIconButton
è basato sul materiale Button
esistente, reagisce alle interazioni degli utenti come di consueto. Quando l'utente preme il pulsante, ne modifica leggermente l'opacità, proprio come un normale Button
materiale.
Crea e applica un effetto personalizzato riutilizzabile con Indication
Nelle sezioni precedenti hai imparato a modificare parte di un componente in risposta
a Interaction
diversi, ad esempio mostrare un'icona alla pressione. Lo stesso approccio può essere utilizzato per modificare il valore dei parametri forniti a un componente o per modificare i contenuti visualizzati all'interno di un componente, ma è applicabile solo per i singoli componenti. Spesso, un sistema di applicazione o progettazione avrà un sistema generico per gli effetti visivi stateful, un effetto che dovrebbe essere applicato a tutti i componenti in modo coerente.
Se stai creando questo tipo di sistema di progettazione, personalizzare un componente e riutilizzarla per altri componenti può risultare difficile per i seguenti motivi:
- Ogni componente del sistema di progettazione richiede lo stesso boilerplate
- È facile dimenticarsi di applicare questo effetto ai componenti appena creati e ai componenti cliccabili personalizzati
- Potrebbe essere difficile combinare l'effetto personalizzato con altri effetti
Per evitare questi problemi e scalare facilmente un componente personalizzato nel tuo sistema, puoi utilizzare Indication
.
Indication
rappresenta un effetto visivo riutilizzabile che può essere applicato a tutti i componenti di un sistema di applicazioni o di progettazione. Indication
è suddiviso in due
parti:
IndicationNodeFactory
: un'azienda che crea istanzeModifier.Node
che visualizzano gli effetti visivi di un componente. Per implementazioni più semplici che non cambiano tra i diversi componenti, si può utilizzare un singleton (oggetto) e riutilizzato nell'intera applicazione.Queste istanze possono essere stateful o stateless. Poiché vengono creati per componente, possono recuperare i valori da un
CompositionLocal
per modificare il modo in cui vengono visualizzati o si comportano all'interno di un determinato componente, come con qualsiasi altroModifier.Node
.Modifier.indication
: un modificatore che attiraIndication
per un componente.Modifier.clickable
e altri modificatori di interazione di alto livello accettano direttamente un parametro di indicazione, pertanto non solo emettonoInteraction
, ma possono anche disegnare effetti visivi per iInteraction
emessi. Per i casi più semplici, quindi, puoi usareModifier.clickable
senza bisogno diModifier.indication
.
Sostituisci l'effetto con un Indication
Questa sezione descrive come sostituire un effetto scala manuale applicato a un pulsante specifico con un'indicazione equivalente che può essere riutilizzata in più componenti.
Il codice seguente crea un pulsante che viene ridimensionato verso il basso alla pressione:
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") }
Per convertire l'effetto di scala nello snippet precedente in un Indication
, segui
questi passaggi:
Crea la
Modifier.Node
responsabile dell'applicazione dell'effetto scala. Una volta collegato, il nodo osserva l'origine dell'interazione, come per gli esempi precedenti. L'unica differenza è che avvia direttamente le animazioni invece di convertire le interazioni in arrivo in stato.Il nodo deve implementare
DrawModifierNode
per poter sostituireContentDrawScope#draw()
e visualizzare un effetto di scala utilizzando gli stessi comandi di disegno di qualsiasi altra API grafica in Compose.La chiamata a
drawContent()
disponibile dal ricevitoreContentDrawScope
disegna il componente effettivo a cui deve essere applicatoIndication
, quindi dovrai solo chiamare questa funzione all'interno di una trasformazione di scala. Assicurati che le tue implementazioniIndication
chiamino sempredrawContent()
, altrimenti il componente a cui stai applicandoIndication
non verrà disegnato.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() } } }
Crea il
IndicationNodeFactory
. La sua unica responsabilità è creare una nuova istanza di nodo per un'origine di interazione fornita. Poiché non ci sono parametri per configurare l'indicazione, la fabbrica può essere un oggetto: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.clickable
utilizzaModifier.indication
internamente, quindi per creare un componente cliccabile conScaleIndication
, devi solo fornire ilIndication
come parametro aclickable
:Box( modifier = Modifier .size(100.dp) .clickable( onClick = {}, indication = ScaleIndication, interactionSource = null ) .background(Color.Blue), contentAlignment = Alignment.Center ) { Text("Hello!", color = Color.White) }
Inoltre, semplifica la creazione di componenti riutilizzabili di alto livello utilizzando un elemento
Indication
personalizzato. Un pulsante potrebbe avere il seguente aspetto:@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 ) }
A questo punto, puoi utilizzare il pulsante nel seguente modo:
ScaleButton(onClick = {}) { Icon(Icons.Filled.ShoppingCart, "") Spacer(Modifier.padding(10.dp)) Text(text = "Add to cart!") }
![Animazione di un pulsante con l'icona di un carrello della spesa che si riduce quando viene premuto](https://developer.android.com/static/develop/ui/compose/images/touchinput/button-custom-indication.gif?authuser=8&hl=it)
Indication
personalizzata.Crea una Indication
avanzata con bordo animato
Indication
non si limita solo agli effetti di trasformazione, come la scalabilità di un
componente. Poiché IndicationNodeFactory
restituisce un Modifier.Node
, puoi tracciare
qualsiasi tipo di effetto sopra o sotto i contenuti, come con altre API di disegno. Ad esempio, puoi disegnare un bordo animato intorno al componente e un overlay sopra il componente quando viene premuto:
![Un pulsante con un bizzarro effetto arcobaleno alla pressione](https://developer.android.com/static/develop/ui/compose/images/touchinput/rainbow-effect.gif?authuser=8&hl=it)
Indication
.L'implementazione Indication
qui è molto simile all'esempio precedente: crea solo un nodo con alcuni parametri. Poiché il bordo animato dipende dalla forma e dal bordo del componente per il quale viene utilizzato Indication
, l'implementazione Indication
richiede anche l'indicazione della forma e dello spessore del bordo come parametri:
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 ) } }
Anche l'implementazione di Modifier.Node
è concettualmente la stessa, anche se il codice di disegno è più complicato. Come in precedenza, osserva l'elemento InteractionSource
quando viene allegato, avvia le animazioni e implementa DrawModifierNode
per
disegnare l'effetto sui contenuti:
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 } }
La differenza principale è che ora esiste una durata minima per l'animazione con la funzione animateToResting()
. Di conseguenza, anche se si rilascia subito il tasto di pressione, l'animazione di stampa continua. Esiste anche una gestione per più pressioni rapide all'inizio di animateToPressed
: se si verifica una pressione durante un'animazione di pressione o di riposo esistente, l'animazione precedente viene annullata e l'animazione della stampa inizia dall'inizio. Per supportare più effetti simultanei (ad esempio con le onde, dove viene disegnata una nuova animazione a onde sopra altre onde), puoi monitorare le animazioni in un elenco, anziché annullare le animazioni esistenti e avviarne di nuove.
Consigliato per te
- Nota: il testo del link viene visualizzato quando JavaScript è disattivato
- Informazioni sui gesti
- Kotlin per Jetpack Compose
- Componenti e layout Material