wandres.dev
COMPOSE: ANIMACIONES · transiciones y gestos

Animatable y el control fino: interrumpir, secuenciar y coordinar

Cuando la animación deja de ser una función del estado y pasa a ser una acción con guion propio, hay que bajar un piso. Estudia Animatable como valor que posees dentro de una corrutina, el mutex de mutación que garantiza un solo dueño y la interrupción sin costuras, cómo se escriben secuencias que esperan unas a otras, y cuándo conviene coordinar varias animaciones con un reloj compartido mediante Transition.

⏱ 18 min

Las APIs de alto nivel se apoyan todas en la misma premisa: la animación es una consecuencia automática de que el estado cambió, y basta con declarar el destino para que el framework se ocupe del resto. Esa premisa cubre la mayoría de la interfaz, pero se agota en cuanto la animación necesita un guion propio —esperar a que termine una parte antes de empezar otra, arrancar con la velocidad que traía un dedo, encadenar tres tramos con lógica entre ellos, cancelarse limpiamente a mitad porque llegó una respuesta del servidor—. Ahí hay que bajar un piso y coger el volante, y el volante se llama Animatable: un valor animable que tú posees, que vive en una corrutina y que se mueve porque tú se lo pides, con todas las garantías de cancelación estructurada que eso implica.

🎯 Al terminar esta lección sabrás
  • Manejar Animatable como valor propio con animateTo, snapTo, stop y su velocidad.
  • Explicar qué garantiza el mutex de mutación y por qué la interrupción no produce saltos.
  • Escribir secuencias y grupos de animaciones concurrentes con corrutinas y cancelación correcta.
  • Decidir entre Animatable suelto y Transition con reloj compartido según el problema.

Un valor que posees dentro de una corrutina

Animatable es un contenedor de un valor animable creado con un valor inicial. Su método principal, animateTo, es una función de suspensión: no devuelve el control hasta que la animación termina, y si la corrutina que la llamó se cancela, la animación se detiene donde esté. Esa doble propiedad —suspender y respetar la cancelación— es la que hace que todo lo demás encaje.

@Composable
fun TarjetaQuePalpita(alerta: Boolean) {
    val escala = remember { Animatable(1f) }

    LaunchedEffect(alerta) {
        if (alerta) {
            escala.animateTo(1.15f, spring(stiffness = Spring.StiffnessMedium))
            escala.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
        } else {
            escala.snapTo(1f)
        }
    }

    Tarjeta(Modifier.graphicsLayer { scaleX = escala.value; scaleY = escala.value })
}

La superficie útil del tipo es pequeña y conviene conocerla entera. value es el valor actual y es un estado observable, de modo que leerlo recompone. targetValue es hacia dónde va, que durante una animación no coincide con value. velocity es la velocidad instantánea, la pieza clave para encadenar con gestos. snapTo fija el valor sin animar, también suspendiendo, porque debe adquirir el mismo derecho de escritura que una animación. stop detiene lo que haya en curso dejando el valor donde esté, y updateBounds fija un mínimo y un máximo que la animación no cruzará, útil para un arrastre acotado.

Hay un detalle de tipos que despista al principio: un Animatable de Float se crea sin más, pero uno de Dp, Offset o Color necesita saber convertir su tipo a un vector de números, así que se le pasa el convertidor correspondiente, como Dp.VectorConverter o el que ofrece cada tipo. Ese convertidor es exactamente lo que las funciones de alto nivel eligen por ti al haber una variante por tipo.

El mutex de mutación: un solo dueño a la vez

La pregunta obvia al ver una API imperativa dentro de un mundo declarativo es qué ocurre si dos sitios distintos intentan mover el mismo valor a la vez. La respuesta de Compose es un MutatorMutex interno: cada Animatable tiene un único derecho de escritura, y quien lo pide con más prioridad —o simplemente después, si la prioridad es igual— hace que el titular anterior se cancele.

Esto es más profundo que evitar una condición de carrera. Significa que la interrupción se resuelve por construcción y sin saltos: la corrutina que estaba animando recibe una cancelación, la animación se detiene en el fotograma en curso, y la nueva empieza desde esa posición exacta y, si es un resorte, con la velocidad exacta. Nunca hay dos fuentes escribiendo valores contradictorios en fotogramas alternos, que es el fallo clásico de las animaciones imperativas hechas a mano.

