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á.
![](https://developer.android.com/static/develop/ui/compose/images/touchinput/button-always-enabled.gif?authuser=8&hl=es)
![](https://developer.android.com/static/develop/ui/compose/images/touchinput/button-reflects-state.gif?authuser=8&hl=es)
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](https://developer.android.com/static/develop/ui/compose/images/handling-interactions-iconbutton.gif?authuser=8&hl=es)
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 deModifier.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 otroModifier.Node
.Modifier.indication
: Es un modificador que dibujaIndication
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 emitenInteraction
, sino que también pueden dibujar efectos visuales para lasInteraction
que emiten. Por lo tanto, para casos simples, puedes usarModifier.clickable
sin necesidad deModifier.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:
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 anularContentDrawScope#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 receptorContentDrawScope
dibujará el componente real al que se debe aplicarIndication
, por lo que solo necesitas llamar a esta función dentro de una transformación de escala. Asegúrate de que tus implementaciones deIndication
siempre llamen adrawContent()
en algún momento. De lo contrario, no se dibujará el componente al que le aplicas laIndication
.private class ScaleNode(private val interactionSource: InteractionSource) : Modifier.Node(), DrawModifierNode { var currentPressPosition: Offset = Offset.Zero val animatedScalePercent = Animatable(1f) private suspend fun animateToPressed(pressPosition: Offset) { currentPressPosition = pressPosition animatedScalePercent.animateTo(0.9f, spring()) } private suspend fun animateToResting() { animatedScalePercent.animateTo(1f, spring()) } override fun onAttach() { coroutineScope.launch { interactionSource.interactions.collectLatest { interaction -> when (interaction) { is PressInteraction.Press -> animateToPressed(interaction.pressPosition) is PressInteraction.Release -> animateToResting() is PressInteraction.Cancel -> animateToResting() } } } } override fun ContentDrawScope.draw() { scale( scale = animatedScalePercent.value, pivot = currentPressPosition ) { this@draw.drawContent() } } }
Crea 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 }
Modifier.clickable
usaModifier.indication
de forma interna, por lo que, para crear un componente en el que se puede hacer clic conScaleIndication
, lo único que debes hacer es proporcionarIndication
como parámetro aclickable
: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](https://developer.android.com/static/develop/ui/compose/images/touchinput/button-custom-indication.gif?authuser=8&hl=es)
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](https://developer.android.com/static/develop/ui/compose/images/touchinput/rainbow-effect.gif?authuser=8&hl=es)
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.
Recomendaciones para ti
- Nota: El texto del vínculo se muestra cuando JavaScript está desactivado
- Información sobre los gestos
- Kotlin para Jetpack Compose
- Componentes y diseños de Material