Skip to main content

Animations in KMP + CMP

Overview

Animations in Compose Multiplatform provide smooth, engaging user experiences across all target platforms. This guide covers everything from basic animations to advanced techniques, with special attention to platform-specific considerations and performance optimization.

Basic Animations

Simple State-Based Animations

@Composable
fun BasicAnimation() {
var isVisible by remember { mutableStateOf(false) }

val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(100.dp)
.alpha(alpha)
.background(Color.Blue)
) {
Text(
text = "Animated Box",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}

Spacer(modifier = Modifier.height(16.dp))

Button(onClick = { isVisible = !isVisible }) {
Text(if (isVisible) "Hide" else "Show")
}
}
}

Size and Position Animations

@Composable
fun SizeAndPositionAnimation() {
var isExpanded by remember { mutableStateOf(false) }

val size by animateDpAsState(
targetValue = if (isExpanded) 200.dp else 100.dp,
animationSpec = spring(
dampingRatio = 0.8f,
stiffness = 300f
)
)

val offset by animateDpAsState(
targetValue = if (isExpanded) 50.dp else 0.dp,
animationSpec = tween(durationMillis = 500)
)

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(size)
.offset(x = offset)
.background(
color = if (isExpanded) Color.Green else Color.Red,
shape = RoundedCornerShape(8.dp)
)
.clickable { isExpanded = !isExpanded }
) {
Text(
text = if (isExpanded) "Expanded" else "Collapsed",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}

Color and Background Animations

@Composable
fun ColorAnimation() {
var isActive by remember { mutableStateOf(false) }

val backgroundColor by animateColorAsState(
targetValue = if (isActive) Color.Green else Color.Red,
animationSpec = tween(durationMillis = 1000)
)

val textColor by animateColorAsState(
targetValue = if (isActive) Color.White else Color.Black,
animationSpec = tween(durationMillis = 1000)
)

Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.clickable { isActive = !isActive },
contentAlignment = Alignment.Center
) {
Text(
text = if (isActive) "Active" else "Inactive",
color = textColor,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
}
}

Animation Specifications

Tween Animation

@Composable
fun TweenAnimationExample() {
var isVisible by remember { mutableStateOf(false) }

val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(
durationMillis = 1000,
easing = EaseInOutCubic
)
)

Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(100.dp)
.alpha(alpha)
.background(Color.Blue)
)

Spacer(modifier = Modifier.height(16.dp))

Button(onClick = { isVisible = !isVisible }) {
Text("Toggle")
}
}
}

Spring Animation

@Composable
fun SpringAnimationExample() {
var isExpanded by remember { mutableStateOf(false) }

val scale by animateFloatAsState(
targetValue = if (isExpanded) 1.5f else 1f,
animationSpec = spring(
dampingRatio = 0.6f, // Lower = more bouncy
stiffness = 300f, // Higher = faster
visibilityThreshold = 0.01f
)
)

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(100.dp)
.scale(scale)
.background(Color.Orange)
.clickable { isExpanded = !isExpanded }
) {
Text(
text = "Spring",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}

Repeatable Animation

@Composable
fun RepeatableAnimationExample() {
val infiniteTransition = rememberInfiniteTransition()

val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)

val pulse by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
)
)

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer(
rotationZ = rotation,
scaleX = pulse,
scaleY = pulse
)
.background(Color.Purple)
) {
Text(
text = "Rotating",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}

Advanced Animation Techniques

Keyframe Animation

@Composable
fun KeyframeAnimationExample() {
var isAnimating by remember { mutableStateOf(false) }

val progress by animateFloatAsState(
targetValue = if (isAnimating) 1f else 0f,
animationSpec = tween(3000)
)

val scale by animateFloatAsState(
targetValue = if (isAnimating) 1f else 0f,
animationSpec = keyframes {
durationMillis = 3000
0f at 0 with LinearEasing
1.5f at 500 with EaseOutCubic
0.8f at 1000 with EaseInOutCubic
1.2f at 2000 with EaseInCubic
1f at 3000 with LinearEasing
}
)

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(100.dp)
.scale(scale)
.background(Color.Teal)
.clickable { isAnimating = !isAnimating }
) {
Text(
text = "Keyframe",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}

Staggered Animations

@Composable
fun StaggeredAnimationExample() {
var isVisible by remember { mutableStateOf(false) }
val items = remember { List(5) { "Item $it" } }

LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(items.size) { index ->
val delay = index * 100

val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(
durationMillis = 500,
delayMillis = delay
)
)

val offset by animateDpAsState(
targetValue = if (isVisible) 0.dp else 50.dp,
animationSpec = tween(
durationMillis = 500,
delayMillis = delay
)
)

Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.alpha(alpha)
.offset(y = offset),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Text(
text = items[index],
modifier = Modifier.padding(16.dp)
)
}
}
}

