Cómo controlar las interacciones del usuario

Los componentes de la interfaz de usuario le proporcionan comentarios al usuario del dispositivo en función de cómo responde a las interacciones. Cada componente tiene su propia forma de responder a las interacciones, lo que ayuda al usuario a saber qué hacen sus interacciones. Por ejemplo, si un usuario toca un botón en la pantalla táctil de un dispositivo, es probable que este cambie de alguna manera, quizás agregando un color de resaltado. Este cambio le informa al usuario que tocó el botón. Si el usuario no quiere hacer sabrán que deben arrastrar el dedo fuera del botón antes de suelta; de lo contrario, el botón se activará.

Figura 1: Botones que siempre aparecen habilitados, sin ondas de presión.
Figura 2: Botones con ondas de presión que reflejan su estado habilitado según corresponda

En la documentación de Gestos de Compose, se explica cómo los componentes de Compose controlan un evento de puntero de bajo nivel, como los movimientos y los clics del puntero. Desde el primer momento, Compose abstrae esos eventos de bajo nivel en interacciones de nivel superior; por ejemplo, una serie de eventos de puntero podría sumar una acción de presionar y soltar. Entender esas abstracciones de nivel superior puede ayudarte a personalizar la manera en que responde tu IU al usuario. Por ejemplo, es posible que quieras personalizar cómo cambia la apariencia de un componente cuando el usuario interactúa con él o quizás solo quieres mantener un registro de esas acciones del usuario. Este documento te brinda la información que necesitas para modificar los elementos estándar de la IU o diseñar los propios.

Interacciones

En muchos casos, no necesitas saber la forma exacta en la que el componente de Compose interpreta las interacciones del usuario. Por ejemplo, Button se basa en Modifier.clickable para determinar si el usuario hizo clic en el botón. Si agregas un botón típico a tu app, puedes definir el código onClick de ese botón, y Modifier.clickable ejecutará ese código cuando corresponda. Eso significa que no necesitas saber si el usuario presionó la pantalla o seleccionó el botón con un teclado. Modifier.clickable determina que el usuario hizo un clic y responde ejecutando el código onClick.

Sin embargo, si deseas personalizar la respuesta de tu componente de IU al comportamiento del usuario, es posible que necesites saber más acerca de lo que ocurre en niveles más profundos. En esta sección, encontrarás parte de esa información.

Cuando un usuario interactúa con un componente de la IU, el sistema representa su comportamiento mediante la generación de varios eventos Interaction. Por ejemplo, si un usuario toca un botón, este genera PressInteraction.Press. Si el usuario levanta el dedo dentro del botón, se genera un PressInteraction.Release que informa que terminó el clic. Por otro lado, si el usuario arrastra el dedo fuera del botón y luego lo levanta, el botón genera PressInteraction.Cancel para indicar que no se completó la acción de presionar el botón.

Estas interacciones no tienen tendencias. Es decir, estos eventos de interacción de bajo nivel no pretenden interpretar el significado de las acciones del usuario ni su secuencia. Tampoco interpretan qué acciones del usuario pueden tener prioridad sobre otras.

Estas interacciones suelen venir en pares, con un inicio y un fin. La segunda interacción contiene una referencia a la primera. Por ejemplo, si un usuario toca un botón y luego levanta el dedo, el toque genera una interacción PressInteraction.Press y levantarlo genera una PressInteraction.Release; la Release tiene una propiedad press que identifica la PressInteraction.Press inicial.

Puedes ver las interacciones de un componente en particular si observas su InteractionSource. InteractionSource se basa en flujos de Kotlin, por lo que puedes recopilar las interacciones a partir de allí de la misma manera en que trabajarías con cualquier otro flujo. Para obtener más información sobre esta decisión de diseño, consulta la entrada de blog Illuminating Interactions.

Estado de las interacciones

Se recomienda que realices un seguimiento de las interacciones tú mismo para extender la funcionalidad integrada de tus componentes. Por ejemplo, tal vez quieras que un botón cambie de color cuando se presiona. La forma más sencilla de hacer un seguimiento de las interacciones es observar el estado de interacción apropiado. InteractionSource ofrece una serie de métodos que revelan varios estados de interacción como estados. Por ejemplo, si quieres ver si se presiona un botón en particular, puedes llamar a su método InteractionSource.collectIsPressedAsState():

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Además de collectIsPressedAsState(), Compose también proporciona collectIsFocusedAsState(), collectIsDraggedAsState() y collectIsHoveredAsState(). En realidad, estos son métodos prácticos compilados sobre las APIs de InteractionSource de nivel inferior. En algunos casos, es recomendable usar esas funciones de nivel inferior directamente.

