Jetpack Compose: Animaciones Avanzadas Paso a Paso

Guía completa de animaciones avanzadas en Compose: transiciones, AnimatedVisibility, Animatable, gestos y rendimiento para interfaces Android fluidas.

Animaciones avanzadas de Jetpack Compose para desarrolladores Android

Las animaciones convierten una aplicación funcional en una experiencia memorable. Jetpack Compose ofrece una API declarativa potente que simplifica enormemente la creación de interfaces fluidas. Esta guía explora técnicas avanzadas para construir animaciones eficientes y mantenibles.

Requisitos previos

Este tutorial asume familiaridad con los fundamentos de Compose (recomposición, estado, modifiers). Para revisar lo básico, conviene consultar primero la guía de preguntas de entrevista sobre Jetpack Compose.

Fundamentos de la API de Animaciones en Compose

Compose proporciona varios niveles de API para las animaciones. La elección depende del nivel de control deseado y de la complejidad de la animación.

La API se divide en tres categorías principales: animaciones de alto nivel (AnimatedVisibility, AnimatedContent), animaciones basadas en estado (animate*AsState) y animaciones de bajo nivel (Animatable, Transition).

AnimationLevels.ktkotlin
// Overview of the three animation API levels
@Composable
fun AnimationApiOverview() {
    // High level: simple predefined animations
    AnimatedVisibility(visible = isVisible) {
        Text("Animated content")
    }

    // Intermediate level: state-driven animation
    val alpha by animateFloatAsState(
        targetValue = if (isSelected) 1f else 0.5f,
        label = "alpha"
    )

    // Low level: full control over animation
    val animatable = remember { Animatable(0f) }
    LaunchedEffect(targetValue) {
        animatable.animateTo(targetValue)
    }
}

Elegir el nivel de API adecuado resulta crucial para mantener un código legible conservando la flexibilidad necesaria.

AnimatedVisibility: Animaciones Elegantes de Entrada y Salida

AnimatedVisibility es el punto de entrada ideal para animar la aparición y desaparición de elementos. Esta API gestiona automáticamente la composición y descomposición del contenido.

Los parámetros enter y exit aceptan combinaciones de transiciones que definen el comportamiento de la animación. Estas transiciones pueden combinarse mediante el operador +.

