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 quiso hacerlo, sabrá que debe arrastrar el dedo fuera del botón antes de soltarlo. De lo contrario, este 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 cómo realizar un 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 un flujo de solo lectura de Interactions; no es posible emitir un Interaction a un InteractionSource. Para emitir Interaction, debes usar un MutableInteractionSource, que se extiende desde InteractionSource.

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

Ejemplo de consumo de modificador

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

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

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

Ejemplo de producción de modificador

Para un modificador que controla eventos de desplazamiento, como Modifier.hoverable, debes emitir Interactions y aceptar un MutableInteractionSource como parámetro:

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

Este modificador es un productor: puede usar el MutableInteractionSource proporcionado para emitir HoverInteractions cuando se coloca el cursor sobre él o no.

Crear componentes que consuman y produzcan

Los componentes de alto nivel, como un Button de Material, actúan como productores y 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 parámetro, de modo que puedes 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 el MutableInteractionSource del componente y observar todos los Interaction que produce el componente. Puedes usar esto para controlar el aspecto de ese componente o cualquier otro de la IU.

Si compilas tus propios componentes interactivos de alto nivel, te recomendamos que expongas MutableInteractionSource como parámetro de esta manera. Además de seguir las prácticas recomendadas de elevación de estado, esto también facilita la lectura y el control del estado visual de un componente de la misma manera en que se puede leer y controlar cualquier otro tipo de estado (como el estado habilitado).

Compose sigue un enfoque arquitectónico por capas, por lo que los componentes de Material de alto nivel se compilan sobre componentes básicos que producen los elementos Interaction que necesitan para controlar las ondas y otros efectos visuales. La biblioteca 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, solo debes usar Modifier.hoverable y pasar un MutableInteractionSource como parámetro. Cuando se coloca el cursor sobre el componente, emite HoverInteraction, y puedes usarlo para cambiar su apariencia.

// 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 también sea enfocable este componente, puedes agregar Modifier.focusable y pasar el mismo MutableInteractionSource como parámetro. Ahora, tanto HoverInteraction.Enter/Exit como FocusInteraction.Focus/Unfocus se emiten a través del mismo MutableInteractionSource, y puedes personalizar la apariencia de 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 una abstracción de nivel aún superior que hoverable y focusable. Para que se pueda hacer clic en un componente, se puede colocar el cursor sobre él de manera implícita, y los componentes en los que se puede hacer clic también deben ser enfocables. Puedes usar Modifier.clickable para crear un componente que controle las interacciones de desplazamiento, enfoque y presión, sin necesidad de combinar APIs de nivel inferior. Si también quieres que se pueda hacer clic en tu componente, puedes reemplazar 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 deseas saber cuál fue la interacción más reciente, simplemente mira el último elemento de la lista. Por ejemplo, así es como la implementación de ondas de Compose determina la superposición de estado adecuada que se usará 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 elementos Interaction siguen la misma estructura, no hay mucha diferencia en el código cuando se trabaja con diferentes tipos de interacciones del usuario: el patrón general es el mismo.

Ten en cuenta que los ejemplos anteriores de esta sección representan el Flow de las interacciones que usan State. Esto facilita la observación de los valores actualizados, ya que la lectura del valor de estado causará recomposiciones automáticamente. Sin embargo, la composición es un prefotograma en lotes. Esto significa que, si cambia el estado y, luego, vuelve a cambiar dentro del mismo fotograma, los componentes que observen el estado no verán el cambio.

Esto es importante para las interacciones, ya que pueden comenzar y finalizar normalmente dentro del mismo fotograma. Por ejemplo, con 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 pulsación comienza y finaliza dentro del mismo marco, el texto nunca aparecerá como "Pressed!". En la mayoría de los casos, esto no es un problema, ya que mostrar un efecto visual durante un tiempo tan pequeño generará un parpadeo y no será muy visible para el usuario. En algunos casos, como cuando se muestra un efecto de ondas o una animación similar, es posible que quieras mostrar el efecto durante al menos un tiempo mínimo, en lugar de detenerte de inmediato si ya no se presiona el botón. Para ello, puedes iniciar y detener animaciones directamente desde la expresión lambda de recopilación, en lugar de escribir en un estado. Puedes ver un ejemplo de este patrón en la sección Cómo compilar un elemento Indication avanzado con borde animado.