LaunchedEffect(Unit) {
delay(500)
isVisible = true
}
}

Complex Animation Sequences

@Composable
fun ComplexAnimationSequence() {
var animationState by remember { mutableStateOf(AnimationState.IDLE) }

val infiniteTransition = rememberInfiniteTransition()

val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)

val scale by animateFloatAsState(
targetValue = when (animationState) {
AnimationState.IDLE -> 1f
AnimationState.LOADING -> 1.2f
AnimationState.SUCCESS -> 1.5f
AnimationState.ERROR -> 0.8f
},
animationSpec = spring(dampingRatio = 0.6f)
)

val backgroundColor by animateColorAsState(
targetValue = when (animationState) {
AnimationState.IDLE -> Color.Gray
AnimationState.LOADING -> Color.Blue
AnimationState.SUCCESS -> Color.Green
AnimationState.ERROR -> Color.Red
},
animationSpec = tween(durationMillis = 500)
)

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.graphicsLayer(
rotationZ = if (animationState == AnimationState.LOADING) rotation else 0f,
scaleX = scale,
scaleY = scale
)
.background(backgroundColor, CircleShape)
.clickable {
animationState = when (animationState) {
AnimationState.IDLE -> AnimationState.LOADING
AnimationState.LOADING -> AnimationState.SUCCESS
AnimationState.SUCCESS -> AnimationState.ERROR
AnimationState.ERROR -> AnimationState.IDLE
}
},
contentAlignment = Alignment.Center
) {
Text(
text = animationState.name,
color = Color.White,
fontWeight = FontWeight.Bold
)
}

Spacer(modifier = Modifier.height(16.dp))

Text("Click to cycle through states")
}
}
}

enum class AnimationState {
IDLE, LOADING, SUCCESS, ERROR
}

Platform-Specific Animations

Platform Detection and Adaptation

@Composable
fun PlatformAwareAnimation() {
val platform = Platform.current
var isAnimating by remember { mutableStateOf(false) }

// Platform-specific animation specs
val animationSpec = when (platform) {
Platform.Android -> tween<Float>(
durationMillis = 300,
easing = FastOutSlowInEasing
)
Platform.IOS -> spring<Float>(
dampingRatio = 0.8f,
stiffness = 300f
)
Platform.Desktop -> tween<Float>(
durationMillis = 200,
easing = LinearEasing
)
Platform.Web -> tween<Float>(
durationMillis = 250,
easing = EaseInOutCubic
)
}

val scale by animateFloatAsState(
targetValue = if (isAnimating) 1.2f else 1f,
animationSpec = animationSpec
)

val rotation by animateFloatAsState(
targetValue = if (isAnimating) 180f else 0f,
animationSpec = animationSpec
)

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(100.dp)
.graphicsLayer(
scaleX = scale,
scaleY = scale,
rotationZ = rotation
)
.background(
when (platform) {
Platform.Android -> Color.Blue
Platform.IOS -> Color.Red
Platform.Desktop -> Color.Green
Platform.Web -> Color.Yellow
}
)
.clickable { isAnimating = !isAnimating }
) {
Text(
text = platform.name,
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}

Spacer(modifier = Modifier.height(16.dp))

Text("Platform: ${platform.name}")
Text("Animation: ${animationSpec::class.simpleName}")
}
}
}