AnimatedVisibilityExample.ktkotlin
@Composable
fun ExpandableCard(
    title: String,
    content: String,
    modifier: Modifier = Modifier
) {
    var isExpanded by remember { mutableStateOf(false) }

    Card(
        modifier = modifier.clickable { isExpanded = !isExpanded }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(text = title, style = MaterialTheme.typography.titleMedium)
                Icon(
                    imageVector = if (isExpanded) Icons.Default.ExpandLess
                                  else Icons.Default.ExpandMore,
                    contentDescription = null
                )
            }

            // Expansion animation with fade + slide
            AnimatedVisibility(
                visible = isExpanded,
                enter = fadeIn(animationSpec = tween(300)) +
                        expandVertically(animationSpec = tween(300)),
                exit = fadeOut(animationSpec = tween(200)) +
                       shrinkVertically(animationSpec = tween(200))
            ) {
                Text(
                    text = content,
                    modifier = Modifier.padding(top = 12.dp),
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

El contenido dentro de AnimatedVisibility solo se compone cuando visible = true, lo que optimiza el rendimiento en listas con muchos elementos expandibles.

Combinación de transiciones

Las transiciones disponibles incluyen fadeIn/fadeOut, slideIn/slideOut, expandIn/shrinkOut, scaleIn/scaleOut. Pueden combinarse libremente para crear efectos personalizados.

animate*AsState: Animaciones Basadas en Estado

La familia de funciones animate*AsState anima automáticamente los cambios de valores primitivos. Es el enfoque más idiomático para animaciones simples en Compose.

Cada tipo de dato tiene su función dedicada: animateColorAsState, animateFloatAsState, animateDpAsState, animateIntAsState, etc.

AnimateAsStateExample.ktkotlin
@Composable
fun InteractiveButton(
    isSelected: Boolean,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    // Animated background color
    val backgroundColor by animateColorAsState(
        targetValue = if (isSelected) MaterialTheme.colorScheme.primary
                      else MaterialTheme.colorScheme.surfaceVariant,
        animationSpec = tween(durationMillis = 250),
        label = "backgroundColor"
    )

    // Animated elevation
    val elevation by animateDpAsState(
        targetValue = if (isSelected) 8.dp else 2.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "elevation"
    )

    // Animated text size
    val textSize by animateFloatAsState(
        targetValue = if (isSelected) 18f else 14f,
        label = "textSize"
    )

    Surface(
        modifier = modifier.clickable(onClick = onClick),
        color = backgroundColor,
        shadowElevation = elevation,
        shape = RoundedCornerShape(12.dp)
    ) {
        Text(
            text = if (isSelected) "Selected" else "Select",
            modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
            fontSize = textSize.sp
        )
    }
}

El parámetro animationSpec controla el comportamiento temporal de la animación. Los dos specs más utilizados son tween (duración fija con easing) y spring (física realista con rebote).

Transition: Orquestar Múltiples Animaciones

Cuando varias propiedades deben animarse de forma coordinada, updateTransition proporciona un control centralizado. Esta API garantiza que todas las animaciones permanezcan sincronizadas.

El patrón consiste en definir un estado enum y luego crear animaciones para cada propiedad que dependa de ese estado.

TransitionExample.ktkotlin
// Card state: defines visual behavior
enum class CardState { Collapsed, Expanded, Selected }

@Composable
fun AnimatedStateCard(
    cardState: CardState,
    modifier: Modifier = Modifier
) {
    // Central transition coordinating all animations
    val transition = updateTransition(
        targetState = cardState,
        label = "cardTransition"
    )

    // Card height based on state
    val cardHeight by transition.animateDp(
        transitionSpec = { spring(stiffness = Spring.StiffnessLow) },
        label = "height"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 80.dp
            CardState.Expanded -> 200.dp
            CardState.Selected -> 160.dp
        }
    }

    // Border color based on state
    val borderColor by transition.animateColor(
        transitionSpec = { tween(300) },
        label = "borderColor"
    ) { state ->
        when (state) {
            CardState.Collapsed -> Color.Transparent
            CardState.Expanded -> MaterialTheme.colorScheme.outline
            CardState.Selected -> MaterialTheme.colorScheme.primary
        }
    }

    // Corner radius based on state
    val cornerRadius by transition.animateDp(
        label = "cornerRadius"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 8.dp
            CardState.Expanded -> 16.dp
            CardState.Selected -> 24.dp
        }
    }

    Card(
        modifier = modifier
            .height(cardHeight)
            .border(2.dp, borderColor, RoundedCornerShape(cornerRadius)),
        shape = RoundedCornerShape(cornerRadius)
    ) {
        // Card content
    }
}

¿Listo para aprobar tus entrevistas de Android?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Animatable: Control Total de la Animación

Animatable es la API de bajo nivel que ofrece un control programático completo. Este enfoque resulta necesario para animaciones interrumpibles, gestos o escenarios complejos.

A diferencia de animate*AsState, Animatable permite detener, invertir o modificar una animación en curso sin esperar a que finalice.

AnimatableExample.ktkotlin
@Composable
fun SwipeableCard(
    onDismiss: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    // Horizontal offset controlled by Animatable
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    // Swipe threshold to trigger dismissal
    val dismissThreshold = 300f

    Box(
        modifier = modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (abs(offsetX.value) > dismissThreshold) {
                                // Animate out then callback
                                val target = if (offsetX.value > 0) 1000f else -1000f
                                offsetX.animateTo(
                                    targetValue = target,
                                    animationSpec = tween(200)
                                )
                                onDismiss()
                            } else {
                                // Return to initial position with spring
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy
                                    )
                                )
                            }
                        }
                    },
                    onHorizontalDrag = { _, dragAmount ->
                        scope.launch {
                            // snapTo for instant finger tracking
                            offsetX.snapTo(offsetX.value + dragAmount)
                        }
                    }
                )
            }
    ) {
        content()
    }
}

Los métodos clave de Animatable son animateTo() (animar hacia un objetivo), snapTo() (cambio instantáneo) y stop() (interrupción).

AnimatedContent: Transiciones de Contenido

AnimatedContent anima las transiciones entre diferentes contenidos. Esta API resulta perfecta para cambios de estado que modifican completamente la interfaz mostrada.

