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
)
)
}
}
}
}
}