Back to Blog

Advanced Animation Techniques in Jetpack Compose

Animations breathe life into your Android applications, creating engaging user experiences that feel natural and responsive. Jetpack Compose provides a powerful and intuitive animation system that makes creating complex animations easier than ever before. In this comprehensive guide, we'll explore advanced animation techniques and best practices.

Animation Fundamentals

Compose animations are built around the concept of animating between states. The framework provides several APIs for different use cases, from simple value animations to complex transitions.

Basic Value Animations

The foundation of Compose animations is animateFloatAsState and similar functions for different data types:

BasicAnimation.kt
@Composable
fun PulsatingButton() {
    var isPressed by remember { mutableStateOf(false) }
    
    val scale by animateFloatAsState(
        targetValue = if (isPressed) 0.95f else 1f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "button_scale"
    )
    
    val alpha by animateFloatAsState(
        targetValue = if (isPressed) 0.8f else 1f,
        animationSpec = tween(durationMillis = 150),
        label = "button_alpha"
    )
    
    Button(
        onClick = { /* Handle click */ },
        modifier = Modifier
            .scale(scale)
            .alpha(alpha)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        isPressed = true
                        tryAwaitRelease()
                        isPressed = false
                    }
                )
            }
    ) {
        Text("Press Me")
    }
}

Color and Size Animations

Animate colors and sizes using dedicated animation functions:

ColorSizeAnimation.kt
@Composable
fun AnimatedCard(isSelected: Boolean) {
    val backgroundColor by animateColorAsState(
        targetValue = if (isSelected) 
            MaterialTheme.colorScheme.primaryContainer 
        else 
            MaterialTheme.colorScheme.surface,
        animationSpec = tween(durationMillis = 300),
        label = "card_background"
    )
    
    val elevation by animateDpAsState(
        targetValue = if (isSelected) 8.dp else 2.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy
        ),
        label = "card_elevation"
    )
    
    val cornerRadius by animateDpAsState(
        targetValue = if (isSelected) 16.dp else 8.dp,
        animationSpec = tween(durationMillis = 300),
        label = "card_corner"
    )
    
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        colors = CardDefaults.cardColors(
            containerColor = backgroundColor
        ),
        elevation = CardDefaults.cardElevation(
            defaultElevation = elevation
        ),
        shape = RoundedCornerShape(cornerRadius)
    ) {
        Text(
            text = "Animated Card",
            modifier = Modifier.padding(16.dp)
        )
    }
}

Advanced Transition Animations

AnimatedVisibility

AnimatedVisibility provides smooth enter and exit animations for composables:

AnimatedVisibility.kt
@Composable
fun ExpandableCard() {
    var isExpanded by remember { mutableStateOf(false) }
    
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
            .clickable { isExpanded = !isExpanded }
    ) {
        Column {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = "Expandable Content",
                    style = MaterialTheme.typography.titleMedium
                )
                
                Icon(
                    imageVector = if (isExpanded) 
                        Icons.Default.ExpandLess 
                    else 
                        Icons.Default.ExpandMore,
                    contentDescription = if (isExpanded) "Collapse" else "Expand",
                    modifier = Modifier.rotate(
                        animateFloatAsState(
                            targetValue = if (isExpanded) 180f else 0f,
                            label = "arrow_rotation"
                        ).value
                    )
                )
            }
            
            AnimatedVisibility(
                visible = isExpanded,
                enter = slideInVertically(
                    animationSpec = tween(300)
                ) + expandVertically(
                    animationSpec = tween(300)
                ) + fadeIn(
                    animationSpec = tween(300)
                ),
                exit = slideOutVertically(
                    animationSpec = tween(300)
                ) + shrinkVertically(
                    animationSpec = tween(300)
                ) + fadeOut(
                    animationSpec = tween(300)
                )
            ) {
                Text(
                    text = "This is the expanded content that appears with a smooth animation. " +
                           "You can add any composable content here.",
                    modifier = Modifier.padding(16.dp),
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
}

Crossfade Transitions

Use Crossfade for smooth transitions between different content:

CrossfadeExample.kt
@Composable
fun TabContent() {
    var selectedTab by remember { mutableStateOf(0) }
    
    Column {
        TabRow(selectedTabIndex = selectedTab) {
            Tab(
                selected = selectedTab == 0,
                onClick = { selectedTab = 0 },
                text = { Text("Home") }
            )
            Tab(
                selected = selectedTab == 1,
                onClick = { selectedTab = 1 },
                text = { Text("Profile") }
            )
            Tab(
                selected = selectedTab == 2,
                onClick = { selectedTab = 2 },
                text = { Text("Settings") }
            )
        }
        
        Crossfade(
            targetState = selectedTab,
            animationSpec = tween(300),
            label = "tab_content"
        ) { tab ->
            when (tab) {
                0 -> HomeContent()
                1 -> ProfileContent()
                2 -> SettingsContent()
            }
        }
    }
}

@Composable
fun HomeContent() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.primaryContainer),
        contentAlignment = Alignment.Center
    ) {
        Text("Home Content")
    }
}

Complex Animation Sequences

Animatable for Custom Animations

For more control over animations, use Animatable:

CustomAnimation.kt
@Composable
fun BouncingBall() {
    val animatable = remember { Animatable(0f) }
    
    LaunchedEffect(Unit) {
        while (true) {
            // Bounce down
            animatable.animateTo(
                targetValue = 200f,
                animationSpec = tween(
                    durationMillis = 800,
                    easing = FastOutSlowInEasing
                )
            )
            
            // Bounce back up
            animatable.animateTo(
                targetValue = 0f,
                animationSpec = tween(
                    durationMillis = 600,
                    easing = FastOutLinearInEasing
                )
            )
            
            // Small pause at the top
            delay(200)
        }
    }
    
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(250.dp)
    ) {
        Box(
            modifier = Modifier
                .size(50.dp)
                .offset(y = animatable.value.dp)
                .background(
                    color = MaterialTheme.colorScheme.primary,
                    shape = CircleShape
                )
                .align(Alignment.TopCenter)
        )
    }
}

