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