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:
@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:
@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:
@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:
@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
:
@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
:
@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:
@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:
@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 manualAnimatable
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
@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:
@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!