Por ejemplo, supón que necesitas saber si se presiona un botón y también si se lo está arrastrando. Si usas collectIsPressedAsState() y collectIsDraggedAsState(), Compose realiza gran parte del trabajo por duplicado y no hay garantía de que obtengas todas las interacciones en el orden correcto. Para este tipo de situaciones, es recomendable trabajar directamente con InteractionSource. Para obtener más información sobre el seguimiento de las interacciones Con InteractionSource, consulta Cómo trabajar con InteractionSource.

En la siguiente sección, se describe cómo consumir y emitir interacciones con InteractionSource y MutableInteractionSource, respectivamente.

Consumir y emitir Interaction

InteractionSource representa una transmisión de solo lectura de Interactions; no es es posible emitir un Interaction a un InteractionSource. Para emitir Interaction, debes usar un MutableInteractionSource, que se extiende desde InteractionSource

Los modificadores y los componentes pueden consumir, emitir o consumir y emitir Interactions. En las siguientes secciones, se describe cómo consumir y emitir interacciones de ambos modificadores y componentes.

Ejemplo de modificador de consumo

Para un modificador que dibuja un borde para el estado enfocado, solo debes observar Interactions para que puedas aceptar una InteractionSource:

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

A partir de la firma de la función, queda claro que este modificador es un consumidor: puede consumir Interaction, pero no emitirlos.

Ejemplo de modificador de producción

Para un modificador que controla eventos de colocar el cursor sobre un elemento, como Modifier.hoverable, puedes debes emitir Interactions y aceptar un MutableInteractionSource como en su lugar:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

Este modificador es un productor y puede usar el MutableInteractionSource para emitir HoverInteractions cuando se coloca el cursor sobre él o sin desplazarse.

Crear componentes que consuman y produzcan

Los componentes de alto nivel, como Button de Material, actúan como productores y a los consumidores. Controlan los eventos de entrada y enfoque, y también cambian su apariencia. en respuesta a estos eventos, como mostrar una onda o animar su elevación. Como resultado, exponen MutableInteractionSource directamente como una para que puedas proporcionar tu propia instancia recordada:

@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() */ }

Esto permite elevar la MutableInteractionSource del componente y observar todos los Interaction que produce el componente. Puedes usar esta opción para controlar de ese componente o de cualquier otro componente de tu IU.

Si vas a compilar tus propios componentes interactivos de alto nivel, te recomendamos que expongas MutableInteractionSource como parámetro de esta manera. Además de elevación de estado, esto también facilita la lectura y controlar el estado visual de un componente de la misma manera que cualquier otro tipo de (como el estado habilitado) se pueden leer y controlar.

Compose sigue un enfoque arquitectónico en capas, por lo que los componentes de Material de alto nivel se construyen sobre la base de la construcción bloques que producen los Interaction que necesitan para controlar las ondas y otras efectos visuales. La biblioteca de base proporciona modificadores de interacción de alto nivel como Modifier.hoverable, Modifier.focusable y Modifier.draggable

Para compilar un componente que responda a los eventos de colocar el cursor sobre un elemento, puedes usar Modifier.hoverable y pasa un elemento MutableInteractionSource como parámetro. Cuando se coloca el cursor sobre el componente, emite HoverInteractions, y puedes usar esto para cambiar la forma en que aparece el componente.

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Para que este componente también sea enfocable, puedes agregar Modifier.focusable y pasar el mismo MutableInteractionSource que un parámetro. Ambos Se emiten HoverInteraction.Enter/Exit y FocusInteraction.Focus/Unfocus a través de la misma MutableInteractionSource, y puedes personalizar la para ambos tipos de interacción en el mismo lugar:

// 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 es un valor aún más alto nivel de abstracción que hoverable y focusable, para que un componente sea se puede hacer clic en él, se puede colocar el cursor sobre él implícitamente, y los componentes en los que se puede hacer clic deben también deben ser enfocables. Puedes usar Modifier.clickable para crear un componente que maneja interacciones de desplazamiento, enfoque y presión, sin necesidad de combinar partes APIs de nivel de servicio. Si también deseas que se pueda hacer clic en tu componente, puedes Reemplaza hoverable y focusable por 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!")
}

Trabaja con InteractionSource

Si necesitas información detallada sobre las interacciones con un componente, puedes usar las APIs de flujo estándar para el InteractionSource de ese componente. Por ejemplo, supongamos que quieres mantener una lista de las interacciones de presionar y arrastrar para un InteractionSource. Este código realiza la mitad del trabajo y agrega las presiones nuevas a la lista a medida que se reciben:

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)
            }
        }
    }
}