// Dos gestos compiten por el mismo valor: el ultimo gana, sin salto
val desplazamiento = remember { Animatable(0f) }
val alcance = rememberCoroutineScope()

Modifier.pointerInput(Unit) {
    detectHorizontalDragGestures(
        onDragStart = { alcance.launch { desplazamiento.stop() } },
        onHorizontalDrag = { _, delta ->
            alcance.launch { desplazamiento.snapTo(desplazamiento.value + delta) }
        },
        onDragEnd = {
            alcance.launch {
                desplazamiento.animateTo(0f, spring(), initialVelocity = desplazamiento.velocity)
            }
        },
    )
}

Ese initialVelocity es la costura invisible entre gesto y animación, y merece atención porque es la diferencia entre una app que se siente cara y una que no. Al soltar, el valor no se queda quieto esperando a que la animación arranque: continúa con la velocidad que llevaba el dedo y el resorte la absorbe. Sin él, hay un instante de parada, breve pero perceptible, que delata que dos sistemas distintos se pasaron el testigo.

⚠️
Cuidado con el alcance de corrutina que eliges

LaunchedEffect pertenece a la composición: su corrutina muere cuando el componible sale del árbol o cuando cambia la clave, y eso normalmente es lo correcto para una animación ligada a un estado. rememberCoroutineScope da un alcance que sobrevive a las recomposiciones y se cancela al salir de la composición, y es el adecuado para lanzar animaciones desde una devolución de llamada, como el final de un gesto o la pulsación de un botón. Lanzar desde el alcance del ViewModel una animación de interfaz es un error de ubicación: el valor pertenece a la pantalla, no al modelo, y sobrevivirá a la pantalla que lo dibujaba.

Secuencias, grupos y cancelación

Como animateTo suspende, una secuencia se escribe como código secuencial y no hay nada nuevo que aprender: cada línea espera a la anterior. Y como las corrutinas componen, lanzar varias animaciones a la vez es abrir varias ramas en el mismo alcance.

LaunchedEffect(mostrarExito) {
    if (!mostrarExito) return@LaunchedEffect
    coroutineScope {
        // En paralelo: la marca crece mientras el fondo se aclara
        launch { marca.animateTo(1f, spring(dampingRatio = 0.55f)) }
        launch { fondo.animateTo(1f, tween(220)) }
    }
    // Solo cuando ambas terminan: la secuencia continua
    delay(600)
    marca.animateTo(0f, tween(180))
    alTerminar()
}

coroutineScope es la pieza que convierte varias animaciones concurrentes en una sola unidad: no vuelve hasta que todas sus hijas terminan, y si una falla o el conjunto se cancela, todas caen juntas. Esa es la coordinación que en un sistema de devoluciones de llamada exigiría un contador de terminaciones y una bandera de cancelación, con sus correspondientes fugas cuando alguien olvida decrementar.

La cancelación también resuelve gratis el caso incómodo de las animaciones que deben abortarse. Si el estado cambia y la clave de LaunchedEffect cambia con él, la corrutina anterior se cancela: la secuencia se interrumpe donde estuviera, los Animatable se quedan quietos y la nueva secuencia toma el mando desde ahí. No hay que escribir ninguna comprobación de sigo siendo válido en cada tramo, que es el error más repetido cuando se traduce este patrón desde APIs antiguas.

Cuando lo que hace falta es un reloj compartido

Animatable da control total a costa de que cada valor sea independiente. Cuando lo que se anima es un cambio de estado que mueve muchas propiedades a la vez, y todas deben estar sincronizadas y ser inspeccionables como una sola cosa, la herramienta correcta es Transition, que se crea con updateTransition y ofrece funciones hijas por tipo.

val transicion = updateTransition(targetState = seleccionado, label = "seleccion")

val borde by transicion.animateDp(label = "borde") { sel -> if (sel) 3.dp else 1.dp }
val color by transicion.animateColor(
    transitionSpec = { tween(200) },
    label = "color",
) { sel -> if (sel) Color(0xFF89B4FA) else Color(0xFF45475A) }
val rotacion by transicion.animateFloat(label = "rotacion") { sel -> if (sel) 45f else 0f }

