updateTransition — múltiples propiedades sincronizadas
updateTransition anima múltiples valores simultáneamente en función de un estado. Todas las animaciones están sincronizadas y transicionan juntas cuando el estado cambia.
enum class EstadoBoton { Normal, Presionado, Deshabilitado }
@Composable
fun BotonAnimado(
estado: EstadoBoton,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
// Una sola Transition que controla todo
val transition = updateTransition(targetState = estado, label = "boton")
// Cada propiedad se anima con su propia spec pero sincronizadas
val escala by transition.animateFloat(
transitionSpec = { spring(dampingRatio = Spring.DampingRatioMediumBouncy) },
label = "escala"
) { estado ->
when (estado) {
EstadoBoton.Normal -> 1f
EstadoBoton.Presionado -> 0.92f
EstadoBoton.Deshabilitado -> 1f
}
}
val alpha by transition.animateFloat(
transitionSpec = { tween(200) },
label = "alpha"
) { estado ->
if (estado == EstadoBoton.Deshabilitado) 0.4f else 1f
}
val colorFondo by transition.animateColor(
transitionSpec = { tween(150) },
label = "color"
) { estado ->
when (estado) {
EstadoBoton.Normal -> Color.Blue
EstadoBoton.Presionado -> Color.DarkGray
EstadoBoton.Deshabilitado -> Color.Gray
}
}
Box(
modifier = Modifier
.scale(escala)
.alpha(alpha)
.background(colorFondo, RoundedCornerShape(8.dp))
.clickable(enabled = estado != EstadoBoton.Deshabilitado) { onClick() }
.padding(16.dp)
) {
content()
}
}
AnimatedContent — transiciones entre contenidos
// AnimatedContent anima la transición cuando el "target state" cambia
@Composable
fun ContadorAnimado(contador: Int) {
AnimatedContent(
targetState = contador,
transitionSpec = {
// Si el número sube: entra desde abajo, sale hacia arriba
// Si el número baja: entra desde arriba, sale hacia abajo
if (targetState > initialState) {
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
} else {
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
}.using(SizeTransform(clip = false))
},
label = "contador"
) { valor ->
Text(
text = "$valor",
style = MaterialTheme.typography.displayLarge
)
}
}
// AnimatedContent para estados de carga
@Composable
fun ContenidoConEstado(uiState: UiState) {
AnimatedContent(
targetState = uiState,
transitionSpec = {
fadeIn(tween(300)) togetherWith fadeOut(tween(300))
},
label = "ui-state"
) { estado ->
when (estado) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Success -> ListaResultados(estado.items)
is UiState.Error -> ErrorView(estado.mensaje)
is UiState.Empty -> EmptyView()
}
}
}
AnimatedVisibility con enter/exit custom
// AnimatedVisibility con transiciones personalizadas
@Composable
fun PanelDeslizante(visible: Boolean, content: @Composable () -> Unit) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(
initialOffsetX = { -it }, // entra desde la izquierda
animationSpec = spring(stiffness = Spring.StiffnessMedium)
) + fadeIn(),
exit = slideOutHorizontally(
targetOffsetX = { -it }, // sale hacia la izquierda
animationSpec = tween(200)
) + fadeOut()
) {
content()
}
}
// Animación de expansión desde el centro
@Composable
fun Expandible(visible: Boolean, content: @Composable () -> Unit) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(
expandFrom = Alignment.Top,
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy)
) + fadeIn(),
exit = shrinkVertically(
shrinkTowards = Alignment.Top,
animationSpec = tween(200)
) + fadeOut()
) {
content()
}
}
// AnimatedVisibility anidado — para animar hijos de forma independiente
@Composable
fun TarjetaExpandible(expandida: Boolean) {
AnimatedVisibility(visible = expandida) {
Column {
Text("Siempre visible cuando la card está expandida")
// Este hijo puede tener su propia animación
AnimatedVisibility(
visible = expandida,
enter = fadeIn(tween(600, delayMillis = 200)) // delay
) {
Text("Aparece con delay después de la card")
}
}
}
}
Animatable — control total de animaciones
Animatable es la API de más bajo nivel. Permite lanzar, cancelar y encadenar animaciones imperativamente desde coroutines:
// Shake animation — sacudir un elemento cuando hay un error
@Composable
fun CampoConShake(error: Boolean, content: @Composable () -> Unit) {
val offsetX = remember { Animatable(0f) }
LaunchedEffect(error) {
if (error) {
// Secuencia de sacudidas
for (i in 0..3) {
launch { offsetX.animateTo(if (i % 2 == 0) 10f else -10f,
animationSpec = tween(50)) }
}
offsetX.animateTo(0f, animationSpec = tween(50))
}
}
Box(modifier = Modifier.offset(x = offsetX.value.dp)) {
content()
}
}
// Animación de "snap" con límites físicos
@Composable
fun DraggableConSnap() {
val offsetX = remember { Animatable(0f) }
val puntoSnap = 200.dp
Box(
modifier = Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
// Actualizar posición durante el drag (sin animación)
runBlocking { offsetX.snapTo(offsetX.value + delta) }
},
onDragStopped = { velocidad ->
// Al soltar: snap al punto más cercano con animación spring
val destino = if (offsetX.value > puntoSnap.value / 2)
puntoSnap.value else 0f
offsetX.animateTo(
targetValue = destino,
animationSpec = spring(stiffness = Spring.StiffnessMedium),
initialVelocity = velocidad
)
}
).offset { IntOffset(offsetX.value.roundToInt(), 0) }
) {
Surface(shape = CircleShape, modifier = Modifier.size(50.dp)) {}
}
}
Gestos con animación
// Card que se puede deslizar para eliminar (swipe to dismiss)
@Composable
fun SwipeToDismissCard(
onDismiss: () -> Unit,
content: @Composable () -> Unit
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onDismiss()
true
} else false
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
val color by animateColorAsState(
targetValue = when (dismissState.currentValue) {
SwipeToDismissBoxValue.EndToStart -> Color.Red
else -> Color.Transparent
},
label = "dismiss-bg"
)
Box(
modifier = Modifier.fillMaxSize().background(color),
contentAlignment = Alignment.CenterEnd
) {
Icon(Icons.Default.Delete, "Eliminar",
tint = Color.White,
modifier = Modifier.padding(16.dp))
}
}
) {
content()
}
}
Shared element transition (Compose 1.7+)
// Transición de elemento compartido entre dos composables
@Composable
fun ListaADetalle() {
var seleccionado by remember { mutableStateOf<Item?>(null) }
SharedTransitionLayout {
AnimatedContent(targetState = seleccionado) { item ->
if (item == null) {
// Lista
LazyColumn {
items(items) { producto ->
Row(modifier = Modifier.clickable { seleccionado = producto }) {
Image(
painter = rememberAsyncImagePainter(producto.imageUrl),
contentDescription = null,
modifier = Modifier
.size(60.dp)
.sharedElement( // marca el elemento como compartido
state = rememberSharedContentState("imagen-${producto.id}"),
animatedVisibilityScope = this@AnimatedContent
)
)
Text(producto.nombre)
}
}
}
} else {
// Detalle
Column(modifier = Modifier.clickable { seleccionado = null }) {
Image(
painter = rememberAsyncImagePainter(item.imageUrl),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.sharedElement( // mismo key que en la lista
state = rememberSharedContentState("imagen-${item.id}"),
animatedVisibilityScope = this@AnimatedContent
)
)
Text(item.nombre, style = MaterialTheme.typography.headlineMedium)
}
}
}
}
}