Sin embargo, además de agregar las interacciones nuevas, también debes quitarlas cuando terminan (por ejemplo, cuando el usuario levanta el dedo del componente). Es fácil de hacer, ya que las interacciones de fin siempre llevan una referencia a la interacción de inicio asociada. En el siguiente código, se muestra cómo quitar las interacciones que finalizaron:

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)
            }
        }
    }
}

Ahora, si quieres saber si el componente se está presionando o arrastrando, lo único que debes hacer es comprobar si interactions está vacío:

val isPressedOrDragged = interactions.isNotEmpty()

Si quieres saber cuál fue la interacción más reciente, consulta la última elemento de la lista. Por ejemplo, así es como la implementación de ondas de Compose calcula cuál es la superposición de estado apropiada que debe usarse para la interacción más reciente:

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

Debido a que todos los Interaction siguen la misma estructura, no hay gran parte de un diferencia de código cuando se trabaja con diferentes tipos de interacciones de usuario: el el patrón general es el mismo.

Ten en cuenta que los ejemplos anteriores de esta sección representan el Flow de Interacciones con State lo que facilita la observación de los valores actualizados, ya que leer el valor del estado generará recomposiciones automáticamente. Sin embargo, la composición es un fotograma previo por lotes. Esto significa que, si el estado cambia y luego vuelve a cambiar dentro del mismo marco, los componentes que observan el estado no a ver el cambio.

Esto es importante para las interacciones, ya que estas pueden comenzar y finalizar regularmente dentro del mismo marco. Por ejemplo, si usamos el ejemplo anterior con Button:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Si una presión comienza y termina dentro del mismo marco, el texto nunca se mostrará como "¡Presionado!". En la mayoría de los casos, no se trata de un problema: se muestra un efecto visual en una cantidad de tiempo tan pequeña hará que parpadee, y no será muy notorio para el usuario. Para algunos casos, como mostrar un efecto de ondas o una animación similar, es posible que quieras mostrar el efecto por al menos en lugar de detenerse de inmediato si ya no se presiona el botón. Para Para eso, pueden iniciar y detener las animaciones directamente lambda, en lugar de escribir en un estado. Hay un ejemplo de este patrón en La sección Cómo compilar un Indication avanzado con borde animado.

Ejemplo: Componente de compilación con manejo de interacciones personalizadas

Para ver cómo compilar componentes con una respuesta personalizada a la entrada, a continuación se muestra un ejemplo de un botón modificado. En este caso, supongamos que quieres un botón que responda a las pulsaciones cambiando la apariencia:

Animación de un botón que agrega de forma dinámica un ícono de carrito de compras cuando se hace clic en él
Figura 3: Un botón que agrega de forma dinámica un ícono cuando se hace clic en él

Para ello, compila un elemento que admite composición personalizado, basado en Button y haz que tome un parámetro icon adicional a fin de dibujar el ícono (en este caso, un carrito de compras). Llamas a collectIsPressedAsState() para realizar un seguimiento a fin de que el usuario pueda colocar el cursor sobre el botón. Cuando eso sucede, agregas el ícono. A continuación, se muestra el código:

@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()
    }
}

Así se ve cómo se usa ese nuevo elemento que admite composición:

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

Como este PressIconButton nuevo se compila sobre el Button de Material existente, reacciona a las interacciones del usuario de todas las maneras habituales. Cuando el usuario presiona el botón, cambia su opacidad ligeramente, como ocurre con un Button común de Material.

Crea y aplica un efecto personalizado reutilizable con Indication

En secciones anteriores, aprendiste a cambiar parte de un componente en respuesta a diferentes elementos Interaction, como mostrar un ícono cuando se presiona. Este mismo se puede usar para cambiar el valor de los parámetros que proporciona a un componente o cambiar el contenido que se muestra dentro de un componente, pero esto es solo se aplica por componente. A menudo, una aplicación o un sistema de diseño tendrá un sistema genérico para efectos visuales con estado, un efecto que debería aplicarse a todos los componentes de forma coherente.

Si estás creando este tipo de sistema de diseño, personalizando un componente y reutilizar esta personalización para otros componentes puede ser difícil siguientes motivos:

  • Cada componente del sistema de diseño necesita el mismo código estándar
  • Es fácil olvidarse de aplicar este efecto a los componentes recién compilados y a componentes en los que se puede hacer clic
  • Puede ser difícil combinar el efecto personalizado con otros efectos.

