Tools
Tools: Jetpack Compose Animations: 4 Techniques to Make Your App Feel Alive
2026-03-02
0 views
admin
Jetpack Compose Animations: 4 Techniques to Make Your App Feel Alive ## 1. animateFloatAsState: The Foundation of Smooth Value Changes ## Use Case: Animated Button Opacity on Tap ## Why It Works ## Customizing Animation Speed ## 2. AnimatedVisibility: Show/Hide with Polish ## Use Case: Animated Error Message ## Animation Combinations ## 3. animateContentSize: Smooth Layout Changes ## Use Case: Expanding Description Text ## The Magic ## 4. updateTransition + Crossfade: Complex State Animations ## Use Case: Loading State with Spinner Rotation ## Why updateTransition is Powerful ## Bonus: Crossfade for Elegant Content Switching ## Performance Tips ## Bringing It All Together ## Next Steps Animation is the heartbeat of modern Android apps. It transforms static UIs into fluid, responsive experiences that feel natural and delightful to users. Jetpack Compose, Google's modern declarative UI framework for Android, provides powerful built-in APIs to create smooth animations with just a few lines of code. In this guide, I'll walk you through four essential animation techniques in Compose that will elevate your app's feel from ordinary to exceptional. Each technique comes with practical code examples you can use immediately in your projects. animateFloatAsState is perhaps the most commonly used animation API in Compose. It smoothly animates a float value from its current state to a target value, perfect for opacity fades, scale changes, and rotations. You can control animation duration using animationSpec: While animateFloatAsState handles value changes, AnimatedVisibility takes composables in and out of the view hierarchy with elegant enter/exit animations. Compose lets you combine multiple animations: This creates a polished effect where the error message slides in, fades in, and expands all at once. When your composable's size changes due to content updates, animateContentSize smoothly animates the layout change instead of jumping instantly. When maxLines changes from 3 to MAX_VALUE, the column's height expands smoothly. No jarring jumps—just elegant growth. For more complex animations involving multiple properties changing together, updateTransition orchestrates all animations as a single logical unit. Crossfade animates the transition between two different composables by fading the old one out while the new one fades in. No complex enter/exit logic needed—Crossfade handles the visual elegance automatically. The most polished apps combine these techniques: Each technique solves a specific animation problem, and mastering all four will make your Compose apps feel genuinely delightful. All 8 templates use clean Compose UI ready for animations. https://myougatheax.gumroad.com Start by implementing animateFloatAsState in your next feature, then gradually explore the other techniques as your app's complexity grows. Happy animating! Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color @Composable
fun AnimatedOpacityButton() { var isPressed by remember { mutableStateOf(false) } // Animate opacity from 1.0 to 0.5 when pressed val alpha by animateFloatAsState( targetValue = if (isPressed) 0.5f else 1.0f, label = "button_alpha" ) Button( onClick = { isPressed = !isPressed }, modifier = Modifier.alpha(alpha) ) { Text("Tap me") }
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color @Composable
fun AnimatedOpacityButton() { var isPressed by remember { mutableStateOf(false) } // Animate opacity from 1.0 to 0.5 when pressed val alpha by animateFloatAsState( targetValue = if (isPressed) 0.5f else 1.0f, label = "button_alpha" ) Button( onClick = { isPressed = !isPressed }, modifier = Modifier.alpha(alpha) ) { Text("Tap me") }
} CODE_BLOCK:
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color @Composable
fun AnimatedOpacityButton() { var isPressed by remember { mutableStateOf(false) } // Animate opacity from 1.0 to 0.5 when pressed val alpha by animateFloatAsState( targetValue = if (isPressed) 0.5f else 1.0f, label = "button_alpha" ) Button( onClick = { isPressed = !isPressed }, modifier = Modifier.alpha(alpha) ) { Text("Tap me") }
} CODE_BLOCK:
val alpha by animateFloatAsState( targetValue = if (isPressed) 0.5f else 1.0f, animationSpec = tween(durationMillis = 500), // Slower fade label = "button_alpha"
) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
val alpha by animateFloatAsState( targetValue = if (isPressed) 0.5f else 1.0f, animationSpec = tween(durationMillis = 500), // Slower fade label = "button_alpha"
) CODE_BLOCK:
val alpha by animateFloatAsState( targetValue = if (isPressed) 0.5f else 1.0f, animationSpec = tween(durationMillis = 500), // Slower fade label = "button_alpha"
) CODE_BLOCK:
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp @Composable
fun LoginForm() { var showError by remember { mutableStateOf(false) } var email by remember { mutableStateOf("") } Column(modifier = Modifier.padding(16.dp)) { TextField( value = email, onValueChange = { email = it }, label = { Text("Email") } ) // Error message with slide + fade animation AnimatedVisibility( visible = showError, enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut() ) { Text( "Invalid email format", color = Color.Red, modifier = Modifier .background(Color(0xFFFFEBEE)) .padding(12.dp) ) } Button(onClick = { showError = email.isEmpty() || !email.contains("@") }) { Text("Sign In") } }
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp @Composable
fun LoginForm() { var showError by remember { mutableStateOf(false) } var email by remember { mutableStateOf("") } Column(modifier = Modifier.padding(16.dp)) { TextField( value = email, onValueChange = { email = it }, label = { Text("Email") } ) // Error message with slide + fade animation AnimatedVisibility( visible = showError, enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut() ) { Text( "Invalid email format", color = Color.Red, modifier = Modifier .background(Color(0xFFFFEBEE)) .padding(12.dp) ) } Button(onClick = { showError = email.isEmpty() || !email.contains("@") }) { Text("Sign In") } }
} CODE_BLOCK:
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp @Composable
fun LoginForm() { var showError by remember { mutableStateOf(false) } var email by remember { mutableStateOf("") } Column(modifier = Modifier.padding(16.dp)) { TextField( value = email, onValueChange = { email = it }, label = { Text("Email") } ) // Error message with slide + fade animation AnimatedVisibility( visible = showError, enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut() ) { Text( "Invalid email format", color = Color.Red, modifier = Modifier .background(Color(0xFFFFEBEE)) .padding(12.dp) ) } Button(onClick = { showError = email.isEmpty() || !email.contains("@") }) { Text("Sign In") } }
} CODE_BLOCK:
enter = slideInVertically() + fadeIn() + expandVertically()
exit = slideOutVertically() + fadeOut() + shrinkVertically() Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
enter = slideInVertically() + fadeIn() + expandVertically()
exit = slideOutVertically() + fadeOut() + shrinkVertically() CODE_BLOCK:
enter = slideInVertically() + fadeIn() + expandVertically()
exit = slideOutVertically() + fadeOut() + shrinkVertically() CODE_BLOCK:
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp @Composable
fun ExpandableText(title: String, fullText: String) { var isExpanded by remember { mutableStateOf(false) } Column( modifier = Modifier .clickable { isExpanded = !isExpanded } .animateContentSize( animationSpec = tween(durationMillis = 400) ) .padding(16.dp) ) { Text( text = title, style = MaterialTheme.typography.headlineSmall ) Text( text = fullText, maxLines = if (isExpanded) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis ) }
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp @Composable
fun ExpandableText(title: String, fullText: String) { var isExpanded by remember { mutableStateOf(false) } Column( modifier = Modifier .clickable { isExpanded = !isExpanded } .animateContentSize( animationSpec = tween(durationMillis = 400) ) .padding(16.dp) ) { Text( text = title, style = MaterialTheme.typography.headlineSmall ) Text( text = fullText, maxLines = if (isExpanded) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis ) }
} CODE_BLOCK:
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp @Composable
fun ExpandableText(title: String, fullText: String) { var isExpanded by remember { mutableStateOf(false) } Column( modifier = Modifier .clickable { isExpanded = !isExpanded } .animateContentSize( animationSpec = tween(durationMillis = 400) ) .padding(16.dp) ) { Text( text = title, style = MaterialTheme.typography.headlineSmall ) Text( text = fullText, maxLines = if (isExpanded) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis ) }
} COMMAND_BLOCK:
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp enum class LoadingState { Idle, Loading, Success, Error
} @Composable
fun LoadingIndicator(state: LoadingState) { val transition = updateTransition(targetState = state, label = "loading_transition") val rotation by transition.animateFloat(label = "rotation") { if (it == LoadingState.Loading) 360f else 0f } Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Crossfade(targetState = state, label = "content_crossfade") { loadingState -> when (loadingState) { LoadingState.Idle -> { Text("Ready to load") } LoadingState.Loading -> { CircularProgressIndicator( modifier = Modifier .size(40.dp) .rotate(rotation) ) } LoadingState.Success -> { Text("Loaded!", color = Color.Green) } LoadingState.Error -> { Text("Error occurred", color = Color.Red) } } } }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp enum class LoadingState { Idle, Loading, Success, Error
} @Composable
fun LoadingIndicator(state: LoadingState) { val transition = updateTransition(targetState = state, label = "loading_transition") val rotation by transition.animateFloat(label = "rotation") { if (it == LoadingState.Loading) 360f else 0f } Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Crossfade(targetState = state, label = "content_crossfade") { loadingState -> when (loadingState) { LoadingState.Idle -> { Text("Ready to load") } LoadingState.Loading -> { CircularProgressIndicator( modifier = Modifier .size(40.dp) .rotate(rotation) ) } LoadingState.Success -> { Text("Loaded!", color = Color.Green) } LoadingState.Error -> { Text("Error occurred", color = Color.Red) } } } }
} COMMAND_BLOCK:
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp enum class LoadingState { Idle, Loading, Success, Error
} @Composable
fun LoadingIndicator(state: LoadingState) { val transition = updateTransition(targetState = state, label = "loading_transition") val rotation by transition.animateFloat(label = "rotation") { if (it == LoadingState.Loading) 360f else 0f } Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Crossfade(targetState = state, label = "content_crossfade") { loadingState -> when (loadingState) { LoadingState.Idle -> { Text("Ready to load") } LoadingState.Loading -> { CircularProgressIndicator( modifier = Modifier .size(40.dp) .rotate(rotation) ) } LoadingState.Success -> { Text("Loaded!", color = Color.Green) } LoadingState.Error -> { Text("Error occurred", color = Color.Red) } } } }
} COMMAND_BLOCK:
@Composable
fun TabContent(selectedTab: Int) { Crossfade(targetState = selectedTab, label = "tab_switch") { tab -> when (tab) { 0 -> HomeScreen() 1 -> ProfileScreen() else -> SettingsScreen() } }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
@Composable
fun TabContent(selectedTab: Int) { Crossfade(targetState = selectedTab, label = "tab_switch") { tab -> when (tab) { 0 -> HomeScreen() 1 -> ProfileScreen() else -> SettingsScreen() } }
} COMMAND_BLOCK:
@Composable
fun TabContent(selectedTab: Int) { Crossfade(targetState = selectedTab, label = "tab_switch") { tab -> when (tab) { 0 -> HomeScreen() 1 -> ProfileScreen() else -> SettingsScreen() } }
} - Smooth Transitions: The animation runs over 300ms by default, creating a natural fade effect
- State-Driven: The animation automatically triggers whenever the state condition changes
- Performance: Uses low-level graphics APIs for buttery-smooth 60fps animations - Orchestration: All animations tied to one state change happen in sync
- Consistency: Prevents animations from fighting each other
- Performance: Recomposes only when animation values change, not on every frame - Use label parameter: Helps with debugging in Android Studio's animation inspector
- Prefer animateFloatAsState for simple values: It's optimized and lightweight
- Avoid animating during initial composition: State must be stable before animation starts
- Test on real devices: Emulators don't accurately represent animation performance - animateFloatAsState for quick state feedback (button presses, toggles)
- AnimatedVisibility for entering/exiting screens and dialogs
- animateContentSize for organic content growth (expanding lists, text)
- updateTransition for complex, synchronized state changes
how-totutorialguidedev.toaillmswitch