Las tres animaciones comparten un mismo estado objetivo, arrancan a la vez y el Transition sabe cuándo todas han terminado, lo que además lo hace visible como una unidad en el inspector de animaciones. createChildTransition permite derivar subtransiciones para componentes hijos, y AnimatedVisibility y AnimatedContent no son más que envoltorios construidos sobre este mismo mecanismo.

🎛️

Animatable

Cuando la animación es una acción con guion: secuencias, encadenar con un gesto, esperar resultados, cancelar a mitad. El valor es tuyo y el tiempo también.

🕰️

Transition

Cuando muchas propiedades responden al mismo cambio de estado y deben moverse como una sola cosa, con un único reloj y un final común.

🪶

animate*AsState

Cuando solo hay un valor y una regla. Por dentro es un Animatable con un LaunchedEffect, así que bajar de piso sin necesidad solo añade líneas.

🚦

MutatorMutex

La garantía transversal: un solo escritor por valor. Si dos flujos tocan la misma animación, no hay carrera, hay relevo con continuidad.

sequenceDiagram
participant G as Gesto
participant A as Animatable
participant C as Corrutina en curso
G->>A: stop mientras el dedo esta abajo
A->>C: cancelacion, el valor se queda donde esta
G->>A: snapTo en cada delta del arrastre
G->>A: animateTo al soltar con la velocidad final
A-->>G: la simulacion continua sin discontinuidad
Bajar de nivel es reclamar la propiedad del tiempo, y hay que devolverla

Todo el diseño de animación de Compose puede leerse como una negociación sobre quién posee el tiempo. En el nivel alto lo posee el framework: tú declaras destinos y él decide cuándo empieza cada viaje, cuándo termina y qué hacer si el destino se mueve. Esa cesión es enormemente rentable, porque el framework hace bien tres cosas que casi nadie hace bien a mano: cancela lo obsoleto, conserva la velocidad al redirigir y garantiza que la pantalla es siempre una función del estado actual. Cuando bajas a Animatable, recuperas el tiempo y con él la capacidad de escribir guiones, pero heredas también las tres obligaciones. La primera la resuelven las corrutinas y su cancelación estructurada, que es precisamente la razón de que la API sea de suspensión y no de devoluciones de llamada; la segunda la resuelve el mutex de mutación con la disciplina de un solo dueño; la tercera no la resuelve nadie por ti, y es donde se rompen los diseños. El síntoma es siempre el mismo: una animación imperativa que ha dejado de ser una función del estado y se ha convertido en una fuente de verdad paralela, de forma que tras un cambio de configuración, una recomposición inesperada o una vuelta desde segundo plano, la pantalla muestra el resultado de un guion que ya nadie está ejecutando. La regla que evita esa deriva es sencilla de enunciar y exigente de cumplir: un Animatable puede poseer el camino, nunca el destino. El destino debe seguir viviendo en el estado, y el guion debe ser reconstruible a partir de él —de ahí que la clave de LaunchedEffect sea tan importante y no un detalle—. Quien respeta esa frontera obtiene lo mejor de los dos mundos: la expresividad de una animación imperativa y la reconstruibilidad de una interfaz declarativa. Quien la cruza acaba con una pantalla cuyo aspecto solo se explica por la historia de lo que el usuario hizo, y eso, en un sistema que puede recomponer o recrear su interfaz en cualquier momento, es exactamente la clase de estado que Compose existe para eliminar.

⚔️ Toma el volante sin perder el estado
  1. Sustituye un animate*AsState tuyo por un Animatable equivalente con LaunchedEffect, y comprueba que el comportamiento es idéntico. Explica qué has ganado y qué has perdido.
  2. Escribe una secuencia de tres tramos con lógica entre ellos y cancélala a mitad cambiando la clave del LaunchedEffect. Verifica que el valor se queda donde estaba y no salta.
  3. Lanza dos animaciones en paralelo dentro de un coroutineScope y ejecuta una acción solo cuando ambas hayan terminado, sin usar ningún contador manual.
  4. Encadena un arrastre con un animateTo pasando initialVelocity, y luego quita ese parámetro. Describe el instante concreto en el que se nota la diferencia.
  5. Reescribe con updateTransition un componente donde tres propiedades reaccionen al mismo booleano y localízalo después en el inspector de animaciones por su label.