Gestione delle interazioni degli utenti

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à.

Figura 1. Pulsanti che appaiono sempre attivi, senza premere onde.
Figura 2. Pulsanti con onde di pressione che riflettono lo stato di attivazione corrispondente.

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&#39;icona a forma di carrello quando l&#39;utente fa clic
Figura 3. Un pulsante che aggiunge in modo dinamico un'icona quando l'utente fa clic.

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 istanze Modifier.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 altro Modifier.Node.

  • Modifier.indication: un modificatore che attira Indication per un componente. Modifier.clickable e altri modificatori di interazione di alto livello accettano direttamente un parametro di indicazione, pertanto non solo emettono Interaction, ma possono anche disegnare effetti visivi per i Interaction emessi. Per i casi più semplici, quindi, puoi usare Modifier.clickable senza bisogno di Modifier.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:

  1. 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 sostituire ContentDrawScope#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 ricevitore ContentDrawScope disegna il componente effettivo a cui deve essere applicato Indication, quindi dovrai solo chiamare questa funzione all'interno di una trasformazione di scala. Assicurati che le tue implementazioni Indication chiamino sempre drawContent(), altrimenti il componente a cui stai applicando Indication 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()
            }
        }
    }

  2. 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
    }

  3. Modifier.clickable utilizza Modifier.indication internamente, quindi per creare un componente cliccabile con ScaleIndication, devi solo fornire il Indication come parametro a clickable:

    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&#39;icona di un carrello della spesa che si riduce quando viene premuto
Figura 4. Un pulsante creato con un'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
Figura 5. Un effetto bordo animato disegnato con 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.