Para evitar estos problemas y escalar con facilidad un componente personalizado en tu sistema, puedes usar Indication. Indication representa un efecto visual reutilizable que se puede aplicar en en una aplicación o un sistema de diseño. Indication se divide en dos partes:

  • IndicationNodeFactory: Una fábrica que crea instancias de Modifier.Node renderizar efectos visuales de un componente. Para implementaciones más simples que no cambian entre componentes, esto puede ser un singleton (objeto) que se puede reutilizar en toda la aplicación.

    Estas instancias pueden ser o no. Dado que se crean por , pueden recuperar valores de un CompositionLocal para cambiar aparecen o se comportan dentro de un componente en particular, como cualquier otro Modifier.Node

  • Modifier.indication: Un modificador que dibuja Indication para una este componente. Modifier.clickable y otros modificadores de interacción de alto nivel aceptan un parámetro de indicación directamente, de modo que no solo emitan Interaction, pero también puede dibujar efectos visuales para las Interaction que emiten. Por lo tanto, para casos simples, puedes usar Modifier.clickable sin que necesita Modifier.indication.

Cómo reemplazar el efecto por una Indication

En esta sección, se describe cómo reemplazar un efecto de escala manual aplicado a uno un botón específico con una indicación equivalente que puede reutilizarse en varias o los componentes de la solución.

El siguiente código crea un botón que escala verticalmente cuando se lo presiona:

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")
}

Para convertir el efecto de escala del fragmento anterior en un Indication, sigue estos pasos: estos pasos:

  1. Crea el Modifier.Node responsable de aplicar el efecto de escala. Cuando se conecta, el nodo observa la fuente de interacción, similar a la anterior ejemplos. La única diferencia es que inicia animaciones directamente. en lugar de convertir las interacciones entrantes en un estado.

    El nodo debe implementar DrawModifierNode para que pueda anularlo. ContentDrawScope#draw() y renderiza un efecto de escala con el mismo dibujo al igual que con cualquier otra API de gráficos en Compose.

    Las llamadas a drawContent() disponibles desde el receptor ContentDrawScope dibujarán el componente real al que se debe aplicar Indication, por lo que solo debes llamar a esta función dentro de una transformación a escala. Asegúrate de que Las implementaciones de Indication siempre llaman a drawContent() en algún momento. De lo contrario, no se dibujará el componente al que estás aplicando Indication.

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

  2. Crea el IndicationNodeFactory. Su única responsabilidad es crear un una nueva instancia de nodo para una fuente de interacción proporcionada. Como no hay para configurar la indicación, la fábrica puede ser un objeto:

    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 usa Modifier.indication internamente. en el que se puede hacer clic con ScaleIndication, solo debes proporcionar Indication como parámetro para clickable:

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    Esto también facilita la compilación de componentes reutilizables de alto nivel usando un Indication: Un botón podría verse de la siguiente manera:

    @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
        )
    }

Luego, puedes usar el botón de la siguiente manera:

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

Animación de un botón con el ícono de un carrito de compras que se hace más pequeño cuando se lo presiona
Figura 4: Un botón compilado con un Indication personalizado.

Cómo compilar un objeto Indication avanzado con borde animado

Indication no se limita solo a los efectos de transformación, como el escalamiento de una este componente. Como IndicationNodeFactory muestra un Modifier.Node, puedes dibujar cualquier tipo de efecto encima o debajo del contenido, al igual que con otras APIs de dibujo Para ejemplo, puedes dibujar un borde animado alrededor del componente y una superposición en la parte superior del componente cuando se lo presiona:

Un botón con un efecto de arcoíris elegante cuando se presiona
Figura 5: Efecto de borde animado dibujado con Indication.

La implementación de Indication es muy similar al ejemplo anterior: solo crea un nodo con algunos parámetros. Dado que el borde animado depende en la forma y el borde del componente para el que se usa el Indication, el La implementación de Indication también requiere que se proporcionen la forma y el ancho del borde. como parámetros:

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
        )
    }
}

La implementación de Modifier.Node también es conceptualmente la misma, incluso si el dibujar código es más complicado. Al igual que antes, observa InteractionSource. cuando se adjunta, inicia animaciones e implementa DrawModifierNode para dibujar el efecto sobre el contenido:

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 principal diferencia es que ahora hay una duración mínima para una animación con la función animateToResting(), de modo que incluso si la presión inmediatamente, la animación de prensa continuará. También está la administración para pulsaciones rápidas múltiples al inicio de animateToPressed, si se presiona ocurre durante una animación en reposo o de presión existente, la animación anterior se cancelada y la animación de prensa comienza desde el principio. Para admitir varias efectos concurrentes (como con ondas, en los que se dibujará una nueva animación de ondas) sobre otros ondas), puedes hacer un seguimiento de las animaciones en una lista, en lugar de cancelar las animaciones existentes e iniciar otras.