Platform-Specific Easing

@Composable
fun PlatformSpecificEasing() {
val platform = Platform.current
var isExpanded by remember { mutableStateOf(false) }

val easing = when (platform) {
Platform.Android -> FastOutSlowInEasing
Platform.IOS -> EaseInOutCubic
Platform.Desktop -> LinearEasing
Platform.Web -> EaseInOutQuart
}

val size by animateDpAsState(
targetValue = if (isExpanded) 200.dp else 100.dp,
animationSpec = tween(
durationMillis = 500,
easing = easing
)
)

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(size)
.background(Color.Purple)
.clickable { isExpanded = !isExpanded }
) {
Text(
text = "Expand",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}

Spacer(modifier = Modifier.height(16.dp))

Text("Platform: ${platform.name}")
Text("Easing: ${easing::class.simpleName}")
}
}
}

Performance Optimization

Animation Performance Best Practices

@Composable
fun OptimizedAnimation() {
var isVisible by remember { mutableStateOf(false) }

// Use remember to avoid recreating animation specs
val animationSpec = remember {
tween<Float>(
durationMillis = 300,
easing = EaseInOutCubic
)
}

// Use derivedStateOf for computed values
val computedValue by remember(isVisible) {
derivedStateOf {
if (isVisible) 1f else 0f
}
}

val alpha by animateFloatAsState(
targetValue = computedValue,
animationSpec = animationSpec
)

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(100.dp)
.alpha(alpha)
.background(Color.Blue)
.clickable { isVisible = !isVisible }
) {
Text(
text = "Optimized",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}

Lazy Animation Loading

@Composable
fun LazyAnimationList() {
val items = remember { List(100) { "Item $it" } }

LazyColumn {
items(items) { item ->
var isVisible by remember { mutableStateOf(false) }

val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)

Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.alpha(alpha)
) {
Text(
text = item,
modifier = Modifier.padding(16.dp)
)
}

// Trigger animation when item becomes visible
LaunchedEffect(Unit) {
delay(100)
isVisible = true
}
}
}
}

Custom Animation Modifiers

Creating Custom Animation Modifiers

@Composable
fun CustomAnimationModifier() {
var isAnimating by remember { mutableStateOf(false) }

Box(
modifier = Modifier
.fillMaxSize()
.pulseAnimation(isAnimating)
.clickable { isAnimating = !isAnimating },
contentAlignment = Alignment.Center
) {
Text("Custom Animation")
}
}

fun Modifier.pulseAnimation(
isAnimating: Boolean
): Modifier = composed {
val infiniteTransition = rememberInfiniteTransition()
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = if (isAnimating) 1.1f else 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
)
)

this.graphicsLayer {
scaleX = scale
scaleY = scale
}
}

fun Modifier.slideInAnimation(
isVisible: Boolean,
direction: SlideDirection = SlideDirection.UP
): Modifier = composed {
val offset by animateDpAsState(
targetValue = if (isVisible) 0.dp else when (direction) {
SlideDirection.UP -> 100.dp
SlideDirection.DOWN -> -100.dp
SlideDirection.LEFT -> 100.dp
SlideDirection.RIGHT -> -100.dp
},
animationSpec = spring(dampingRatio = 0.8f)
)

this.offset(
x = if (direction == SlideDirection.LEFT || direction == SlideDirection.RIGHT) offset else 0.dp,
y = if (direction == SlideDirection.UP || direction == SlideDirection.DOWN) offset else 0.dp
)
}

enum class SlideDirection {
UP, DOWN, LEFT, RIGHT
}

Animation Testing

Testing Animations

