Ejemplo de animación avanzada: Gestos

Hay varios aspectos que se deben tener en cuenta cuando trabajamos con animaciones y eventos táctiles, en comparación con los casos en que trabajamos solo con animaciones. En primer lugar, es posible que debamos interrumpir una animación en curso cuando comienzan los eventos táctiles, ya que la interacción del usuario debe tener la prioridad más alta.

En el siguiente ejemplo, usamos un Animatable para representar la posición de desplazamiento de un componente circular. Los eventos táctiles se procesan con el modificador pointerInput. Cuando detectamos un nuevo evento de toque, llamamos a animateTo para animar el valor de desplazamiento a la posición del toque. Un evento de toque también puede ocurrir durante la animación y, en ese caso, animateTo interrumpe la animación en curso y comienza la animación a la nueva posición objetivo, a la vez que se mantiene la velocidad de la animación interrumpida.

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        awaitPointerEventScope {
                            val position = awaitFirstDown().position

                            launch {
                                // Animate to the tap position.
                                offset.animateTo(position)
                            }
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

Otro patrón frecuente es tener que sincronizar valores de animación con valores provenientes de eventos táctiles, como los arrastres. En el siguiente ejemplo, vemos que "deslizar para descartar" se implementa como un Modifier (en lugar de usar el SwipeToDismiss componible). El desplazamiento horizontal del elemento se representa como un Animatable. Esta API tiene una característica útil en la animación de gestos. Su valor puede cambiar mediante eventos táctiles y la animación. Cuando recibimos un evento de toque, detenemos el Animatable mediante el método stop para que se intercepte cualquier animación en curso.

Durante un evento de arrastre, usamos snapTo para actualizar el valor Animatable con el valor calculado de los eventos táctiles. Para la navegación, Compose proporciona VelocityTracker a fin de registrar eventos de arrastre y calcular la velocidad. Se puede transmitir la velocidad directamente a animateDecay para la animación de navegación. Cuando queremos deslizar el valor de desplazamiento de vuelta a la posición original, especificamos el valor de desplazamiento objetivo de 0f con el método animateTo.

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    // Detect a touch down event.
                    val pointerId = awaitFirstDown().id

                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}