La clave targetState determina cuándo debe ocurrir una transición. transitionSpec define cómo interactúan el contenido saliente y el entrante.

AnimatedContentExample.ktkotlin
@Composable
fun CounterWithAnimation(
    count: Int,
    modifier: Modifier = Modifier
) {
    AnimatedContent(
        targetState = count,
        modifier = modifier,
        transitionSpec = {
            // Determine animation direction
            val direction = if (targetState > initialState) {
                // New number enters from top
                slideInVertically { height -> -height } + fadeIn() togetherWith
                slideOutVertically { height -> height } + fadeOut()
            } else {
                // New number enters from bottom
                slideInVertically { height -> height } + fadeIn() togetherWith
                slideOutVertically { height -> -height } + fadeOut()
            }
            direction.using(SizeTransform(clip = false))
        },
        label = "counter"
    ) { targetCount ->
        Text(
            text = "$targetCount",
            style = MaterialTheme.typography.displayLarge,
            fontWeight = FontWeight.Bold
        )
    }
}
Rendimiento con AnimatedContent

El contenido dentro de AnimatedContent se recompone con cada cambio de targetState. Para contenido complejo, conviene memorizar los elementos costosos o usar una estrategia de claves apropiada.

Animaciones Infinitas con rememberInfiniteTransition

Para animaciones en bucle (indicadores de carga, efectos de pulso), rememberInfiniteTransition ofrece una API dedicada que no requiere gestión manual del ciclo.

InfiniteTransitionExample.ktkotlin
@Composable
fun PulsingDot(
    color: Color = MaterialTheme.colorScheme.primary,
    modifier: Modifier = Modifier
) {
    val infiniteTransition = rememberInfiniteTransition(label = "pulse")

    // Looping scale animation
    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )

    // Synchronized opacity animation
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.5f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "alpha"
    )

    Box(
        modifier = modifier
            .size(24.dp)
            .scale(scale)
            .alpha(alpha)
            .background(color = color, shape = CircleShape)
    )
}

Animaciones de Listas con LazyColumn

Las animaciones de elementos de lista requieren atención especial. El modifier animateItem() (antes animateItemPlacement) anima automáticamente las reordenaciones.

ListAnimationExample.ktkotlin
@Composable
fun AnimatedTaskList(
    tasks: List<Task>,
    onToggle: (Task) -> Unit,
    onDelete: (Task) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(
            items = tasks,
            key = { it.id }  // Stable key required for animateItem
        ) { task ->
            var isVisible by remember { mutableStateOf(true) }

            // Exit animation before deletion
            AnimatedVisibility(
                visible = isVisible,
                exit = shrinkVertically() + fadeOut()
            ) {
                TaskItem(
                    task = task,
                    onToggle = { onToggle(task) },
                    onDelete = {
                        isVisible = false
                        // Delay to let animation complete
                    },
                    modifier = Modifier.animateItem(
                        fadeInSpec = tween(300),
                        fadeOutSpec = tween(300),
                        placementSpec = spring(
                            dampingRatio = Spring.DampingRatioMediumBouncy,
                            stiffness = Spring.StiffnessLow
                        )
                    )
                )
            }

            // Trigger deletion after animation
            LaunchedEffect(isVisible) {
                if (!isVisible) {
                    delay(300)
                    onDelete(task)
                }
            }
        }
    }
}
Claves estables para animaciones de listas

Sin un parámetro key estable, animateItem no puede rastrear los elementos entre recomposiciones. Conviene usar un ID único en lugar del índice de la lista.

Animaciones con Canvas

Para efectos visuales personalizados, combinar Canvas con valores animados ofrece flexibilidad total.