@Test
fun testAnimationState() {
composeTestRule.setContent {
BasicAnimation()
}

// Test initial state
composeTestRule.onNodeWithText("Show").assertIsDisplayed()

// Trigger animation
composeTestRule.onNodeWithText("Show").performClick()

// Wait for animation to complete
composeTestRule.waitForIdle()

// Test final state
composeTestRule.onNodeWithText("Hide").assertIsDisplayed()
}

@Test
fun testAnimationValues() {
composeTestRule.setContent {
var isVisible by remember { mutableStateOf(false) }

val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 100)
)

Box(
modifier = Modifier
.size(100.dp)
.alpha(alpha)
.background(Color.Blue)
.clickable { isVisible = !isVisible }
)
}

// Test initial alpha value
// Note: Testing exact animation values is complex in Compose
// Focus on testing state changes and user interactions
}

Common Animation Patterns

Loading Animations

@Composable
fun LoadingAnimation() {
val infiniteTransition = rememberInfiniteTransition()

val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)

val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.2f,
animationSpec = infiniteRepeatable(
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
)

Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier
.size(50.dp)
.graphicsLayer(
rotationZ = rotation,
scaleX = scale,
scaleY = scale
)
)

Spacer(modifier = Modifier.height(16.dp))

Text("Loading...")
}
}
}

Transition Animations

@Composable
fun TransitionAnimation() {
var currentScreen by remember { mutableStateOf(Screen.HOME) }

AnimatedContent(
targetState = currentScreen,
transitionSpec = {
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 300)
) + fadeIn(animationSpec = tween(durationMillis = 300)) with
slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth },
animationSpec = tween(durationMillis = 300)
) + fadeOut(animationSpec = tween(durationMillis = 300))
}
) { screen ->
when (screen) {
Screen.HOME -> HomeScreen(
onNavigate = { currentScreen = Screen.DETAILS }
)
Screen.DETAILS -> DetailsScreen(
onNavigate = { currentScreen = Screen.HOME }
)
}
}
}

enum class Screen {
HOME, DETAILS
}

@Composable
fun HomeScreen(onNavigate: () -> Unit) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(onClick = onNavigate) {
Text("Go to Details")
}
}
}

@Composable
fun DetailsScreen(onNavigate: () -> Unit) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(onClick = onNavigate) {
Text("Go to Home")
}
}
}

Best Practices

1. Performance Considerations

// Good: Use remember for expensive computations
val animationSpec = remember {
tween<Float>(durationMillis = 300)
}

// Bad: Recreating animation spec every composition
val animationSpec = tween<Float>(durationMillis = 300)

2. Platform-Specific Optimization

// Adapt animations for different platforms
val animationSpec = when (Platform.current) {
Platform.Android -> tween<Float>(durationMillis = 300)
Platform.IOS -> spring<Float>(dampingRatio = 0.8f)
Platform.Desktop -> tween<Float>(durationMillis = 200)
Platform.Web -> tween<Float>(durationMillis = 250)
}

3. Accessibility

// Respect user preferences
val isAnimationEnabled = LocalAccessibilityService.current?.isAnimationEnabled ?: true

val animationSpec = if (isAnimationEnabled) {
tween<Float>(durationMillis = 300)
} else {
tween<Float>(durationMillis = 0)
}

4. Testing Animations

// Test state changes, not animation values
@Test
fun testAnimationState() {
composeTestRule.setContent {
AnimatedComponent()
}

// Test initial state
composeTestRule.onNodeWithText("Initial").assertIsDisplayed()

// Trigger animation
composeTestRule.onNodeWithText("Click me").performClick()

// Wait for animation to complete
composeTestRule.waitForIdle()

// Test final state
composeTestRule.onNodeWithText("Final").assertIsDisplayed()
}

This comprehensive animation guide covers everything from basic animations to advanced techniques, with special attention to platform-specific considerations, performance optimization, and best practices for KMP + CMP development.