Remember the first time you saw a slick animation in an app and thought, “Wow, that’s cool”? That moment of delight is what we’re after when creating animations in our apps. With Compose Multiplatform, we can now bring those magical moments to users across Android, iOS, desktop, and web—all from a single codebase.
I’ve spent the last two years building cross-platform apps with Compose Multiplatform, and animations have consistently been the element that transforms good apps into great ones. They’re not just eye candy; they guide users, provide feedback, and make interactions feel natural and responsive.
Let’s dive into how you can create advanced animations in Compose Multiplatform that will make your users say “wow” regardless of which platform they’re on.
Getting Started with Compose Multiplatform Animations
Before we jump into the fancy stuff, let’s make sure we’re on the same page with the basics. Compose Multiplatform uses the same animation system as Jetpack Compose, with a few platform-specific considerations.
First, add the necessary dependencies to your build.gradle.kts
file:
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("org.jetbrains.compose.animation:animation:1.5.0")
}
}
}
}
Code language: JavaScript (javascript)
Now, let’s look at the three main types of animations in Compose Multiplatform:
- State-based animations: Animate changes between different states
- Content-based animations: Animate the appearance and disappearance of content
- Low-level animations: Custom animations using the Animation API
State-Based Animations: The Building Blocks
State-based animations are the bread and butter of Compose animations. They’re easy to implement and cover most use cases.
Here’s a simple example of a button that grows when pressed:
@Composable
fun AnimatedButton(onClick: () -> Unit) {
var pressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (pressed) 1.2f else 1f,
label = "button scale"
)
Box(
modifier = Modifier
.scale(scale)
.background(Color.Blue, RoundedCornerShape(8.dp))
.clickable {
pressed = !pressed
onClick()
}
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text("Click Me", color = Color.White)
}
}
Code language: JavaScript (javascript)
This works across all platforms without any platform-specific code. The button smoothly scales up when pressed and back down when released.
But let’s kick it up a notch with multiple animated properties:
@Composable
fun FancyButton(onClick: () -> Unit) {
var pressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (pressed) 1.2f else 1f,
label = "button scale"
)
val color by animateColorAsState(
targetValue = if (pressed) Color.Purple else Color.Blue,
label = "button color"
)
val cornerRadius by animateDpAsState(
targetValue = if (pressed) 16.dp else 8.dp,
label = "corner radius"
)
Box(
modifier = Modifier
.scale(scale)
.background(color, RoundedCornerShape(cornerRadius))
.clickable {
pressed = !pressed
onClick()
}
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text("Fancy Button", color = Color.White)
}
}
Code language: JavaScript (javascript)
Now our button scales, changes color, AND adjusts its corner radius when pressed. That’s three animations running simultaneously with just a few lines of code!
Animating Lists and Collections
One of the most common animation needs is animating items in a list. Compose Multiplatform makes this surprisingly easy with AnimatedVisibility
and animateItemPlacement()
.
Here’s how to create a list with animated item additions and removals:
@Composable
fun AnimatedShoppingList() {
var items by remember { mutableStateOf(listOf("Apples", "Bananas", "Milk")) }
var newItem by remember { mutableStateOf("") }
Column {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = newItem,
onValueChange = { newItem = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Add item") }
)
Spacer(Modifier.width(8.dp))
Button(onClick = {
if (newItem.isNotEmpty()) {
items = items + newItem
newItem = ""
}
}) {
Text("Add")
}
}
LazyColumn {
items(items, key = { it }) { item ->
AnimatedVisibility(
visible = true,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.animateItemPlacement()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(item)
IconButton(onClick = { items = items - item }) {
Text("🗑️")
}
}
}
}
}
}
}
Code language: PHP (php)
When you add or remove items, they’ll smoothly animate in and out of the list. The animateItemPlacement()
modifier ensures that when items change position, they slide into place rather than jumping.
Custom Transitions Between Screens
Navigation animations can significantly improve the user experience. Let’s create a custom transition between two screens:
enum class Screen { List, Detail }
@Composable
fun NavigationExample() {
var currentScreen by remember { mutableStateOf(Screen.List) }
var selectedItem by remember { mutableStateOf<String?>(null) }
AnimatedContent(
targetState = currentScreen,
transitionSpec = {
when {
targetState == Screen.Detail && initialState == Screen.List ->
slideInHorizontally { width -> width } with
slideOutHorizontally { width -> -width }
else ->
slideInHorizontally { width -> -width } with
slideOutHorizontally { width -> width }
}
}
) { screen ->
when (screen) {
Screen.List -> {
ItemList(
onItemClick = { item ->
selectedItem = item
currentScreen = Screen.Detail
}
)
}
Screen.Detail -> {
ItemDetail(
item = selectedItem ?: "",
onBackClick = {
currentScreen = Screen.List
}
)
}
}
}
}
@Composable
fun ItemList(onItemClick: (String) -> Unit) {
// List implementation
}
@Composable
fun ItemDetail(item: String, onBackClick: () -> Unit) {
// Detail implementation
}
Code language: PHP (php)
This creates a slide animation when navigating between screens, similar to what you’d see in native apps. The direction of the slide depends on whether you’re going forward to the detail screen or back to the list.
Platform-Specific Considerations
While most animations work identically across platforms, there are some platform-specific considerations to keep in mind:
Platform | Consideration | Solution |
---|---|---|
iOS | Some animations may feel too “Android-like” | Adjust timing and easing to match iOS conventions |
Web | Performance can be an issue with complex animations | Simplify animations or use LaunchedEffect for JS-specific optimizations |
Desktop | Mouse interactions differ from touch | Add hover animations for better desktop UX |
For iOS-specific animations, you might want to adjust the animation specs:
val duration = if (Platform.isIOS) 350 else 300
val easing = if (Platform.isIOS) FastOutSlowInEasing else LinearOutSlowInEasing
val animSpec = tween<Float>(
durationMillis = duration,
easing = easing
)
val scale by animateFloatAsState(
targetValue = if (pressed) 1.2f else 1f,
animationSpec = animSpec,
label = "button scale"
)
Code language: JavaScript (javascript)
Creating a Lottie-Like Animation System
For really complex animations, you might want something similar to Lottie. While there’s no official Lottie support for Compose Multiplatform yet, we can create our own animation system using Canvas
and Animatable
:
@Composable
fun ParticleExplosion(modifier: Modifier = Modifier) {
val particles = remember { List(100) { Particle() } }
val animatedProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatedProgress.animateTo(
targetValue = 1f,
animationSpec = tween(2000, easing = LinearEasing)
)
}
Canvas(modifier = modifier.size(200.dp)) {
val progress = animatedProgress.value
particles.forEach { particle ->
val distance = particle.maxDistance * progress
val x = size.width / 2 + cos(particle.angle) * distance
val y = size.height / 2 + sin(particle.angle) * distance
val alpha = 1f - progress
drawCircle(
color = particle.color.copy(alpha = alpha),
radius = particle.size * (1f - progress * 0.5f),
center = Offset(x, y)
)
}
}
}
class Particle {
val angle = Random.nextFloat() * 2 * PI.toFloat()
val maxDistance = Random.nextFloat() * 500f + 50f
val size = Random.nextFloat() * 10f + 5f
val color = Color(
red = Random.nextFloat(),
green = Random.nextFloat(),
blue = Random.nextFloat(),
alpha = 1f
)
}
Code language: PHP (php)
This creates a particle explosion effect where particles fly outward from the center and fade away. You can customize this to create all sorts of complex animations.
Practical Example: Pull-to-Refresh Animation
Let’s create a practical example that you might use in a real app: a custom pull-to-refresh animation:
@Composable
fun CustomPullToRefresh(
refreshing: Boolean,
onRefresh: () -> Unit,
content: @Composable () -> Unit
) {
val refreshState = rememberPullRefreshState(
refreshing = refreshing,
onRefresh = onRefresh
)
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(refreshState)
) {
content()
PullRefreshIndicator(
refreshing = refreshing,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary,
scale = true
)
}
}
@Composable
fun NewsScreen() {
var refreshing by remember { mutableStateOf(false) }
var items by remember { mutableStateOf(List(20) { "News Item ${it + 1}" }) }
LaunchedEffect(refreshing) {
if (refreshing) {
delay(2000) // Simulate network request
items = List(20) { "Fresh News ${it + 1}" }
refreshing = false
}
}
CustomPullToRefresh(
refreshing = refreshing,
onRefresh = { refreshing = true }
) {
LazyColumn {
items(items) { item ->
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
Divider()
}
}
}
}
Code language: PHP (php)
This creates a custom pull-to-refresh experience that works on both Android and iOS. The animation is smooth and provides good feedback to the user about what’s happening.
Performance Tips for Smooth Animations
Animations can be resource-intensive, especially on lower-end devices. Here are some tips to keep your animations running smoothly:
- Animate only what’s necessary: Don’t animate properties that don’t need to change
- Use
remember
andkey
: Avoid recreating animations on every recomposition - Simplify during animation: Temporarily reduce complexity during animations
- Test on real devices: Emulators can be misleading for performance testing
Here’s an example of simplifying content during animation:
@Composable
fun OptimizedCardExpansion() {
var expanded by remember { mutableStateOf(false) }
val transition = updateTransition(expanded, label = "card expansion")
val height by transition.animateDp(label = "height") { isExpanded ->
if (isExpanded) 300.dp else 100.dp
}
val isAnimating = transition.isRunning
Card(
modifier = Modifier
.fillMaxWidth()
.height(height)
.clickable { expanded = !expanded },
elevation = CardDefaults.cardElevation(4.dp)
) {
Box(Modifier.fillMaxSize()) {
if (isAnimating) {
// Show simplified content during animation
Text(
"Loading...",
modifier = Modifier.align(Alignment.Center)
)
} else {
// Show full content when not animating
Column(Modifier.padding(16.dp)) {
Text("Card Title", style = MaterialTheme.typography.headlineSmall)
if (expanded) {
Spacer(Modifier.height(8.dp))
Text("This is the detailed content that appears when the card is expanded. It can contain complex layouts and even images.")
// Complex content here
}
}
}
}
}
}
Code language: JavaScript (javascript)
This approach shows simplified content during the animation and then switches to the full content once the animation completes, ensuring smooth performance even with complex layouts.
Wrapping Up
Animations in Compose Multiplatform are powerful tools that can take your app from functional to fantastic. They guide users, provide feedback, and add that extra polish that makes apps feel professional and well-crafted.
We’ve covered state-based animations, content transitions, list animations, custom drawing animations, and practical examples like pull-to-refresh. With these techniques, you can create engaging, cross-platform experiences that delight users on any device.
Remember that the best animations are the ones users barely notice—they just make the app feel natural and responsive. So go ahead, add some animation magic to your Compose Multiplatform app, and watch as your users’ experience transforms from ordinary to extraordinary!
Happy animating!