Ejemplo: Componente de compilación con control 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 dinámicamente un ícono de carrito de compras cuando se hace clic en él
Figura 3: Es un botón que agrega un ícono de forma dinámica 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 Interaction, por ejemplo, mostrar un ícono cuando se presiona. Este mismo enfoque se puede usar para cambiar el valor de los parámetros que proporcionas a un componente o el contenido que se muestra dentro de un componente, pero 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 debe aplicarse a todos los componentes de manera coherente.

Si compilas este tipo de sistema de diseño, personalizar un componente y reutilizar esta personalización para otros componentes puede ser difícil por los siguientes motivos:

  • Todos los componentes del sistema de diseño necesitan el mismo código
  • Es fácil olvidarse de aplicar este efecto a los componentes recién compilados y a los componentes personalizados 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 a todos los componentes de una aplicación o un sistema de diseño. Indication se divide en dos partes:

  • IndicationNodeFactory: Es una fábrica que crea instancias de Modifier.Node que renderizan efectos visuales para un componente. En el caso de implementaciones más simples que no cambian entre los componentes, esto puede ser un singleton (objeto) y reutilizar en toda la aplicación.

    Estas instancias pueden tener o no estado. Como se crean por componente, pueden recuperar valores de un CompositionLocal para cambiar la forma en que aparecen o se comportan dentro de un componente en particular, como con cualquier otro Modifier.Node.

  • Modifier.indication: Es un modificador que dibuja Indication para un componente. Modifier.clickable y otros modificadores de interacción de alto nivel aceptan un parámetro de indicación directamente, por lo que no solo emiten Interaction, sino que también pueden dibujar efectos visuales para las Interaction que emiten. Por lo tanto, para casos simples, puedes usar Modifier.clickable sin necesidad de Modifier.indication.

Reemplazar el efecto por un Indication

En esta sección, se describe cómo reemplazar un efecto de ajuste de escala manual aplicado a un botón específico por un equivalente de indicación que se puede volver a usar en varios componentes.

El siguiente código crea un botón que se reduce 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 en el fragmento anterior en un Indication, sigue 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, de manera similar a los ejemplos anteriores. La única diferencia aquí es que inicia directamente animaciones en lugar de convertir las interacciones entrantes en un estado.

    El nodo debe implementar DrawModifierNode para que pueda anular ContentDrawScope#draw() y renderizar un efecto de escala con los mismos comandos de dibujo que con cualquier otra API de gráficos en Compose.

    Llamar a drawContent() disponible en el receptor ContentDrawScope dibujará el componente real al que se debe aplicar Indication, por lo que solo necesitas llamar a esta función dentro de una transformación de escala. Asegúrate de que tus implementaciones de Indication siempre llamen a drawContent() en algún momento. De lo contrario, no se dibujará el componente al que le aplicas la 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. La única responsabilidad es crear una nueva instancia de nodo para una fuente de interacción proporcionada. Como no hay parámetros 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 de forma interna, por lo que, para crear un componente en el que se puede hacer clic con ScaleIndication, lo único que debes hacer es proporcionar Indication como parámetro 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)
    }

    Esto también facilita la compilación de componentes reutilizables de alto nivel mediante un Indication personalizado; 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!")
}

Una animación de un botón con un ícono de un carrito de compras que se achica cuando se presiona
Figura 4: Un botón creado con un Indication personalizado.

Compila una Indication avanzada con borde animado

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

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

La implementación de Indication aquí es muy similar al ejemplo anterior: solo crea un nodo con algunos parámetros. Como el borde animado depende de la forma y el borde del componente para el que se usa Indication, 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 código de dibujo es más complicado. Como 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 aquí es que ahora hay una duración mínima para la animación con la función animateToResting(), por lo que, incluso si se suelta de inmediato, la animación de presión continuará. También se puede controlar varias pulsaciones rápidas al comienzo de animateToPressed. Si una pulsación se produce durante una animación existente al presionar o en reposo, se cancela la animación anterior, y la animación de presión comienza desde el principio. Para admitir varios efectos simultáneos (como con ondas, en el que una nueva animación de ondas se dibujará sobre otras), puedes hacer un seguimiento de las animaciones en una lista, en lugar de cancelar las existentes y comenzar otras nuevas.