CanvasAnimationExample.ktkotlin
@Composable
fun AnimatedProgressRing(
    progress: Float,  // 0f to 1f
    modifier: Modifier = Modifier
) {
    // Progress animation with spring for natural feel
    val animatedProgress by animateFloatAsState(
        targetValue = progress,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessVeryLow
        ),
        label = "progress"
    )

    // Continuous rotation animation
    val infiniteTransition = rememberInfiniteTransition(label = "rotation")
    val rotation by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(2000, easing = LinearEasing)
        ),
        label = "rotation"
    )

    val primaryColor = MaterialTheme.colorScheme.primary
    val trackColor = MaterialTheme.colorScheme.surfaceVariant

    Canvas(
        modifier = modifier
            .size(120.dp)
            .rotate(rotation)
    ) {
        val strokeWidth = 12.dp.toPx()
        val radius = (size.minDimension - strokeWidth) / 2

        // Background circle (track)
        drawCircle(
            color = trackColor,
            radius = radius,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
        )

        // Animated progress arc
        drawArc(
            color = primaryColor,
            startAngle = -90f,
            sweepAngle = animatedProgress * 360f,
            useCenter = false,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
            topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
            size = Size(radius * 2, radius * 2)
        )
    }
}

¿Listo para aprobar tus entrevistas de Android?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Optimización del Rendimiento de Animaciones

Las animaciones mal optimizadas pueden provocar jank y agotar la batería. Estas son las buenas prácticas para mantener 60 FPS.

La primera regla consiste en evitar las asignaciones durante la animación. Conviene usar graphicsLayer en lugar de modifiers que disparan recomposiciones.

PerformanceOptimization.ktkotlin
@Composable
fun OptimizedAnimatedCard(
    isExpanded: Boolean,
    modifier: Modifier = Modifier
) {
    val scale by animateFloatAsState(
        targetValue = if (isExpanded) 1.1f else 1f,
        label = "scale"
    )

    val alpha by animateFloatAsState(
        targetValue = if (isExpanded) 1f else 0.8f,
        label = "alpha"
    )

    Card(
        modifier = modifier
            // ✅ graphicsLayer: GPU modifications without recomposition
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                this.alpha = alpha
            }
        // ❌ Avoid: .scale(scale).alpha(alpha)
        // These modifiers trigger recompositions
    ) {
        Text("Card content")
    }
}

// Example with remembered lambda to avoid allocations
@Composable
fun OptimizedClickableItem(
    onClick: () -> Unit,
    content: @Composable () -> Unit
) {
    // ✅ Stable remembered lambda
    val interactionSource = remember { MutableInteractionSource() }

    Box(
        modifier = Modifier
            .clickable(
                interactionSource = interactionSource,
                indication = ripple(),
                onClick = onClick
            )
    ) {
        content()
    }
}

El segundo punto crítico atañe a las animaciones en listas. Conviene limitar el número de animaciones simultáneas y usar derivedStateOf para los cálculos derivados.

ListPerformance.ktkotlin
@Composable
fun PerformantAnimatedList(
    items: List<Item>,
    modifier: Modifier = Modifier
) {
    // Calculate once whether the list is empty
    val isEmpty by remember {
        derivedStateOf { items.isEmpty() }
    }

    LazyColumn(modifier = modifier) {
        items(
            items = items,
            key = { it.id }
        ) { item ->
            // Lightweight animation only on initial appearance
            var hasAppeared by remember { mutableStateOf(false) }

            LaunchedEffect(Unit) {
                hasAppeared = true
            }

            val alpha by animateFloatAsState(
                targetValue = if (hasAppeared) 1f else 0f,
                animationSpec = tween(200),
                label = "itemAlpha"
            )

            ItemCard(
                item = item,
                modifier = Modifier.graphicsLayer { this.alpha = alpha }
            )
        }
    }
}

Conclusión

Las animaciones en Jetpack Compose ofrecen un equilibrio entre facilidad de uso y control avanzado. Estos son los puntos clave a recordar:

  • ✅ Elegir el nivel de API adecuado según la complejidad (AnimatedVisibility → animate*AsState → Animatable)
  • ✅ Usar updateTransition para coordinar varias animaciones relacionadas
  • ✅ Preferir spring para animaciones naturales y tween para duraciones precisas
  • ✅ Proporcionar siempre un parámetro key estable en las animaciones de listas
  • ✅ Optimizar con graphicsLayer para evitar recomposiciones innecesarias
  • ✅ Probar las animaciones en dispositivos reales para validar el rendimiento

Dominar las animaciones de Compose distingue a las aplicaciones Android profesionales. Estas técnicas, combinadas con atención al rendimiento, permiten crear experiencias de usuario fluidas y atractivas.

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Etiquetas

#jetpack compose
#android
#animations
#kotlin
#ui

Compartir

Artículos relacionados