Infinite Animations

Create continuous animations using rememberInfiniteTransition:

InfiniteAnimation.kt
@Composable
fun LoadingSpinner() {
    val infiniteTransition = rememberInfiniteTransition(label = "spinner")
    
    val rotation by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000,
                easing = LinearEasing
            )
        ),
        label = "rotation"
    )
    
    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 800,
                easing = FastOutSlowInEasing
            ),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )
    
    Box(
        modifier = Modifier
            .size(60.dp)
            .scale(scale)
            .rotate(rotation)
            .background(
                brush = Brush.sweepGradient(
                    colors = listOf(
                        Color.Transparent,
                        MaterialTheme.colorScheme.primary,
                        MaterialTheme.colorScheme.secondary
                    )
                ),
                shape = CircleShape
            )
    )
}

Gesture-Based Animations

Swipe to Dismiss

Combine gestures with animations for interactive experiences:

SwipeToDismiss.kt
@Composable
fun SwipeableCard(
    onDismiss: () -> Unit,
    content: @Composable () -> Unit
) {
    val offsetX = remember { Animatable(0f) }
    val alpha = remember { Animatable(1f) }
    
    Box(
        modifier = Modifier
            .offset(x = offsetX.value.dp)
            .alpha(alpha.value)
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        val velocity = offsetX.velocity
                        val offset = offsetX.value
                        
                        if (abs(offset) > size.width * 0.3f || abs(velocity) > 1000f) {
                            // Dismiss the card
                            launch {
                                val targetOffset = if (offset > 0) size.width.toFloat() else -size.width.toFloat()
                                launch {
                                    offsetX.animateTo(
                                        targetValue = targetOffset,
                                        animationSpec = tween(300)
                                    )
                                }
                                launch {
                                    alpha.animateTo(
                                        targetValue = 0f,
                                        animationSpec = tween(300)
                                    )
                                }
                                onDismiss()
                            }
                        } else {
                            // Snap back to center
                            launch {
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy
                                    )
                                )
                            }
                        }
                    }
                ) { _, dragAmount ->
                    launch {
                        offsetX.snapTo(offsetX.value + dragAmount)
                        // Update alpha based on offset
                        alpha.snapTo(1f - abs(offsetX.value) / size.width * 0.5f)
                    }
                }
            }
    ) {
        content()
    }
}

Pull to Refresh

Create a custom pull-to-refresh animation:

PullToRefresh.kt
@Composable
fun PullToRefreshList(
    items: List,
    isRefreshing: Boolean,
    onRefresh: () -> Unit
) {
    val pullToRefreshState = rememberPullToRefreshState()
    
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pullToRefresh(
                state = pullToRefreshState,
                isRefreshing = isRefreshing,
                onRefresh = onRefresh
            )
    ) {
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(items) { item ->
                Card(
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text(
                        text = item,
                        modifier = Modifier.padding(16.dp)
                    )
                }
            }
        }
        
        PullToRefreshContainer(
            state = pullToRefreshState,
            modifier = Modifier.align(Alignment.TopCenter)
        )
    }
}

Performance Optimization

Animation Performance Tips

Follow these best practices to ensure smooth animations:

  • Use remember for animation objects to avoid recreation
  • Prefer animateFloatAsState over manual Animatable when possible
  • Use appropriate animation specs (spring for interactive, tween for choreographed)
  • Avoid animating expensive operations in the composition phase
  • Use graphicsLayer for transform animations to avoid layout passes
OptimizedAnimation.kt
@Composable
fun OptimizedAnimatedBox(isExpanded: Boolean) {
    val scale by animateFloatAsState(
        targetValue = if (isExpanded) 1.2f else 1f,
        label = "scale"
    )
    
    val rotation by animateFloatAsState(
        targetValue = if (isExpanded) 45f else 0f,
        label = "rotation"
    )
    
    Box(
        modifier = Modifier
            .size(100.dp)
            // Use graphicsLayer for transform animations
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                rotationZ = rotation
            }
            .background(
                color = MaterialTheme.colorScheme.primary,
                shape = RoundedCornerShape(8.dp)
            )
    )
}

Animation Testing

Testing animations ensures they work correctly across different scenarios:

AnimationTest.kt
@Test
fun testButtonAnimation() {
    composeTestRule.setContent {
        var isPressed by remember { mutableStateOf(false) }
        
        Button(
            onClick = { isPressed = !isPressed },
            modifier = Modifier.testTag("animated_button")
        ) {
            Text("Click me")
        }
    }
    
    // Verify initial state
    composeTestRule
        .onNodeWithTag("animated_button")
        .assertExists()
    
    // Trigger animation
    composeTestRule
        .onNodeWithTag("animated_button")
        .performClick()
    
    // Advance clock to let animation complete
    composeTestRule.mainClock.advanceTimeBy(1000L)
    
    // Verify animation completed
    composeTestRule.waitForIdle()
}

Conclusion

Mastering animations in Jetpack Compose opens up endless possibilities for creating engaging and delightful user experiences. From simple state-driven animations to complex gesture-based interactions, Compose provides the tools you need to bring your UI to life.

Remember to start simple with basic animations, then gradually incorporate more advanced techniques as needed. Always consider performance implications and test your animations across different devices and scenarios.

The key to great animations is subtlety and purpose – they should enhance the user experience without being distracting. Use the techniques and patterns covered in this guide to create animations that feel natural and add real value to your applications.

Happy animating with Compose!