Animation as a UX Multiplier
Micro-Interactions with animate*AsState
@Composable
fun LikeButton(isLiked: Boolean, onToggle: () -> Unit) {
// Animate multiple properties simultaneously
val scale by animateFloatAsState(
targetValue = if (isLiked) 1.0f else 0.85f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "likeScale"
)
val tint by animateColorAsState(
targetValue = if (isLiked) Color(0xFFE53935) else Color(0xFF9E9E9E),
animationSpec = tween(durationMillis = 300),
label = "likeTint"
)
val rotation by animateFloatAsState(
targetValue = if (isLiked) 0f else -30f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioHighBouncy
),
label = "likeRotation"
)
IconButton(onClick = onToggle) {
Icon(
imageVector = if (isLiked) Icons.Filled.Favorite
else Icons.Outlined.FavoriteBorder,
contentDescription = if (isLiked) "Unlike" else "Like",
tint = tint,
modifier = Modifier
.scale(scale)
.rotate(rotation)
)
}
}
// Animated counter that rolls digits up/down
@Composable
fun AnimatedCounter(count: Int) {
val animatedCount by animateIntAsState(
targetValue = count,
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
label = "counter"
)
Text(
text = "$animatedCount",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
}AnimatedVisibility: Enter and Exit Choreography
@Composable
fun NotificationBanner(
message: String,
isVisible: Boolean,
onDismiss: () -> Unit
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(
initialOffsetY = { -it }, // Slide in from top
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy
)
) + fadeIn(animationSpec = tween(300)),
exit = slideOutVertically(
targetOffsetY = { -it },
animationSpec = tween(200)
) + fadeOut(animationSpec = tween(150))
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(message, modifier = Modifier.weight(1f))
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Dismiss")
}
}
}
}
}
// Staggered list animation
@Composable
fun StaggeredAnimatedList(items: List<Product>) {
LazyColumn {
itemsIndexed(items) { index, product ->
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(index * 50L) // 50ms stagger per item
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(300)) +
slideInHorizontally(
initialOffsetX = { it / 3 },
animationSpec = tween(
durationMillis = 400,
easing = FastOutSlowInEasing
)
)
) {
ProductCard(product)
}
}
}
}Shared Element Transitions
@Composable
fun AppNavHost(navController: NavHostController) {
SharedTransitionLayout {
NavHost(navController, startDestination = "list") {
composable("list") {
ProductListScreen(
onProductClick = { product ->
navController.navigate("detail/${product.id}")
},
animatedVisibilityScope = this
)
}
composable("detail/{productId}") { backStackEntry ->
val productId = backStackEntry.arguments
?.getString("productId") ?: return@composable
ProductDetailScreen(
productId = productId,
animatedVisibilityScope = this,
onBack = { navController.popBackStack() }
)
}
}
}
}
@Composable
fun SharedTransitionScope.ProductCard(
product: Product,
onClick: () -> Unit,
animatedVisibilityScope: AnimatedVisibilityScope
) {
Card(
modifier = Modifier
.clickable(onClick = onClick)
.sharedElement(
state = rememberSharedContentState(
key = "product-${product.id}"
),
animatedVisibilityScope = animatedVisibilityScope
)
) {
AsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.sharedElement(
state = rememberSharedContentState(
key = "image-${product.id}"
),
animatedVisibilityScope = animatedVisibilityScope
)
)
Text(
text = product.name,
modifier = Modifier
.padding(16.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(
key = "title-${product.id}"
),
animatedVisibilityScope = animatedVisibilityScope
)
)
}
}Gesture-Driven Animation with Animatable
@Composable
fun SwipeToDismissCard(
content: @Composable () -> Unit,
onDismiss: () -> Unit
) {
val offsetX = remember { Animatable(0f) }
val dismissThreshold = 300f
Box(
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (abs(offsetX.value) > dismissThreshold) {
// Fling off screen
CoroutineScope(Dispatchers.Main).launch {
offsetX.animateTo(
targetValue = if (offsetX.value > 0)
1000f else -1000f,
animationSpec = tween(200)
)
onDismiss()
}
} else {
// Snap back
CoroutineScope(Dispatchers.Main).launch {
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
)
}
}
},
onHorizontalDrag = { _, dragAmount ->
CoroutineScope(Dispatchers.Main).launch {
offsetX.snapTo(offsetX.value + dragAmount)
}
}
)
}
.graphicsLayer {
// Fade and rotate as card is dragged
alpha = 1f - (abs(offsetX.value) / 1000f)
.coerceIn(0f, 1f)
rotationZ = (offsetX.value / 50f)
.coerceIn(-15f, 15f)
}
) {
content()
}
}Performance: Keeping Animations at 60fps
Key Takeaways
- 1Apps with thoughtful motion design score 23% higher on perceived quality -- animation is information, not decoration.
- 2Use animate*AsState for property changes, AnimatedVisibility for enter/exit, Animatable for gesture-driven motion.
- 3Shared element transitions require SharedTransitionLayout wrapping NavHost and sharedElement modifiers on matching elements.
- 4Physics-based springs (DampingRatioMediumBouncy, StiffnessLow) feel more natural than linear or easing-based specs.
- 5Always use Modifier.graphicsLayer for transform animations to avoid recomposition and stay at 60fps.
- 6Label every animation for meaningful names in Android Studio's Animation Inspector.
Frequently Asked
How do I avoid animation performance issues?
Avoid animating large composition trees. Use graphicsLayer for complex animations. Profile with Layout Inspector. Use LaunchedEffect for animation coordination.
What's the best animation spec?
Spring for natural, responsive motion. Tween for precise, choreographed sequences. Use springDefaults for common patterns. Customize stiffness and damping for brand feel.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.