animate*AsState — animar valores

La forma más simple de animar en Compose. Cuando el valor objetivo cambia, animate*AsState anima la transición automáticamente:

@Composable
fun BotonAnimado(seleccionado: Boolean) {

    // El color anima suavemente entre los dos valores
    val colorFondo by animateColorAsState(
        targetValue = if (seleccionado)
            MaterialTheme.colorScheme.primary
        else
            MaterialTheme.colorScheme.surface,
        animationSpec = tween(durationMillis = 300),
        label = "colorFondo"
    )

    // El tamaño también puede animarse
    val tamanio by animateDpAsState(
        targetValue = if (seleccionado) 56.dp else 48.dp,
        animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
        label = "tamanio"
    )

    Box(
        modifier = Modifier
            .size(tamanio)
            .background(colorFondo, shape = CircleShape)
    )
}

// Variantes disponibles:
// animateFloatAsState, animateIntAsState, animateDpAsState,
// animateSizeAsState, animateOffsetAsState, animateColorAsState,
// animateIntOffsetAsState, animateRectAsState

animationSpectween() para animaciones lineales con duración fija. spring() para animaciones físicas más naturales con rebote. keyframes() para control granular de puntos intermedios.

AnimatedVisibility — entradas y salidas

Anima la aparición y desaparición de composables. Mucho más fácil que el equivalente en el View System:

@Composable
fun BannerError(visible: Boolean, mensaje: String) {
    AnimatedVisibility(
        visible = visible,
        enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
        exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
    ) {
        Card(
            modifier = Modifier.fillMaxWidth(),
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.errorContainer
            )
        ) {
            Text(
                text = mensaje,
                modifier = Modifier.padding(16.dp),
                color = MaterialTheme.colorScheme.onErrorContainer
            )
        }
    }
}

// Animaciones de enter/exit disponibles:
// fadeIn/fadeOut
// slideInVertically/slideOutVertically
// slideInHorizontally/slideOutHorizontally
// expandIn/shrinkOut
// expandVertically/shrinkVertically
// scaleIn/scaleOut

AnimatedContent — transiciones entre contenido

Cuando el contenido cambia según un estado, AnimatedContent anima la transición entre los diferentes contenidos:

@Composable
fun ContadorAnimado(contador: Int) {
    AnimatedContent(
        targetState = contador,
        transitionSpec = {
            // Nuevo número entra desde abajo, viejo sale hacia arriba
            if (targetState > initialState) {
                slideInVertically { height -> height } + fadeIn() togetherWith
                slideOutVertically { height -> -height } + fadeOut()
            } else {
                slideInVertically { height -> -height } + fadeIn() togetherWith
                slideOutVertically { height -> height } + fadeOut()
            }.using(SizeTransform(clip = false))
        },
        label = "contador"
    ) { targetContador ->
        Text(
            text = "$targetContador",
            style = MaterialTheme.typography.displayLarge
        )
    }
}

// Caso real: animando entre estados de la pantalla
AnimatedContent(
    targetState = uiState,
    label = "pantallaState"
) { state ->
    when (state) {
        is UiState.Loading -> CircularProgressIndicator()
        is UiState.Success -> ListaProductos(state.productos)
        is UiState.Error -> ErrorView(state.mensaje)
        is UiState.Empty -> EmptyView()
    }
}

Crossfade — fade entre composables

Más simple que AnimatedContent cuando solo necesitás un fade:

Crossfade(
    targetState = isLoading,
    animationSpec = tween(500),
    label = "loadingCrossfade"
) { loading ->
    if (loading) {
        CircularProgressIndicator(modifier = Modifier.size(48.dp))
    } else {
        Icon(Icons.Default.CheckCircle, contentDescription = "Listo",
             tint = MaterialTheme.colorScheme.primary)
    }
}

animateContentSize — tamaño animado

Uno de los modifiers más útiles del día a día: anima automáticamente cuando el tamaño del composable cambia:

@Composable
fun TarjetaExpandible(titulo: String, detalle: String) {
    var expandida by remember { mutableStateOf(false) }

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .animateContentSize(  // una sola línea para animar el cambio de tamaño
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioMediumBouncy,
                    stiffness = Spring.StiffnessLow
                )
            )
            .clickable { expandida = !expandida }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(titulo, style = MaterialTheme.typography.titleMedium)
                Icon(
                    if (expandida) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
                    contentDescription = if (expandida) "Contraer" else "Expandir"
                )
            }
            if (expandida) {
                Spacer(modifier = Modifier.height(8.dp))
                Text(detalle, style = MaterialTheme.typography.bodyMedium)
            }
        }
    }
}

updateTransition — múltiples propiedades coordinadas

Cuando necesitás animar varias propiedades de forma coordinada en respuesta al mismo cambio de estado:

enum class EstadoBoton { Normal, Presionado, Cargando }

@Composable
fun BotonEstado(estado: EstadoBoton, onClick: () -> Unit) {
    val transicion = updateTransition(targetState = estado, label = "estadoBoton")

    val escala by transicion.animateFloat(label = "escala") { s ->
        when (s) {
            EstadoBoton.Normal -> 1f
            EstadoBoton.Presionado -> 0.95f
            EstadoBoton.Cargando -> 1f
        }
    }

    val alpha by transicion.animateFloat(label = "alpha") { s ->
        if (s == EstadoBoton.Cargando) 0.7f else 1f
    }

    val color by transicion.animateColor(label = "color") { s ->
        when (s) {
            EstadoBoton.Normal -> MaterialTheme.colorScheme.primary
            EstadoBoton.Presionado -> MaterialTheme.colorScheme.primaryContainer
            EstadoBoton.Cargando -> MaterialTheme.colorScheme.surfaceVariant
        }
    }

    Box(
        modifier = Modifier
            .scale(escala)
            .alpha(alpha)
            .background(color, RoundedCornerShape(50))
            .clickable(enabled = estado == EstadoBoton.Normal, onClick = onClick)
            .padding(horizontal = 24.dp, vertical = 12.dp)
    ) {
        if (estado == EstadoBoton.Cargando) {
            CircularProgressIndicator(modifier = Modifier.size(20.dp))
        } else {
            Text("Guardar", color = MaterialTheme.colorScheme.onPrimary)
        }
    }
}

Shared Element Transition — Compose 1.7+

Las transiciones de elementos compartidos entre pantallas (el famoso "hero animation") llegaron de forma estable en Compose 1.7. Permiten animar un elemento de una pantalla a la siguiente:

// En la pantalla de lista
@Composable
fun ListaProductos(onProductoClick: (Int) -> Unit) {
    SharedTransitionLayout {
        NavHost(...) {
            composable("lista") {
                LazyColumn {
                    items(productos) { producto ->
                        AnimatedVisibility(visible = true) {
                            Image(
                                painter = ...,
                                modifier = Modifier.sharedElement(
                                    rememberSharedContentState(key = "imagen-${producto.id}"),
                                    animatedVisibilityScope = this
                                )
                            )
                        }
                    }
                }
            }
            composable("detalle/{id}") {
                AnimatedVisibility(visible = true) {
                    Image(
                        painter = ...,
                        modifier = Modifier.sharedElement(
                            rememberSharedContentState(key = "imagen-$id"),
                            animatedVisibilityScope = this
                        )
                    )
                }
            }
        }
    }
}