Android

Jetpack Compose Animations

Jetpack Compose Animations Made Simple: A Complete Beginner-to-Pro Guide

Let’s be real — animations can feel intimidating at first. But once you understand how Jetpack Compose thinks about them, everything clicks into place.

Unlike the old View-based animation system (which involved XML files, ObjectAnimator, and a lot of boilerplate), Jetpack Compose animations are built right into the UI framework — they’re reactive, composable, and surprisingly intuitive.

Whether you’re building a simple button press effect or a complex multi-step transition, Compose gives you the right tool for every situation. This guide walks through every layer of the animation API — from the simplest one-liners to advanced choreography — with real, working code and honest explanations of why things work the way they do.

Prerequisites: Basic Kotlin knowledge and familiarity with Compose fundamentals (composables, state) is helpful but not mandatory. Every concept is explained from the ground up.

Why Jetpack Compose Animations Are a Game-Changer

Before Compose, adding animations to Android apps meant wrestling with AnimatorSet, writing XML animation resources, dealing with lifecycle issues, and hoping nothing crashed on API 21. It was doable, but painful.

Jetpack Compose animations completely rethink this. Because Compose is a declarative UI framework, animations are just another form of state change. You describe what the UI should look like, and Compose figures out how to smoothly get there. That mental model shift makes everything easier.

Here’s a quick side-by-side comparison:

How Compose Thinks About Animation

Think of it like this:

When state changes → Compose automatically animates the UI between old and new values.

Example:

Button size = small → user clicksstate changes → button grows smoothly

You don’t manually trigger animation frames. Compose does it for you.

Understanding this mental model will make everything else click. In Compose, your UI is a function of state:

UI = f(state) — When state changes, Compose re-renders the UI. Animations are just a smooth interpolation between two states over time. You don’t “run” an animation — you change state and tell Compose how to animate the transition.

The animation system in Compose has three layers, and it’s worth knowing which layer you’re working at:

Layer 1 — High-level APIs: AnimatedVisibility, AnimatedContent, Crossfade. These handle the most common cases with zero configuration needed.

Layer 2 — Value-based APIs: animate*AsState, updateTransition, InfiniteTransition. These animate specific values (Float, Dp, Color, etc.) that you then apply in your composables.

Layer 3 — Low-level APIs: Animatable, coroutine-based. Full manual control for complex sequencing, interruptions, or physics-based motion.

The golden rule: start at the highest level that solves your problem. Only go deeper when you genuinely need more control. Most production animations live happily in layers 1 and 2.

The Core Building Blocks

Before writing any animations, it helps to understand the main APIs you’ll actually use:

1. animate*AsState

For simple, one-off animations tied to a single value.

2. updateTransition

For animating multiple values based on the same state.

3. AnimatedVisibility

For showing and hiding composables with animation.

4. AnimatedContent

For switching between UI states.

5. rememberInfiniteTransition

For looping animations.

You don’t need all of them at once. Most real screens use 1–2 of these consistently.

animate*AsState — Your First Animation

This is the most common animation API you’ll use in everyday Jetpack Compose development. The idea is beautifully simple: instead of setting a value directly, you animate towards that value. Compose smoothly interpolates between the old value and the new one whenever the target changes.

There are ready-made variants for the most common types: animateDpAsState, animateFloatAsState, animateColorAsState, animateSizeAsState, animateIntAsState, animateOffsetAsState, and more.

1. animateDpAsState — Smooth Size & Spacing Changes

Let’s say you have a card that expands when selected. Here’s how that looks with a Jetpack Compose animation:

Kotlin
@Composable
fun ExpandableCard() {
    // Track whether the card is selected
    var isExpanded by remember { mutableStateOf(false) }

    // Animate the height based on expanded state
    val cardHeight by animateDpAsState(
        targetValue = if (isExpanded) 200.dp else 80.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        label = "cardHeight"   // helps the debugger identify this animation
    )
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(cardHeight)    // use the animated value here
            .clickable { isExpanded = !isExpanded },
        elevation = CardDefaults.cardElevation(8.dp)
    ) {
        Box(
            modifier = Modifier.padding(16.dp)
        ) {
            Text("Tap me to expand!")
        }
    }
}

Here, animateDpAsState watches isExpanded. Every time you tap the card, isExpanded flips, which gives animateDpAsState a new targetValue. Compose then smoothly interpolates the height from its current value to the new target. You didn’t write a single frame of the animation — Compose handled it all.

2. animateColorAsState — Smooth Color Transitions

Color animations are incredibly satisfying. Here’s a toggle button that shifts between two colours smoothly:

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

    // Colour interpolates between green and grey
    val buttonColor by animateColorAsState(
        targetValue = if (isActive) Color(0xFF4ADE80) else Color(0xFF334155),
        animationSpec = tween(durationMillis = 400),
        label = "buttonColor"
    )

    // Text colour also animates
    val textColor by animateColorAsState(
        targetValue = if (isActive) Color.Black else Color.White,
        animationSpec = tween(durationMillis = 400),
        label = "textColor"
    )

    Button(
        onClick = { isActive = !isActive },
        colors = ButtonDefaults.buttonColors(containerColor = buttonColor)
    ) {
        Text(
            text = if (isActive) "Active ✓" else "Inactive",
            color = textColor,
            fontWeight = FontWeight.Bold
        )
    }
}

3. animateFloatAsState — Alpha, Rotation, Scale

animateFloatAsState is incredibly versatile because so many visual properties are floats — opacity, rotation, scale, and more. Here’s a smooth fade-and-scale animation for an icon:

Kotlin
@Composable
fun FadeScaleIcon(isVisible: Boolean) {
    val alpha by animateFloatAsState(
        targetValue = if (isVisible) 1f else 0f,
        animationSpec = tween(durationMillis = 300),
        label = "alpha"
    )

    val scale by animateFloatAsState(
        targetValue = if (isVisible) 1f else 0.5f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "scale"
    )
   
    Icon(
        imageVector = Icons.Default.Notifications,
        contentDescription = "Notification",
        modifier = Modifier
            .graphicsLayer {
                this.alpha = alpha        // apply animated alpha
                this.scaleX = scale       // apply animated scale
                this.scaleY = scale
            },
        tint = Color(0xFF4ADE80)
    )
}

Always prefer graphicsLayer { } over Modifier.alpha() or Modifier.scale() for animated properties. graphicsLayer runs on the RenderThread and doesn’t trigger recomposition for each frame, making it significantly more performant.

AnimatedVisibility — Show & Hide with Style

AnimatedVisibility is probably the most commonly used high-level Jetpack Compose animation API. It wraps a composable and animates its entrance and exit automatically. You just toggle a boolean.

Kotlin
@Composable
fun ErrorBanner(
    hasError: Boolean,
    message: String,
    onDismiss: (() -> Unit)? = null
) {
    AnimatedVisibility(
        visible = hasError,
        enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
        exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
                .clickable(enabled = onDismiss != null) {
                    onDismiss?.invoke()
                },
            colors = CardDefaults.cardColors(
                containerColor = Color(0xFFEF4444)
            )
        ) {
            Text(
                text = message,
                color = Color.White,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ErrorBannerPreview() {
    var hasError by remember { mutableStateOf(false) }

    MaterialTheme {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {

                Button(onClick = { hasError = !hasError }) {
                    Text("Show Error")
                }

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

                ErrorBanner(
                    hasError = hasError,
                    message = "Something went wrong!",
                    onDismiss = { hasError = false }
                )
            }
        }
    }
}

The real power of AnimatedVisibility is how you can combine enter/exit transitions using the + operator. Here are all the available transitions you can mix and match:

  • fadeIn / fadeOut
  • slideInHorizontally / slideOutHorizontally
  • slideInVertically / slideOutVertically
  • expandIn / shrinkOut
  • expandHorizontally / shrinkHorizontally
  • expandVertically / shrinkVertically
  • scaleIn / scaleOut

Using AnimatedVisibility Inside a List

One important nuance: when using AnimatedVisibility inside a LazyColumn, always provide stable key values so Compose can track item identity across recompositions:

Kotlin
@Composable
fun AnimatedListInteractive() {
    // Original list (source of truth)
    val allItems = remember {
        listOf("Apple", "Banana", "Cherry", "Date", "Elderberry")
    }

    // Track removed items
    val removedItems = remember { mutableStateListOf<String>() }

    LazyColumn {
        items(
            items = allItems,
            key = { it }
        ) { item ->

            val isRemoved = item in removedItems

            AnimatedVisibility(
                visible = !isRemoved,
                enter = expandVertically() + fadeIn(),
                exit = shrinkVertically() + fadeOut(),
                modifier = Modifier.animateItem()
            ) {
                ListItem(
                    headlineContent = { Text(item) },
                    modifier = Modifier
                        .fillMaxWidth()
                        .clickable {
                            removedItems.add(item) // trigger animation
                        }
                )
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun AnimatedListPreview() {
    CenteredPreview {
        AnimatedListInteractive()
    }
}

Important: Without stable keys, Compose can’t track item identity across recompositions, and your exit animations will be silently skipped.

AnimatedContent — Swapping Composables Smoothly

AnimatedContent is like a supercharged version of AnimatedVisibility. Instead of showing or hiding content, it animates between different pieces of content as its target state changes. Think of it as an animated when expression.

A great example: a loading/content/error state machine where you want each state to visually transition into the next.

Kotlin
sealed class UiState {
    object Loading : UiState()
    data class Success(val data: String) : UiState()
    object Error : UiState()
}

@Composable
fun StatefulScreen(uiState: UiState) {
    AnimatedContent(
        targetState = uiState,
        transitionSpec = {
            // New content fades in + slides up
            // while old content fades out + slides down
            (fadeIn(animationSpec = tween(300)) +
             slideInVertically { it / 2 })
                .togetherWith(
                    fadeOut(animationSpec = tween(200)) +
                    slideOutVertically { -it / 2 }
                )
        },
        label = "stateTransition"
    ) { state ->
        when (state) {
            is UiState.Loading -> LoadingSpinner()
            is UiState.Success -> SuccessContent(state.data)
            is UiState.Error  -> ErrorMessage()
        }
    }
}

Key insight: Inside AnimatedContent‘s lambda, the state parameter is the target state being transitioned to. Both the entering and exiting composables exist simultaneously during the transition — that’s how the cross-fade and slide works.

Animating a Counter

A really satisfying use of AnimatedContent is an animated number counter. The number slides up when increasing and slides down when decreasing:

Kotlin
@Composable
fun AnimatedCounter(count: Int) {
    AnimatedContent(
        targetState = count,
        transitionSpec = {
            if (targetState > initialState) {
                // Counting up: slide in from bottom, slide out to top
                slideInVertically { it } + fadeIn() togetherWith
                slideOutVertically { -it } + fadeOut()
            } else {
                // Counting down: slide in from top, slide out to bottom
                slideInVertically { -it } + fadeIn() togetherWith
                slideOutVertically { it } + fadeOut()
            }
        },
        label = "counter"
    ) { targetCount ->
        Text(
            text = "$targetCount",
            style = MaterialTheme.typography.displayMedium,
            fontWeight = FontWeight.Bold
        )
    }
}

Crossfade — The Simplest Content Switch

When you just need to fade between two pieces of content (no sliding, no scaling), Crossfade is the right tool. It’s essentially a simplified AnimatedContent with a hardcoded fade transition — perfect for tab content swaps.

Kotlin
@Composable
fun TabScreen() {
    var selectedTab by remember { mutableStateOf(0) }

    Column {
        TabRow(selectedTabIndex = selectedTab) {
            Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
                Text("Home", modifier = Modifier.padding(16.dp))
            }
            Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
                Text("Profile", modifier = Modifier.padding(16.dp))
            }
        }
        Crossfade(
            targetState = selectedTab,
            animationSpec = tween(durationMillis = 350),
            label = "tabContent"
        ) { tab ->
            when (tab) {
                0 -> HomeContent()
                1 -> ProfileContent()
            }
        }
    }
}

updateTransition — Coordinating Multiple Animations

When you have multiple animated values that all change together based on the same state, updateTransition is the right tool. It creates a single transition object that you can attach multiple animated properties to — all synchronized, all driven by the same state.

Think of it as a conductor for your animation orchestra.

Kotlin
enum class FabState { Collapsed, Expanded }

@Composable
fun AnimatedFAB() {
    var fabState by remember { mutableStateOf(FabState.Collapsed) }
    // One transition drives all the properties below
    val transition = updateTransition(
        targetState = fabState,
        label = "fabTransition"
    )
    val fabSize by transition.animateDp(
        label = "fabSize",
        transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }
    ) { state ->
        if (state == FabState.Expanded) 180.dp else 56.dp
    }

    val cornerRadius by transition.animateDp(
        label = "cornerRadius"
    ) { state ->
        if (state == FabState.Expanded) 16.dp else 28.dp
    }

    val backgroundColor by transition.animateColor(
        label = "backgroundColor"
    ) { state ->
        if (state == FabState.Expanded)
            Color(0xFF4ADE80)
        else
            Color(0xFF6366F1)
    }

    val textAlpha by transition.animateFloat(
        label = "textAlpha",
        transitionSpec = { tween(durationMillis = 200) }
    ) { state ->
        if (state == FabState.Expanded) 1f else 0f
    }

    Surface(
        modifier = Modifier
            .width(fabSize)
            .height(56.dp)
            .clickable {
                fabState = if (fabState == FabState.Collapsed)
                    FabState.Expanded else FabState.Collapsed
            },
        shape = RoundedCornerShape(cornerRadius),
        color = backgroundColor,
        shadowElevation = 6.dp
    ) {
        Row(
            modifier = Modifier.padding(horizontal = 16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.Center
        ) {
            Icon(Icons.Default.Add, contentDescription = "Add")
            Text(
                text = "  Create New",
                modifier = Modifier.alpha(textAlpha)
            )
        }
    }
}

Why updateTransition over multiple animate*AsState? Because all child animations share the same progress. They all start and finish together, which means your animations are inherently synchronized. With separate animate*AsState calls, timing can drift if the state changes rapidly.

InfiniteTransition — Looping Animations Forever

InfiniteTransition is for animations that run continuously — loading spinners, pulsing indicators, shimmer effects, breathing animations. Once started, they loop until the composable leaves the composition.

Kotlin
@Composable
fun PulsingDot() {
    val infiniteTransition = rememberInfiniteTransition(label = "pulse")

    val scale by infiniteTransition.animateFloat(
        initialValue = 0.85f,
        targetValue = 1.15f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 700, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse   // ping-pong back and forth
        ),
        label = "dotScale"
    )

    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.4f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 700),
            repeatMode = RepeatMode.Reverse
        ),
        label = "dotAlpha"
    )

    Box(
        modifier = Modifier
            .size(20.dp)
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                this.alpha = alpha
            }
            .background(Color(0xFF4ADE80), shape = CircleShape)
    )
}

Building a Shimmer Loading Effect

Shimmer placeholders are a staple of modern app design. Here’s how to build one from scratch using InfiniteTransition and a gradient — as a reusable Modifier extension:

Kotlin
@Composable
fun Modifier.shimmerEffect(): Modifier {
    val transition = rememberInfiniteTransition(label = "shimmer")

    val offset by transition.animateFloat(
        initialValue = -300f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1200, easing = LinearEasing)
        ),
        label = "shimmerOffset"
    )

    return background(
        brush = Brush.linearGradient(
            colors = listOf(
                Color(0xFF1E293B),
                Color(0xFF334155),
                Color(0xFF1E293B)
            ),
            start = Offset(offset, 0f),
            end = Offset(offset + 300f, 0f)
        )
    )
}

// Usage: apply to any placeholder composable
@Composable
fun ShimmerCard() {
    Column(modifier = Modifier.padding(16.dp)) {

        Box(modifier = Modifier
            .fillMaxWidth().height(160.dp).clip(RoundedCornerShape(12.dp))
            .shimmerEffect())

        Spacer(Modifier.height(12.dp))

        Box(modifier = Modifier
            .fillMaxWidth(0.7f).height(16.dp).clip(RoundedCornerShape(4.dp))
            .shimmerEffect())

        Spacer(Modifier.height(8.dp))

        Box(modifier = Modifier
            .fillMaxWidth(0.5f).height(16.dp).clip(RoundedCornerShape(4.dp))
            .shimmerEffect())
    }
}

Animatable — Full Manual Control

Animatable is the lowest-level animation primitive in Compose. It’s coroutine-based, which means you control exactly when animations start, stop, or get interrupted. Use it when the high-level APIs don’t give you enough control — for example, when you need animations triggered by gestures, sequenced one after another, or interrupted mid-flight.

Kotlin
@Composable
fun ShakeOnErrorField(hasError: Boolean) {
    // Animatable holds the current value and lets us animate it imperatively
    val offsetX = remember { Animatable(0f) }

    // LaunchedEffect runs in a coroutine - perfect for Animatable
    LaunchedEffect(hasError) {
        if (hasError) {
            // Sequence of animations: shake left, right, left, right, settle
            repeat(4) {
                offsetX.animateTo(
                    targetValue = if (it % 2 == 0) 10f else -10f,
                    animationSpec = tween(durationMillis = 50)
                )
            }
            offsetX.animateTo(0f) // settle back to centre
        }
    }

    TextField(
        value = "",
        onValueChange = {},
        isError = hasError,
        modifier = Modifier
            .offset(x = offsetX.value.dp)
            .fillMaxWidth()
    )
}

Animatable for Gesture-Driven Motion

One of Animatable‘s superpowers is handling interruptions gracefully. If a new gesture starts while an animation is running, you can snapTo the current gesture position without a jarring jump:

Kotlin
@Composable
fun DraggableCard() {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    Card(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragStart = {
                        // Stop any running animation when user grabs the card
                        scope.launch { offsetX.stop() }
                    },
                    onDragEnd = {
                        scope.launch {
                            // Spring back to centre when finger lifts
                            offsetX.animateTo(
                                targetValue = 0f,
                                animationSpec = spring(
                                    dampingRatio = Spring.DampingRatioMediumBouncy
                                )
                            )
                        }
                    },
                    onHorizontalDrag = { _, dragAmount ->
                        scope.launch {
                            offsetX.snapTo(offsetX.value + dragAmount)
                        }
                    }
                )
            }
    ) {
        Text("Drag me!", modifier = Modifier.padding(24.dp))
    }
}

Springs, Tweens & Easing Curves Explained

Every Jetpack Compose animation needs an animationSpec — it defines how the animation moves from A to B. There are several types, and picking the right one makes a huge difference in how your UI feels.

spring() — Physics-Based & Interruptible

Springs are the default for most interactive animations because they feel natural and handle interruptions gracefully. A spring has two key parameters:

dampingRatio — Controls bounciness. 

1f = no bounce (critically damped). 

0.5f = bouncy. 

Compose provides presets: Spring.DampingRatioNoBouncy, Spring.DampingRatioLowBouncy, Spring.DampingRatioMediumBouncy, Spring.DampingRatioHighBouncy.

stiffness — Controls speed. 

High stiffness = snappy and fast. 

Low stiffness = slow and floaty. 

Presets: Spring.StiffnessHigh, Spring.StiffnessMedium, Spring.StiffnessLow, Spring.StiffnessVeryLow.

Kotlin
// Snappy, no bounce — good for UI chrome (drawers, panels)
spring(
    dampingRatio = Spring.DampingRatioNoBouncy,
    stiffness = Spring.StiffnessMedium
)

// Playful bounce - good for FABs, chips, selection indicators
spring(
    dampingRatio = Spring.DampingRatioMediumBouncy,
    stiffness = Spring.StiffnessMedium
)

// Slow, floaty - good for hero transitions, large-format elements
spring(
    dampingRatio = Spring.DampingRatioLowBouncy,
    stiffness = Spring.StiffnessVeryLow
)

tween() — Duration-Based with Easing

Use tween when you need precise control over timing — particularly for coordinated multi-step animations where things need to arrive at specific moments.

Kotlin
// Standard eased animation — most general-purpose use
tween(durationMillis = 300, easing = FastOutSlowInEasing)

// Linear - good for progress bars, shimmer effects
tween(durationMillis = 1200, easing = LinearEasing)

// Delayed start - for staggered entrance animations
tween(durationMillis = 400, delayMillis = 150, easing = EaseOutBack)

// Custom cubic bezier easing curve
tween(
    durationMillis = 500,
    easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
)

keyframes() — Frame-Precise Control

keyframes lets you define exactly what value the animation should hit at specific points in time. It’s like an animator’s timeline — perfect when you need a bouncy overshoot or a stutter effect.

Kotlin
val size by animateDpAsState(
    targetValue = targetSize,
    animationSpec = keyframes {
        durationMillis = 600
        40.dp at 0            // start at 40dp
        80.dp at 100          // shoot up to 80dp at 100ms
        60.dp at 300          // bounce back to 60dp at 300ms
        70.dp at 500          // settle towards 70dp
    },
    label = "bounceSize"
)

Quick Reference Table

Gesture-Driven Animations

The best mobile animations respond directly to touch. Gesture-driven Jetpack Compose animations feel alive because they track the user’s finger position — they don’t just trigger on events, they continuously follow input.

Here’s a swipe-to-dismiss card commonly seen in notification screens and task managers:

Kotlin
@Composable
fun SwipeToDeleteCard(onDismiss: () -> Unit) {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val density = LocalDensity.current

    // Threshold: 40% of screen width triggers dismiss
    val screenWidth = with(density) { LocalConfiguration.current.screenWidthDp.dp.toPx() }
    val threshold = screenWidth * 0.4f

    // Derive alpha from position for a natural fade-out as you swipe
    val alpha = (1f - (abs(offsetX.value) / threshold)).coerceIn(0f, 1f)

    Box(
        modifier = Modifier
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
            .alpha(alpha)
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (abs(offsetX.value) > threshold) {
                                // Fly off screen, then call onDismiss
                                offsetX.animateTo(
                                    targetValue = if (offsetX.value > 0) screenWidth else -screenWidth,
                                    animationSpec = tween(200)
                                )
                                onDismiss()
                            } else {
                                // Snap back if under threshold
                                offsetX.animateTo(0f, spring(Spring.DampingRatioMediumBouncy))
                            }
                        }
                    },
                    onHorizontalDrag = { _, dragAmount ->
                        scope.launch { offsetX.snapTo(offsetX.value + dragAmount) }
                    }
                )
            }
    ) {
        Card(modifier = Modifier.fillMaxWidth()) {
            Text("Swipe me to dismiss →", modifier = Modifier.padding(24.dp))
        }
    }
}

Shared Element Transitions (Compose 1.7+)

Shared element transitions are one of the most visually impressive patterns in mobile UI — a card expands into a detail screen, or an image flies from a list into a full-screen view. In the old View system, this was notoriously painful. In Compose 1.7+, it’s finally approachable.

Requires: androidx.compose.animation:animation:1.7.0+ and using SharedTransitionLayout with Navigation Compose or manual visibility management.

Kotlin
@Composable
fun SharedElementDemo() {
    var showDetail by remember { mutableStateOf(false) }

    SharedTransitionLayout {
        AnimatedContent(
            targetState = showDetail,
            label = "sharedElement"
        ) { isDetail ->
            if (!isDetail) {
                // List item card
                Card(
                    modifier = Modifier
                        .size(120.dp)
                        .clickable { showDetail = true },
                ) {
                    Image(
                        painter = painterResource(R.drawable.hero_image),
                        contentDescription = null,
                        modifier = Modifier
                            .sharedElement(
                                state = rememberSharedContentState(key = "hero_image"),
                                animatedVisibilityScope = this@AnimatedContent
                            )
                            .fillMaxSize(),
                        contentScale = ContentScale.Crop
                    )
                }
            } else {
                // Full-screen detail view
                Column(modifier = Modifier
                    .fillMaxSize()
                    .clickable { showDetail = false }
                ) {
                    Image(
                        painter = painterResource(R.drawable.hero_image),
                        contentDescription = null,
                        modifier = Modifier
                            .sharedElement(
                                state = rememberSharedContentState(key = "hero_image"),
                                animatedVisibilityScope = this@AnimatedContent
                            )
                            .fillMaxWidth()
                            .height(300.dp),
                        contentScale = ContentScale.Crop
                    )
                    Text("Detail content here", modifier = Modifier.padding(16.dp))
                }
            }
        }
    }
}

The key concept: Both composables reference the same key in rememberSharedContentState. Compose automatically detects this and morphs the element from its position/size in the source to its position/size in the destination. The element literally flies across the screen.

Performance Tips & Best Practices

Smooth animations are 60fps animations. Here’s how to make sure your Jetpack Compose animations never drop a frame.

Use graphicsLayer for Transform Animations

Always animate transforms (scale, rotation, alpha, translation) using graphicsLayer rather than layout modifiers. graphicsLayer runs on the RenderThread and doesn’t cause recomposition.

Kotlin
// Causes recomposition every frame — avoid for animated values
Modifier.scale(animatedScale)  // triggers layout pass

// Runs on RenderThread - no recomposition needed
Modifier.graphicsLayer {
    scaleX = animatedScale
    scaleY = animatedScale
}

Always Provide the label Parameter

The label parameter on every animation API might seem optional, but it makes the Animation Inspector in Android Studio actually usable. Always provide it — it takes two seconds and saves minutes of debugging.

Use animateItem() for LazyList Reordering (Compose 1.7+)

Kotlin
LazyColumn {
    items(items, key = { it.id }) { item ->
        ListItem(
            headlineContent = { Text(item.title) },
            modifier = Modifier.animateItem() // handles add/remove/reorder
        )
    }
}

Avoid Animating Layout Properties in Lists

Animating height or width inside a LazyColumn item forces a full list measurement pass each frame. Use graphicsLayer { scaleY = ... } as an approximation, or use AnimatedVisibility‘s built-in expandVertically/shrinkVertically which is optimised for this.

Respect Reduce Motion

Check LocalAccessibilityManager.current.isAnimationEnabled and respect the system’s “Reduce Motion” setting. Some users have vestibular disorders that make motion sickness a real issue.

Test on Real Devices

Emulators lie about performance. Always test animations on a mid-range physical device — if it’s smooth there, you’re good everywhere.

Real-World Animation Patterns You Can Steal

Bottom Sheet Peek Animation

Kotlin
@Composable
fun PeekBottomSheet(isExpanded: Boolean) {
    val sheetHeight by animateDpAsState(
        targetValue = if (isExpanded) 400.dp else 80.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioNoBouncy,
            stiffness = Spring.StiffnessMediumLow
        ),
        label = "sheetHeight"
    )

    val handleAlpha by animateFloatAsState(
        targetValue = if (isExpanded) 0f else 1f,
        label = "handleAlpha"
    )

    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .height(sheetHeight),
        shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
        shadowElevation = 8.dp
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Box(
                modifier = Modifier
                    .padding(top = 12.dp)
                    .size(width = 40.dp, height = 4.dp)
                    .background(Color.Gray.copy(alpha = handleAlpha), RoundedCornerShape(2.dp))
            )
        }
    }
}

Staggered List Entrance

A staggered entrance is when list items animate in one after another with a slight delay between each. It’s the difference between an “okay” app and a polished one:

Kotlin
@Composable
fun StaggeredItem(index: Int, content: @Composable () -> Unit) {
    var visible by remember { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        // Each item starts its animation 60ms after the previous
        delay(index * 60L)
        visible = true
    }

    AnimatedVisibility(
        visible = visible,
        enter = fadeIn(animationSpec = tween(300)) +
                slideInVertically(
                    animationSpec = spring(Spring.DampingRatioLowBouncy),
                    initialOffsetY = { it / 2 }
                )
    ) {
        content()
    }
}

// Usage
@Composable
fun AnimatedFeed(items: List<FeedItem>) {
    LazyColumn {
        itemsIndexed(items) { index, item ->
            StaggeredItem(index = index) {
                FeedCard(item)
            }
        }
    }
}

Animated Progress Button

A button that transforms into a loading indicator on click — combining multiple animations under one updateTransition:

Kotlin
enum class ButtonState { Idle, Loading, Success }

@Composable
fun AnimatedProgressButton(
    state: ButtonState,
    onClick: () -> Unit
) {
    val transition = updateTransition(state, label = "btnTransition")
    val width by transition.animateDp(label = "btnWidth") {
        when (it) {
            ButtonState.Idle    -> 200.dp
            ButtonState.Loading -> 56.dp   // collapses to a circle
            ButtonState.Success -> 200.dp
        }
    }

    val color by transition.animateColor(label = "btnColor") {
        when (it) {
            ButtonState.Idle    -> Color(0xFF6366F1)  // indigo
            ButtonState.Loading -> Color(0xFF475569)  // grey
            ButtonState.Success -> Color(0xFF4ADE80)  // green
        }
    }

    Surface(
        modifier = Modifier
            .width(width)
            .height(56.dp)
            .clickable(enabled = state == ButtonState.Idle) { onClick() },
        shape = RoundedCornerShape(28.dp),
        color = color
    ) {
        Box(contentAlignment = Alignment.Center) {
            when (state) {
                ButtonState.Idle    -> Text("Submit", color = Color.White)
                ButtonState.Loading -> CircularProgressIndicator(
                    modifier = Modifier.size(24.dp),
                    color = Color.White, strokeWidth = 2.dp
                )
                ButtonState.Success -> Icon(Icons.Default.Check, null, tint = Color.White)
            }
        }
    }
}

Conclusion

From simple one-liner animate*AsState calls to choreographed multi-step transitions and gesture-driven physics — you now have the full picture of how Jetpack Compose animations work and when to use each API.

The biggest takeaway? Start at the highest API level that solves your problem, and only go deeper when you genuinely need more control. Most apps live happily in the AnimatedVisibility and animate*AsState layer, and that’s completely fine.

Now the best thing to do is open Android Studio and start experimenting. Animations are one of those things that click much faster in practice than in theory. Build something, ship it, and enjoy watching your users smile. 

Jetpack Animation Spring

Jetpack Animation Spring: The Secret to Natural UI Motion in Android

Good UI motion doesn’t call attention to itself. It just feels right.

If your animations feel stiff or robotic, chances are you’re using fixed-duration transitions. Real-world motion doesn’t work like that. Things accelerate, slow down, and sometimes bounce a little. That’s where Jetpack Animation Spring comes in.

Let’s walk through what it is, why it matters, and how to use it in compose projects without overcomplicating things.

Why UI Motion Matters More Than You Think

There’s a specific feeling you get from a well-made app — a card that snaps into place just right, or a button that bounces back a little when you tap it. It’s hard to pin down, but you know it when you feel it. That feeling almost always comes from physics-based animation.

Most of us start with tween animations: move from point A to point B in 300 milliseconds. Simple, predictable, and completely fine. But tweens move in straight lines. Nothing in the physical world actually does that.

That’s the gap Jetpack Animation Spring fills. It gives your UI a sense of mass and momentum — things overshoot slightly, then settle. It’s a small change that makes a noticeable difference.

Tween Animation

Moves from A to B in a fixed time. Feels robotic. Can’t react to mid-gesture interruptions naturally. Ignores real-world physics entirely.

Spring Animation

Moves based on force and resistance. Overshoots slightly, then settles. Can be interrupted mid-flight and still feels smooth. Mimics how real objects move.

Google’s Material Design guidelines call for spring-based animations on interactive elements because they respond more naturally to user input. It’s worth understanding how they work.

What Exactly Is Jetpack Animation Spring?

Jetpack Animation Spring is part of the Jetpack Compose animation APIs. It lets you drive animations with physics instead of timers — no manual easing curves, no hardcoded durations.

A spring animation behaves like a real spring. Pull one end and let go — it doesn’t stop dead at the resting point. It overshoots, oscillates back, and gradually settles. The exact behavior is controlled by two values:

  • How stiff the spring is (does it snap back quickly or slowly?)
  • How much damping there is (does it bounce a lot or settle immediately?)

In Compose, all of this is exposed through the spring() function. You pass a spring config to any animation call and Compose takes care of the rest.

Key insight: Unlike tween animations, spring animations are duration-independent. They don’t have a fixed end time — they run until the value reaches its target and the velocity drops to near zero. This makes them perfect for interruption-friendly interactions.

The Physics Behind Spring Animation

Under the hood, every spring in Compose is a damped harmonic oscillator. Which sounds more intimidating than it is. The model has two moving parts:

A spring pulls the object toward the target. A damper slows the object down. The interplay between these two forces creates the characteristic spring motion.

Damping Ratio — How Bouncy Is It?

The damping ratio controls how fast the oscillation dies out — think of it as friction:

Stiffness — How Fast Does It Get There?

Stiffness controls how aggressively the spring pulls the value toward the target. A high stiffness means fast, snappy motion. A low stiffness means a slow, gentle glide.

Setting Up Spring Animation in Jetpack Compose

Make sure you have the Compose animation dependency in your build.gradle.kts. If you’re using the Compose BOM you probably already have it, but here it is explicitly:

Kotlin
// In your app-level build.gradle.kts
dependencies {
    // Core Compose Animation
    implementation("androidx.compose.animation:animation:1.6.0")

    // Compose UI (already included with most BOM setups)
    implementation("androidx.compose.ui:ui:1.6.0")
}

Here’s a simple example — a box that slides horizontally when a button is clicked:

Kotlin
@Composable
fun SimpleSpringExample() {

    // Step 1: Track whether the box is in its "moved" state
    var moved by remember { mutableStateOf(false) }

    // Step 2: Create an animated float value
    val offsetX by animateFloatAsState(
        targetValue = if (moved) 200f else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness    = Spring.StiffnessLow
        ),
        label = "boxOffset"
    )

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {

        // Step 3: Apply the offset to the Box
        Box(
            modifier = Modifier
                .size(80.dp)
                .offset(x = offsetX.dp)
                .background(
                    color = Color(0xFF5B8DEE),
                    shape = RoundedCornerShape(16.dp)
                )
        )

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

        Button(onClick = { moved = !moved }) {
            Text(if (moved) "Spring Back!" else "Move with Spring!")
        }
    }
}

1. mutableStateOf(false) — This is a simple boolean that tells Compose whether the box should be at position 0 or position 200. When it changes, Compose re-composes and the animation kicks off automatically.

2. animateFloatAsState()— This is the magic function. It watches the targetValue and whenever it changes, it smoothly animates the float from its current value to the new target using the provided animationSpec.

3. spring(dampingRatio, stiffness) — This is the Jetpack Animation Spring config. Medium bouncy damping with low stiffness means the box moves slowly but overshoots its target before settling back.

4. offset(x = offsetX.dp) — We apply the animated value as the X offset of the Box. Every frame, Compose recalculates this value based on the spring physics and redraws the UI. No manual frame handling needed!

Understanding SpringSpec Parameters in Depth

The spring() function takes three parameters. Most people only use the first two, but the third one is worth knowing:

Kotlin
fun <T> spring(
    dampingRatio: Float = Spring.DampingRatioNoBouncy,
    stiffness:    Float = Spring.StiffnessMedium,
    visibilityThreshold: T? = null
): SpringSpec<T>

The visibilityThreshold Parameter

This one often gets overlooked! The visibilityThreshold tells Compose: “Stop animating when the value is this close to the target.” 

Means, it defines “when the animation is close enough to stop”

Because spring animations mathematically never fully stop (they asymptotically approach the target), Compose needs a cutoff.

Kotlin
// For a Dp value — stop when within 0.5dp of target
spring(
    dampingRatio = Spring.DampingRatioLowBouncy,
    stiffness    = Spring.StiffnessMedium,
    visibilityThreshold = 0.5.dp
)

// For a Float - stop when within 0.01f of target
spring<Float>(
    dampingRatio = Spring.DampingRatioNoBouncy,
    stiffness    = Spring.StiffnessMediumLow,
    visibilityThreshold = 0.01f
)

// For an Offset - stop when within 1px in both X and Y
spring(
    dampingRatio = Spring.DampingRatioMediumBouncy,
    stiffness    = Spring.StiffnessHigh,
    visibilityThreshold = Offset(1f, 1f)
)

Meaning per type:

  • Dp → 0.5.dp
     → Stop when difference < 0.5dp (visually indistinguishable)
  • Float → 0.01f
     → Stop when difference < 0.01
  • Offset → Offset(1f, 1f)
     → Stop when X and Y are within 1 pixel

It’s a performance optimization that prevents the animation from running indefinitely due to tiny floating-point movements.

Tip: For pixel-level animations, always set a sensible visibilityThreshold. Without it, your spring might technically run for dozens of extra frames on tiny sub-pixel oscillations — wasting battery for no visible benefit.

Built-in Spring Presets You Should Know

Compose ships a set of named constants in the Spring object. These are good defaults — you can always tweak from there, but they cover most use cases out of the box.

Damping Ratio Constants

1. NoBouncy

1.0f — Critically damped. Reaches target smoothly without overshooting. Great for practical, functional UI.

2. LowBouncy

0.75f — Slight overshoot. Very subtle and tasteful. Works for most interactive elements.

3. MediumBouncy

0.5f — Noticeable overshoot. Feels playful and lively. Perfect for FABs, pop-ups, and cards.

4. HighBouncy

0.2f — Very bouncy! Eye-catching but use sparingly. Great for celebrations or onboarding animations.

Stiffness Constants

Kotlin
// Compose's named stiffness constants:

Spring.StiffnessVeryLow    // ≈  50f  — very slow, dreamy
Spring.StiffnessLow        // ≈ 200f  — slow and smooth
Spring.StiffnessMediumLow  // ≈ 400f  — default-ish, general use
Spring.StiffnessMedium     // ≈ 500f  — standard interactive
Spring.StiffnessHigh       // ≈1500f  — snappy and sharp

Watch out: Combining a very high stiffness with a high bouncy damping ratio can make your UI feel chaotic. A high-stiffness spring that also bounces a lot will snap back and forth very quickly — which is rarely the intended effect. Aim for balance.

Real-World Use Cases with Code

Here are the three patterns I reach for most often in real projects.

Use Case 1: Bouncy FAB Appearance

A FAB that just fades in feels flat. Scale it in with a bouncy spring and it feels like it’s jumping out at you. Two lines to change:

Kotlin
@Composable
fun BouncyFAB() {

    var isVisible by remember { mutableStateOf(true) }

    // Animate scale from 0 to 1 with a high-bouncy spring
    val scale by animateFloatAsState(
        targetValue = if (isVisible) 1f else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioHighBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        label = "fabScale"
    )

    FloatingActionButton(
        onClick = { isVisible = !isVisible },
        modifier = Modifier.scale(scale)
    ) {
        Icon(
            imageVector = Icons.Default.Add,
            contentDescription = "Add"
        )
    }
}

@Preview(showBackground = true)
@Composable
fun BouncyFABPreview() {
    CenteredPreview {
        BouncyFAB()
    }
}

When isVisible flips to true, the scale springs from 0f to 1f. With HighBouncy damping, the FAB overshoots past 1.0 before bouncing back to its final size. Modifier.scale() applies the current value every frame — no extra work needed.

Use Case 2: Drag-and-Release with Spring Snap

Drag something sideways, release it, and it springs back to center. This is one of those interactions that feels obvious once you’ve seen it — and it’s straightforward to build:

Kotlin
@Composable
fun SpringSnapCard() {

    // Animatable gives us full control - we can "snap" or animate to values
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp),
        contentAlignment = Alignment.Center
    ) {
        Card(
            modifier = Modifier
                .size(width = 300.dp, height = 180.dp)
                .offset(x = offsetX.value.dp)
                .pointerInput(Unit) {
                    detectHorizontalDragGestures(

                        // While dragging: follow the finger directly
                        onHorizontalDrag = { _, dragAmount ->
                            scope.launch {
                                offsetX.snapTo(offsetX.value + dragAmount)
                            }
                        },

                        // On release: spring back to center!
                        onDragEnd = {
                            scope.launch {
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy,
                                        stiffness    = Spring.StiffnessMedium
                                    )
                                )
                            }
                        }
                    )
                }
        ) {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text  = "Drag me sideways! <---->",
                    style = MaterialTheme.typography.bodyLarge
                )
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun SpringSnapCardPreview() {
    CenteredPreview {
        SpringSnapCard()
    }
}

The key pattern: snapTo() during the drag (no animation, just follow the finger), then animateTo() with a spring on release. The spring picks up from whatever velocity the drag left behind, so fast flicks feel different from slow drags.

Use Case 3: Animated Color with Spring

Spring works on colors too. Here’s a button that animates both color and scale on press — layering two springs gives it a more tactile feel:

Kotlin
@Composable
fun SpringColorButton() {

    var isPressed by remember { mutableStateOf(false) }

    // Animate between two colors using spring
    val bgColor by animateColorAsState(
        targetValue = if (isPressed)
            Color(0xFF34D399)   // green when pressed
        else
            Color(0xFF5B8DEE),  // blue when idle
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness    = Spring.StiffnessMedium
        ),
        label = "buttonColor"
    )

    val scale by animateFloatAsState(
        targetValue = if (isPressed) 0.94f else 1f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness    = Spring.StiffnessHigh
        ),
        label = "buttonScale"
    )

    Box(
        modifier = Modifier
            .scale(scale)
            .clip(RoundedCornerShape(14.dp))
            .background(bgColor)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        isPressed = true
                        tryAwaitRelease()
                        isPressed = false
                    }
                )
            }
            .padding(horizontal = 32.dp, vertical = 16.dp),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text  = "Press & Hold Me",
            color = Color.White,
            fontWeight = FontWeight.SemiBold
        )
    }
}

The scale spring uses high stiffness so it reacts instantly. The color spring uses medium stiffness so it transitions a little more slowly. That difference in timing is subtle, but it’s what stops the button from feeling like one flat animation.

Using Animatable with Spring for Full Control

For most UI state changes, animateFloatAsState() is all you need. But sometimes you want to trigger an animation imperatively — not in response to a state flip, but from a coroutine, a gesture callback, or a side effect. That’s what Animatable is for.

You hold a reference to it, then call animateTo(), snapTo(), or updateBounds() directly. Spring specs work the same way:

Kotlin
@Composable
fun AnimatableSpringExample() {

    // Create an Animatable — this is our "controllable" value
    val rotation = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Icon(
            imageVector = Icons.Default.Favorite,
            contentDescription = null,
            tint = Color.Red,
            modifier = Modifier
                .size(72.dp)
                .graphicsLayer {
                    rotationZ = rotation.value
                }
        )

        Spacer(Modifier.height(24.dp))

        Button(
            onClick = {
                scope.launch {
                    // Snap to 20° instantly (no animation) — simulate a "flick"
                    rotation.snapTo(20f)

                    // Then spring back to 0° with a bouncy spring
                    rotation.animateTo(
                        targetValue = 0f,
                        animationSpec = spring(
                            dampingRatio = Spring.DampingRatioHighBouncy,
                            stiffness    = Spring.StiffnessMediumLow
                        )
                    )
                }
            }
        ) {
            Text("Wobble the Heart! ❤️")
        }
    }
}

The snapTo()animateTo() pattern is useful any time you want a “jolt” effect. Instantly displace the value, then spring it back. The icon appears to recoil from the tap rather than just changing state.

animateXAsState vs Animatable: If the animation is tied to a state variable, use animateXAsState(). If you need to fire it manually from a coroutine, gesture handler, or event callback, use Animatable.

Pro Tips & Common Mistakes

Do: Always Provide a Label

Since Compose 1.4, animated state APIs have a label parameter. Always fill it in. Android Studio’s Animation Inspector (also known as Animation Preview) uses these labels to identify animations during debugging — it makes your life significantly easier when inspecting overlapping animations.

Kotlin
// Good — label helps the Animation Inspector identify this
val scale by animateFloatAsState(
    targetValue = targetScale,
    animationSpec = spring(...),
    label = "cardScale"   // ← always add this!
)

// Bad — anonymous, hard to debug
val scale by animateFloatAsState(targetValue = targetScale)

Do: Use Spring for Gestures and Interruptions

If the user can reverse or redirect a motion mid-way — swipe back, lift a finger early, change direction — spring is the right tool. It reads the current velocity at the moment of interruption and continues from there. A tween would just reset and start over, which looks broken.

Don’t: Use Spring Where Duration Matters

Springs have no fixed end time. That’s a feature when you’re animating interactions, but a problem for things like progress bars or choreographed transitions where you need precise timing. Use tween() there.

Kotlin
// Use tween for progress bars (duration matters)
val progress by animateFloatAsState(
    targetValue    = loadingProgress,
    animationSpec  = tween(durationMillis = 2000, easing = LinearEasing),
    label = "loadingProgress"
)

// Use spring for interactive gestures (feel matters)
val cardOffset by animateFloatAsState(
    targetValue    = dragOffset,
    animationSpec  = spring(Spring.DampingRatioMediumBouncy),
    label = "cardDrag"
)

Don’t: Assume a Spring Will Finish in N Milliseconds

This one bites people. A developer adds a 300ms delay() after kicking off a spring animation, expecting it to be done. It’s not — a slow spring can run for 800ms or more. Use finishedListener to know when it actually settles.

Kotlin
// React to animation end correctly using finishedListener
val offsetX by animateFloatAsState(
    targetValue    = if (moved) 200f else 0f,
    animationSpec  = spring(Spring.DampingRatioLowBouncy),
    label          = "cardOffset",
    finishedListener = { finalValue ->
        // This fires when the spring fully settles
        Log.d("Spring", "Settled at: $finalValue")
        // Trigger your next action here, not after a hardcoded delay
    }
)

// Wrong — delay doesn't know when spring settles
scope.launch {
    moved = true
    delay(300)         // spring might still be running!
    doNextThing()
}

Frequently Asked Questions

Q. Can I use Jetpack Animation Spring with the old View-based Android system?

Yes. The SpringAnimation class in androidx.dynamicanimation brings the same physics model to View-based UIs. The API looks different from Compose’s spring(), but the underlying math is identical. For new projects, stick with the Compose API — it’s cleaner and integrates with state automatically.

Q. Is Jetpack Animation Spring bad for performance?

Not in practice. Compose batches and optimizes recompositions, so the overhead is minimal for typical UI. If you’re animating something heavy — large composables, complex layouts — wrap it in graphicsLayer. Transformations inside graphicsLayer (scale, rotation, translation, alpha) run on the RenderThread and skip recomposition entirely.

Q. Can I animate multiple properties with spring at the same time?

Yes — each animateXAsState() call is independent, so you can stack as many as you need in one composable. If multiple properties need to stay in sync (start at the same time, driven by the same state change), use updateTransition() instead. It groups them under a single transition so they move together.

Q. What is the difference between spring() and tween() in Compose?

Physics vs time. tween() moves from A to B over a fixed duration. spring() is duration-free — it runs until the value settles, driven by stiffness and damping. Use springs for anything the user can touch or interrupt. Use tweens where timing needs to be exact.

Conclusion

Jetpack Animation Spring is one of those tools that quietly improves your app. It doesn’t add features, but it makes everything feel better.

You don’t need complex setups. Just adjust damping and stiffness until it feels right.

Once you start using it in the right places, it’s hard to go back.

AnimatedContent

How to Master AnimatedContent in Jetpack Compose: Build Smooth UI Transitions

Good UI doesn’t just look nice, it moves well. Small, thoughtful animations help users understand what changed and why. In Jetpack Compose, AnimatedContent makes this surprisingly easy.

This guide walks you through how it works, when to use it, and how to keep things clean and performant.

What is AnimatedContent?

AnimatedContent is a composable in Jetpack Compose that automatically animates between different UI states.

Instead of abruptly switching content, it smoothly transitions from one state to another.

Think of it like this:

  • Without AnimatedContent → content just changes
  • With AnimatedContent → content transforms into the next state

When Should You Use AnimatedContent?

Use AnimatedContent when:

  • You switch between UI states (loading → success → error)
  • You update text, numbers, or layouts dynamically
  • You want smooth transitions without managing animations manually

Basic Example of AnimatedContent

Kotlin
@Composable
fun SimpleAnimatedContentExample(count: Int) {
    AnimatedContent(targetState = count) { targetCount ->
        Text(
            text = "Count: $targetCount",
            fontSize = 24.sp
        )
    }
}
  • targetState = count → tells Compose what state to watch
  • When count changes → animation is triggered
  • targetCount → the new value inside the animation block

This is the core idea of AnimatedContent.

Let’s build a simple counter with animation.

Kotlin
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }

    Column(
        horizontalAlignment =
            Alignment.CenterHorizontally
    ) {
        AnimatedContent(targetState = count) { value ->
            Text(
                text = "$value",
                fontSize = 40.sp
            )
        }
        Spacer(modifier = Modifier.height(16.dp))

        Button(onClick = { count++ }) {
            Text("Increase")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun CounterScreenPreview() {
    CenteredPreview {
        CounterScreen()
    }
}

Here,

Every time count changes:

  • Old text fades/slides out
  • New text animates in

No extra animation code needed.

Customizing Animation in AnimatedContent

By default, animations are nice but basic. You can customize them using transitionSpec.

Example with Slide + Fade

Kotlin
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(12.dp),
        modifier = Modifier.padding(16.dp)
    ) {

        // Increment
        CounterButton(
            text = "+",
            onClick = { count++ }
        )

        // Animated Counter
        RollingCounter(count = count)

        // Decrement
        CounterButton(
            text = "-",
            onClick = { if (count > 0) count-- },
            enabled = count > 0
        )
    }
}

@Composable
fun CounterButton(
    text: String,
    onClick: () -> Unit,
    enabled: Boolean = true
) {
    Button(
        onClick = onClick,
        enabled = enabled,
        shape = CircleShape,
        modifier = Modifier.size(48.dp),
        contentPadding = PaddingValues(0.dp)
    ) {
        Text(
            text = text,
            fontSize = 22.sp
        )
    }
}

@Composable
fun RollingCounter(
    count: Int,
    fontSize: TextUnit = 40.sp
) {
    val digits = count.toString().map { it.toString() }

    Row {
        digits.forEachIndexed { index, digit ->
            key(index) {
                RollingDigit(
                    digit = digit,
                    fontSize = fontSize
                )
            }
        }
    }
}

@Composable
fun RollingDigit(
    digit: String,
    fontSize: TextUnit
) {
    Box(
        modifier = Modifier
            .height(48.dp)
            .width(28.dp),
        contentAlignment = Alignment.Center
    ) {
        AnimatedContent(
            targetState = digit,
            transitionSpec = {
                if (targetState > initialState) {
                    // Increment → roll up
                    slideInVertically { it } + fadeIn() togetherWith
                            slideOutVertically { -it } + fadeOut()
                } else {
                    // Decrement → roll down
                    slideInVertically { -it } + fadeIn() togetherWith
                            slideOutVertically { it } + fadeOut()
                }
            }
        ) { targetDigit ->
            Text(
                text = targetDigit,
                fontSize = fontSize,
                fontFamily = FontFamily.Monospace
            )
        }
    }
}

@Composable
fun CenteredPreview(content: @Composable () -> Unit) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        content()
    }
}

@Preview(showBackground = true)
@Composable
fun CounterPreview() {
    CenteredPreview {
        CounterScreen()
    }
}
  • Digit-by-digit rolling animation
  • Direction-aware transitions (up/down)
  • Creates a smooth vertical transition

Understanding transitionSpec (Simple Explanation)

Inside transitionSpec, you define:

  • Enter animation → how new content appears
  • Exit animation → how old content disappears

You combine them using:

Kotlin
enterAnimation togetherWith exitAnimation

Add Direction-Based Animation

You can change animation depending on state.

Kotlin
AnimatedContent(
    targetState = count,
    transitionSpec = {
        if (targetState > initialState) {
            slideInHorizontally { it } togetherWith
            slideOutHorizontally { -it }
        } else {
            slideInHorizontally { -it } togetherWith
            slideOutHorizontally { it }
        }
    }
) { value ->
    Text(text = "$value", fontSize = 40.sp)
}

What’s happening?

  • Increasing number → slides from right
  • Decreasing number → slides from left

This small detail makes your UI feel intelligent.

Example 2: Switch Between Screens

You can use AnimatedContent to switch UI states like this:

Kotlin
enum class ScreenState {
    LOADING, SUCCESS, ERROR
}

@Composable
fun ScreenExample(state: ScreenState) {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(120.dp),
        contentAlignment = Alignment.Center
    ) {
        AnimatedContent(
            targetState = state,
            label = "screen_state",
            transitionSpec = {
                when {
                    // Loading → Success
                    initialState == ScreenState.LOADING &&
                            targetState == ScreenState.SUCCESS -> {
                        slideInVertically { it } + fadeIn() togetherWith
                                slideOutVertically { -it } + fadeOut()
                    }

                    // Loading → Error
                    initialState == ScreenState.LOADING &&
                            targetState == ScreenState.ERROR -> {
                        slideInVertically { -it } + fadeIn() togetherWith
                                slideOutVertically { it } + fadeOut()
                    }

                    // Error → Loading (retry)
                    initialState == ScreenState.ERROR &&
                            targetState == ScreenState.LOADING -> {
                        fadeIn() togetherWith fadeOut()
                    }

                    // Default
                    else -> {
                        fadeIn() togetherWith fadeOut()
                    }
                }
            }
        ) { targetState ->
            when (targetState) {

                ScreenState.LOADING -> {
                    CircularProgressIndicator()
                }

                ScreenState.SUCCESS -> {
                    Text(
                        text = "Data Loaded!",
                        fontSize = 20.sp
                    )
                }

                ScreenState.ERROR -> {
                    Column(
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text(
                            text = "Something went wrong",
                            fontSize = 20.sp
                        )
                        Spacer(modifier = Modifier.height(8.dp))
                        Button(onClick = { /* retry action */ }) {
                            Text("Retry")
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun ScreenContainer() {
    var state by remember { mutableStateOf(ScreenState.LOADING) }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
        modifier = Modifier.padding(16.dp)
    ) {

        ScreenExample(state = state)

        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = { state = ScreenState.LOADING }) {
                Text("Loading")
            }
            Button(onClick = { state = ScreenState.SUCCESS }) {
                Text("Success")
            }
            Button(onClick = { state = ScreenState.ERROR }) {
                Text("Error")
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ScreenPreview() {
    CenteredPreview {
        ScreenContainer()
    }
}

Why this is powerful

Instead of hard switching screens:

  • Each state transition feels natural
  • Improves UX instantly

Common Mistakes to Avoid

1. Forgetting stable state

If your targetState changes too frequently or unpredictably, animations may feel glitchy.

Tip: Use proper state management (remember, ViewModel).

2. Overusing animations

Too many animations can overwhelm users.

Keep it simple:

  • Use animation where it adds clarity
  • Avoid unnecessary motion

3. Heavy UI inside AnimatedContent

If your composable is too complex, animation may lag.

Solution:

  • Keep UI lightweight
  • Break into smaller composables

Performance Tips for AnimatedContent

  • Prefer simple transitions (fade + slide)
  • Avoid recomposing large layouts
  • Use remember wisely
  • Test on low-end devices

Real-World Use Cases

You can use AnimatedContent for:

  • Cart updates in e-commerce apps
  • Switching tabs or filters
  • Form step transitions
  • Notifications or alerts
  • Dashboard value updates

Why AnimatedContent Improves UX

Good animation:

  • Guides attention
  • Explains change
  • Reduces confusion

AnimatedContent does this automatically, which saves time and improves quality.

Conclusion

Mastering AnimatedContent is less about memorizing APIs and more about understanding when and why to animate.

Start small:

  • Animate text
  • Animate numbers
  • Then move to full UI transitions

Over time, you’ll naturally build smoother, more polished apps.

animatedvisibility

How to Use AnimatedVisibility in Jetpack Compose for Stunning UI Transitions

Adding smooth motion to your Android app doesn’t just make it look “cool” — it guides the user’s eye and makes the interface feel responsive and alive. If you’ve ever felt overwhelmed by complex animation frameworks, I have great news: AnimatedVisibility in Jetpack Compose is here to do the heavy lifting for you.

In this guide, I’ll walk you through how to use AnimatedVisibility step by step. We’ll keep things simple, practical, and easy to follow. By the end, you’ll know how to create clean, engaging UI transitions without overcomplicating your code.

What is AnimatedVisibility?

AnimatedVisibility is a composable in Jetpack Compose that lets you show or hide UI elements with animation.

Instead of instantly appearing or disappearing, your UI components can:

  • Fade in or out
  • Slide in or out
  • Expand or shrink

This creates a smoother and more natural user experience.

Why Use AnimatedVisibility?

Here’s why developers love using AnimatedVisibility:

  • Makes UI feel modern and responsive
  • Improves user experience with smooth transitions
  • Easy to implement with minimal code
  • Highly customizable animations

If you’re building dropdowns, alerts, expandable cards, or onboarding flows, AnimatedVisibility is incredibly useful.

Basic Example of AnimatedVisibility

Let’s start with a simple example.

Step 1: Add a Toggle State

Kotlin
var isVisible by remember { mutableStateOf(false) }

This state controls whether the UI is visible or not.

Step 2: Use AnimatedVisibility

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

    AnimatedVisibility(visible = isVisible) {
        Text("Hello! I appear with animation.")
    }
}

Here,

  • When the button is clicked, isVisible changes
  • AnimatedVisibility reacts to that change
  • The text appears or disappears with a default animation

By default, it uses a combination of fade and expand animations.

Adding Custom Animations

The real power of AnimatedVisibility comes from customization.

You can define how elements enter and exit.

Example: Fade + Slide Animation

Kotlin
AnimatedVisibility(
    visible = isVisible,
    enter = fadeIn() + slideInVertically(),
    exit = fadeOut() + slideOutVertically()
) {
    Text("Smooth animated text!")
}
  • fadeIn() → gradually appears
  • slideInVertically() → slides from top or bottom
  • fadeOut() → fades away
  • slideOutVertically() → slides out

You can combine animations using the + operator.

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

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

        AnimatedVisibility(
            visible = isVisible,
            enter = fadeIn() + slideInVertically(),
            exit = fadeOut() + slideOutVertically()
        ) {
            Text("Smooth animated text!")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun FadePlusSlideExamplePreview() {
    CenteredPreview {
        FadePlusSlideExample()
    }
}

Controlling Animation Direction

You can customize how elements slide in.

Kotlin
slideInVertically { fullHeight -> -fullHeight }

What This Means,

  • The element enters from the top
  • -fullHeight moves it above the screen before sliding down

Similarly, you can control exit direction:

Kotlin
slideOutVertically { fullHeight -> fullHeight }

This makes it slide downward when disappearing.

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

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

        AnimatedVisibility(
            visible = isVisible,
            enter = fadeIn() + slideInVertically { fullHeight -> -fullHeight },
            exit = fadeOut() + slideOutVertically { fullHeight -> fullHeight }
        ) {
            Text("Slide Direction text!")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun SlideDirectionExamplePreview() {
    CenteredPreview {
        SlideDirectionExample()
    }
}

Using AnimatedVisibility with Expand and Shrink

This is great for dropdowns or expandable content.

Kotlin
@Composable
fun ExpandShrinkExample() {
    var isExpandable by remember { mutableStateOf(false) }

    Column {
        Button(onClick = { isExpandable = !isExpandable }) {
            Text(if (isExpandable) "Shrink Content" else "Expand Content")
        }

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

        AnimatedVisibility(
            visible = isExpandable,
            enter = expandVertically() + fadeIn(),
            exit = shrinkVertically() + fadeOut(),
        ) {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(Color.LightGray)
                    .padding(16.dp)
            ) {
                Text("Expandable content goes here")
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ExpandShrinkExamplePreview() {
    CenteredPreview {
        ExpandShrinkExample()
    }
}

Why Use This?

  • Feels natural for lists and cards
  • Mimics real-world expansion behavior
  • Works great for FAQs or settings screens

Real-World Use Case

Let’s combine everything into a practical example.

Expandable Card

Kotlin
@Composable
fun ExpandableCard() {
    var expanded by remember { mutableStateOf(false) }

    Column(modifier = Modifier.padding(16.dp)) {
        Button(onClick = { expanded = !expanded }) {
            Text("Show Details")
        }

        AnimatedVisibility(
            visible = expanded,
            enter = fadeIn() + expandVertically(),
            exit = fadeOut() + shrinkVertically()
        ) {
            Text(
                text = "Here are more details about this item. This section expands smoothly.",
                modifier = Modifier.padding(top = 8.dp)
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ExpandableCardPreview() {
    CenteredPreview {
        ExpandableCard()
    }
}
  • Clean separation of state and UI
  • Smooth transition enhances usability
  • Easy to reuse in different parts of your app

AnimatedVisibility with LazyColumn

Using AnimatedVisibility inside a LazyColumn is a great way to create smooth, modern list interactions. Think expandable list items, animated insert/remove, or showing extra details per row.

You’d typically combine AnimatedVisibility with LazyColumn when:

  • Expanding/collapsing list items
  • Showing extra details on click
  • Animating conditional content inside rows

Here’s a simple example where each item expands when clicked.

Data Model

Kotlin
data class ListItem(
    val id: Int,
    val title: String,
    val description: String
)

Sample Data

Kotlin
val items = listOf(
    ListItem(1, "Item 1", "This is item 1 details"),
    ListItem(2, "Item 2", "This is item 2 details"),
    ListItem(3, "Item 3", "This is item 3 details")
)

LazyColumn with AnimatedVisibility

Kotlin
@Composable
fun ExpandableList() {
    val expandedItems = remember { mutableStateListOf<Int>() }

    LazyColumn {
        items(items) { item ->

            val isExpanded = expandedItems.contains(item.id)

            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable {
                        if (isExpanded) {
                            expandedItems.remove(item.id)
                        } else {
                            expandedItems.add(item.id)
                        }
                    }
                    .padding(16.dp)
            ) {

                Text(text = item.title)

                AnimatedVisibility(
                    visible = isExpanded,
                    enter = fadeIn() + expandVertically(),
                    exit = fadeOut() + shrinkVertically()
                ) {
                    Text(
                        text = item.description,
                        modifier = Modifier.padding(top = 8.dp)
                    )
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ExpandableListPreview() {
    CenteredPreview {
        ExpandableList()
    }
}

Best Practices for Using AnimatedVisibility

To get the most out of AnimatedVisibility, keep these tips in mind:

1. Keep Animations Subtle

Avoid overly complex animations. Simple transitions feel more professional.

2. Use Meaningful Motion

Animations should guide the user, not distract them.

3. Manage State Properly

Use remember and mutableStateOf correctly to avoid unexpected behavior.

4. Combine Animations Carefully

Too many combined effects can feel heavy. Stick to 1–2 transitions.

5. Test on Real Devices

Animations may feel different on slower devices. Always test performance.

Common Mistakes to Avoid

Here are a few pitfalls when working with AnimatedVisibility:

  • Forgetting to control state properly
  • Overusing animations in every component
  • Using heavy animations inside large lists
  • Not handling re-composition efficiently

Keep things simple and intentional.

When Should You Use AnimatedVisibility?

Use AnimatedVisibility when you need to:

  • Show/hide UI elements dynamically
  • Create expandable layouts
  • Improve onboarding screens
  • Add feedback to user actions
  • Build interactive components

If visibility changes are part of your UI, this composable is the right tool.

Conclusion

AnimatedVisibility is one of the easiest ways to bring life into your Jetpack Compose UI.

You don’t need complex animation frameworks or tons of code. With just a few lines, you can create smooth, engaging transitions that feel natural and polished.

Start small. Try a simple fade or slide. Then experiment with combinations as you get comfortable.

visibilityThreshold

visibilityThreshold in Spring Animations: How It Works in Jetpack Compose

When you start working with animations in Jetpack Compose, the spring() API feels intuitive at first—until you notice something odd: animations don’t seem to fully stop. They get very, very close to the target value… but technically never reach it.

That’s where visibilityThreshold quietly does some of the most important work.

This article walks you through what it is, why it matters, and how to use it correctly across different data types like Dp, Float, and Offset. Along the way, we’ll build real composable examples you can run and experiment with.

Why Spring Animations Need a Stopping Condition

Spring animations simulate real physics, Instead of moving linearly from point A to point B, they behave like a spring:

  • They move toward the target
  • Overshoot (depending on damping)
  • Oscillate
  • Gradually settle

From a math perspective, they never fully stop — they just get closer over time.

In UI, that’s not useful. We need a clear stopping point. Compose handles this using a threshold.

What is visibilityThreshold?

visibilityThreshold defines the minimum difference between the current animated value and the target value at which the animation is considered finished.

In simple terms:

  • If the remaining distance is smaller than the threshold → animation stops
  • If not → animation continues

How It Works for Different Types

Different types need different thresholds because “closeness” depends on the unit.

1. Dp (Density-independent pixels)

Kotlin
spring(
    dampingRatio = Spring.DampingRatioLowBouncy,
    stiffness = Spring.StiffnessMedium,
    visibilityThreshold = 0.5.dp
)

The animation stops when it’s within 0.5dp of the target.

Why this works:

  • Human eyes can’t distinguish sub-pixel differences at that scale
  • Prevents unnecessary micro-adjustments

2. Float (e.g., Alpha, Progress)

Kotlin
spring<Float>(
    dampingRatio = Spring.DampingRatioNoBouncy,
    stiffness = Spring.StiffnessMediumLow,
    visibilityThreshold = 0.01f
)

Stops when difference < 0.01

Why this matters:

  • Floats are continuous values
  • Without a threshold, fade animations might keep updating unnecessarily

3. Offset (Position)

Kotlin
spring(
    dampingRatio = Spring.DampingRatioMediumBouncy,
    stiffness = Spring.StiffnessHigh,
    visibilityThreshold = Offset(1f, 1f)
)

Stops when both X and Y are within 1 pixel

Why:

  • Movement smaller than 1px is visually irrelevant
  • Prevents jitter at the end of motion

Quick Refresher on Spring Parameters

Real Examples with Composables

Example 1: Animating Size (Dp)

Kotlin
@Composable
fun SpringDpExample() {
    var expanded by remember { mutableStateOf(false) }

    val size by animateDpAsState(
        targetValue = if (expanded) 200.dp else 100.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessMedium,
            visibilityThreshold = 0.5.dp
        )
    )

    Box(
        modifier = Modifier
            .size(size)
            .background(Color.Blue)
            .clickable { expanded = !expanded }
    )
}

@Preview(showBackground = true)
@Composable
fun SpringDpExamplePreview() {
    CenteredPreview {
        SpringDpExample()
    }
}

What to notice:

  • Smooth expansion and contraction
  • A slight bounce effect
  • Clean stop without jitter

Example 2: Animating Alpha (Float)

Kotlin
@Composable
fun SpringFloatExample() {
    var visible by remember { mutableStateOf(true) }

    val alpha by animateFloatAsState(
        targetValue = if (visible) 1f else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioNoBouncy,
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = 0.01f
        )
    )

    Box(
        modifier = Modifier
            .size(150.dp)
            .background(Color.Red.copy(alpha = alpha))
            .clickable { visible = !visible }
    )
}

@Preview(showBackground = true)
@Composable
fun SpringFloatExamplePreview() {
    CenteredPreview {
        SpringFloatExample()
    }
}

Observation:

  • Clean fade without bounce
  • No flickering at the end
  • Efficient termination

Example 3: Animating Position (Offset)

Kotlin
@Composable
fun SpringOffsetExample() {
    var moved by remember { mutableStateOf(false) }

    val offset by animateOffsetAsState(
        targetValue = if (moved) Offset(300f, 300f) else Offset.Zero,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessHigh,
            visibilityThreshold = Offset(1f, 1f)
        )
    )

    Box(
        modifier = Modifier
            .offset { IntOffset(offset.x.toInt(), offset.y.toInt()) }
            .size(80.dp)
            .background(Color.Green)
            .clickable { moved = !moved }
    )
}

@Preview(showBackground = true)
@Composable
fun SpringOffsetExamplePreview() {
    CenteredPreview {
        SpringOffsetExample()
    }
}

What to watch:

  • Movement feels natural
  • Slight bounce at destination
  • Stops cleanly without micro-shakes

Why visibilityThreshold is Important 

1. Performance Optimization

Without it:

  • The animation engine keeps recalculating tiny differences
  • Wastes CPU cycles
  • Impacts battery (especially on low-end devices)

With it:

  • Animation ends decisively
  • Reduces recompositions

2. Visual Stability

Tiny sub-pixel movements can cause:

  • Flickering
  • Jitter
  • Unstable UI feel

Threshold eliminates those artifacts.

3. Better UX

Users don’t perceive microscopic differences.

Ending animations early:

  • Feels faster
  • Feels smoother
  • Improves perceived performance

Choosing the Right Threshold

Here’s a practical guideline:

Avoid:

  • Too small → wasted computation
  • Too large → noticeable snapping

Common Mistakes

Ignoring it completely
→ Animations run longer than needed

Using the same value everywhere
→ Different types need different precision

Setting it too high
→ Animation ends too early and looks abrupt

Conclusion

visibilityThreshold is easy to overlook, but it has a noticeable impact on how polished your animations feel.

A small tweak here can:

  • Reduce unnecessary work
  • Improve smoothness
  • Make animations feel more intentional

It’s one of those details that separates a working UI from a well-crafted one.

animateAsState

Master animate*AsState in Jetpack Compose: Effortless UI Animations Explained

Animations in the old View system were a lot of ceremony. You’d set up an ObjectAnimator, attach a listener, call start(), remember to cancel on detach, and hope nothing leaked. For something as simple as fading a view, it felt disproportionate.

Compose takes a different approach. Instead of imperative animation commands, you describe what you want the UI to look like for a given state — and the animate*AsState family handles the transition automatically. No start/cancel lifecycle. No listeners unless you need them.

What Is animate*AsState?

animate*AsState is a group of composable functions that smoothly animate a value whenever its target changes. Feed it a target, and it produces a frame-by-frame animated value you can plug directly into your UI.

The * is a wildcard — there’s a variant for each value type you’re likely to animate:

They all follow the same pattern, so once you’ve used one, the others are trivial.

The Mental Model

The key shift from the View system: you don’t start animations. You change state.

State changes → animate*AsState detects the new target → interpolates toward it each frame

When isExpanded flips from false to true, you don’t tell anything to animate. You just update state, and the animated value catches up on its own. If the state changes again mid-flight, the animation redirects smoothly from wherever it currently is.

This is different from ValueAnimator, which needs explicit start/cancel calls and doesn’t know about your UI state at all.

animateFloatAsState: Fading a Box

Start here — it’s the simplest case.

Kotlin
@Composable
fun FadeExample() {
    var isVisible by remember { mutableStateOf(true) }

    val alpha by animateFloatAsState(
        targetValue = if (isVisible) 1f else 0f,
        label = "alpha animation"
    )

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.padding(24.dp)
    ) {
        Box(
            modifier = Modifier
                .size(100.dp)
                .graphicsLayer { this.alpha = alpha }
                .background(Color(0xFF6650A4))
        )
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { isVisible = !isVisible }) {
            Text(if (isVisible) "Hide" else "Show")
        }
    }
}


@Preview(showBackground = true)
@Composable
fun FadeExamplePreview() {
    FadeExample()
}

isVisible is boolean. When it toggles, animateFloatAsState picks up the new target and eases alpha toward it over several frames. Each frame triggers a recomposition, which re-reads the updated alpha — that’s the full animation loop.

The label parameter is optional, but set it anyway. It appears in the Android Studio Animation Inspector and makes debugging significantly less painful.

animateColorAsState: Transitioning Colors

Color is one of the more visually rewarding things to animate because even a 300ms cross-fade reads as deliberate and polished.

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

    val backgroundColor by animateColorAsState(
        targetValue = if (isActive) Color(0xFF6650A4) else Color(0xFFECECEC),
        animationSpec = tween(durationMillis = 500),
        label = "background color"
    )

    val textColor by animateColorAsState(
        targetValue = if (isActive) Color.White else Color.Black,
        animationSpec = tween(durationMillis = 500),
        label = "text color"
    )

    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .fillMaxWidth()
            .height(120.dp)
            .background(backgroundColor, shape = RoundedCornerShape(16.dp))
            .clickable { isActive = !isActive }
    ) {
        Text(
            text = if (isActive) "Active" else "Inactive",
            color = textColor,
            fontSize = 20.sp,
            fontWeight = FontWeight.SemiBold
        )
    }
}


@Preview(showBackground = true)
@Composable
fun ColorToggleExamplePreview() {
    CenteredPreview {
        ColorToggleExample()
    }
}

Both the background and text colors animate in sync. You don’t coordinate them — they just share the same state source (isActive), so they naturally stay in step.

The animationSpec = tween(durationMillis = 500) is where you control how the animation plays out. More on that below.

animateDpAsState: Expandable Cards

animateDpAsState works on any Dp value — height, width, padding, corner radius. A common use case is an expandable card:

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

    val cardHeight by animateDpAsState(
        targetValue = if (isExpanded) 200.dp else 80.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "card height"
    )
    
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(cardHeight)
            .clickable { isExpanded = !isExpanded },
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = "Tap to ${if (isExpanded) "collapse" else "expand"}",
                fontWeight = FontWeight.Bold,
                fontSize = 16.sp
            )
            if (isExpanded) {
                Spacer(modifier = Modifier.height(12.dp))
                Text(
                    text = "The card height is driven by animateDpAsState. " +
                           "The spring spec adds a slight overshoot on open.",
                    fontSize = 14.sp,
                    color = Color.Gray
                )
            }
        }
    }
}

Using spring instead of tween here adds a small overshoot when the card opens — the physics-based easing makes it feel more physical than a plain duration curve.

Animation Specs

animationSpec controls the character of the animation. There are three you’ll reach for regularly.

tween — Fixed Duration

Kotlin
val size by animateDpAsState(
    targetValue = targetSize,
    animationSpec = tween(
        durationMillis = 400,
        delayMillis = 100,
        easing = FastOutSlowInEasing
    ),
    label = "size"
)

Pick tween when you need precise timing — UI tests, coordinated sequences, or matching a transition to an audio cue.

Common easing options:

  • FastOutSlowInEasing — decelerates into the final position (good for elements entering the screen)
  • LinearOutSlowInEasing — starts at constant speed, slows at the end (good for exits)
  • FastOutLinearInEasing — accelerates throughout (for emphasis)
  • EaseInOut — smooth on both ends, feels the most natural
  • LinearEasing — constant speed; fine for loaders, rarely right for UI transitions

spring — Physics-Based

Kotlin
val offsetX by animateFloatAsState(
    targetValue = targetPosition,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioMediumBouncy,  // How much it overshoots
        stiffness = Spring.StiffnessLow                  // How fast it moves
    ),
    label = "offset"
)

spring doesn’t have a fixed duration — it settles based on physics. The two parameters to tune:

Damping ratio (controls overshoot):

  • NoBouncy (1f) — glides in cleanly, no overshoot
  • LowBouncy (0.75f) — barely noticeable bounce
  • MediumBouncy (0.5f) — clear bounce, works well for cards and buttons
  • HighBouncy (0.2f) — exaggerated overshoot, use it deliberately

Stiffness (controls speed):

  • VeryLow— slow, floaty
  • Low — relaxed
  • Medium — balanced default
  • High — snappy
  • VeryHigh — nearly instant

keyframes — Custom Intermediate Values

Kotlin
val scale by animateFloatAsState(
    targetValue = if (isPressed) 0.9f else 1f,
    animationSpec = keyframes {
        durationMillis = 300
        1f at 0       // frame 0ms
        1.1f at 100   // slightly overshoots at 100ms
        0.95f at 200  // settles low
        1f at 300     // lands at resting scale
    },
    label = "press scale"
)

Use keyframes for custom press effects or anything where you need control over intermediate values. It’s more verbose, but it gives you exact control over the curve at each timestamp.

Combining Multiple Animations: Like Button

Each animate*AsState call handles exactly one value. When you need several properties to animate at once, you just stack them. They all read from the same state and run concurrently without any coordination code.

Kotlin
@Composable
fun AnimatedLikeButton() {
    var isLiked by remember { mutableStateOf(false) }

    val scale by animateFloatAsState(
        targetValue = if (isLiked) 1f else 0.85f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioHighBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        label = "like scale"
    )

    val heartColor by animateColorAsState(
        targetValue = if (isLiked) Color(0xFFE91E63) else Color.Gray,
        animationSpec = tween(durationMillis = 200),
        label = "heart color"
    )

    val iconSize by animateDpAsState(
        targetValue = if (isLiked) 36.dp else 28.dp,
        animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
        label = "icon size"
    )

    IconButton(
        onClick = { isLiked = !isLiked },
        modifier = Modifier.graphicsLayer { scaleX = scale; scaleY = scale }
    ) {
        Icon(
            imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
            contentDescription = if (isLiked) "Unlike" else "Like",
            tint = heartColor,
            modifier = Modifier.size(iconSize)
        )
    }
}

On tap:

  1. The icon swaps (state change, instant)
  2. Color fades from gray to pink over 200ms (tween)
  3. Scale bounces with a spring (HighBouncy)
  4. Icon size bumps up with a softer spring (MediumBouncy)

Three independent animations, one state variable, no coordinator.

Rotation

The .rotate() modifier accepts a Float, so animateFloatAsState drops right in. Useful for expand/collapse arrows and spinners.

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

    val rotation by animateFloatAsState(
        targetValue = if (isExpanded) 180f else 0f,
        animationSpec = tween(durationMillis = 300, easing = EaseInOut),
        label = "arrow rotation"
    )

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { isExpanded = !isExpanded }
            .padding(16.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text("Show details", fontWeight = FontWeight.Medium)
        Icon(
            imageVector = Icons.Default.KeyboardArrowDown,
            contentDescription = "Expand",
            modifier = Modifier.rotate(rotation)
        )
    }
}

Slide-In with Offset

Banner notifications, bottom bars, toast-style messages — offset animation handles all of these. Start the composable off-screen and animate it into position.

Kotlin
@Composable
fun SlideInNotification(
    message: String,
    isVisible: Boolean
) {
    val offsetY by animateDpAsState(
        targetValue = if (isVisible) 0.dp else (-80).dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioLowBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        label = "notification offset"
    )

    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .offset(y = offsetY),
        color = Color(0xFF4CAF50),
        shadowElevation = 6.dp,
        shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
    ) {
        Text(
            text = message,
            modifier = Modifier.padding(16.dp),
            color = Color.White,
            fontWeight = FontWeight.Medium
        )
    }
}

@Preview(showBackground = true)
@Composable
fun SlideInNotificationInteractivePreview() {
    CenteredPreview {
        var isVisible by remember { mutableStateOf(false) }

        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            SlideInNotification(
                message = "This is a notification. Hello from animation",
                isVisible = isVisible
            )

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

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

When isVisible becomes true, the banner animates from its current offset to 0.dp, sliding down into view with a slight bounce. When set to false, it animates back to -80.dp, sliding out upward.

Reacting When an Animation Finishes

If you need to trigger something after the animation settles — navigate, update a flag, kick off the next step — use finishedListener:

Kotlin
@Composable
fun AnimationWithCallback() {
    var isMoved by remember { mutableStateOf(false) }
    var statusText by remember { mutableStateOf("Ready") }

    val offsetX by animateDpAsState(
        targetValue = if (isMoved) 200.dp else 0.dp,
        animationSpec = tween(durationMillis = 600),
        label = "move animation",
        finishedListener = { finalValue ->
            // Called once - when the animation fully settles
            statusText = if (finalValue == 200.dp) "Moved!" else "Back home!"
        }
    )

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Box(
            modifier = Modifier
                .size(60.dp)
                .offset(x = offsetX)
                .background(Color(0xFF6650A4), shape = CircleShape)
        )
        Spacer(modifier = Modifier.height(16.dp))
        Text(text = statusText)
        Spacer(modifier = Modifier.height(8.dp))
        Button(onClick = { isMoved = !isMoved }) {
            Text(if (isMoved) "Move back" else "Move right")
        }
    }
}

finishedListener fires once, with the final settled value. It does not fire on every frame — that’s what makes it safe to use for side effects.

Performance: Use graphicsLayer for Visual Transforms

For scale, alpha, and rotation, avoid stacking individual modifiers. Batch them in a single graphicsLayer block:

Kotlin
// Prefer this — all transforms applied in one pass on the render thread
Modifier.graphicsLayer {
    scaleX = scale
    scaleY = scale
    alpha = alpha
    rotationZ = rotation
}

// Avoid this for pure visual properties
Modifier
    .scale(scale)
    .alpha(alpha)
    .rotate(rotation)

graphicsLayer applies visual transformations during the draw phase, avoiding layout changes and reducing the cost of recomposition for purely visual updates. This makes it especially efficient for animations like alpha, translation, and scale—particularly in lists or frequently updated UI.

Keep targetValue Simple

If the logic for computing your target value is complex, extract it before passing it in:

Kotlin
// Fine
val scale by animateFloatAsState(
    targetValue = if (isExpanded) 1.2f else 1f,
    label = "scale"
)

// Better to extract first than inline a big when block
val targetScale = when {
    isExpanded && isSelected -> 1.3f
    isExpanded -> 1.15f
    isSelected -> 1.05f
    else -> 1f
}

val scale by animateFloatAsState(targetValue = targetScale, label = "scale")

When Not to Use animate*AsState

animate*AsState is the right tool when you’re animating a single value in response to a state flip. Reach for something else when:

  • You’re animating multiple values that need to stay in sync as a unitupdateTransition
  • You need an infinitely repeating animation → rememberInfiniteTransition
  • You’re tracking a pointer/drag gesture and need manual control → Animatable
  • The composable is entering or leaving the compositionAnimatedVisibility

The last one trips people up most often. animate*AsState can only animate a composable that’s already in the tree. If you’re using if (condition) { MyComposable() } and condition becomes false, MyComposable is gone — there’s nothing left to animate. Wrap it in AnimatedVisibility instead.

Full Example: Custom Animated Toggle

Here’s everything from this article working together in a single component — a toggle row with animated track color, thumb position, and a subtle press scale:

Kotlin
@Composable
fun AnimatedSettingsToggle(
    label: String,
    description: String,
    isEnabled: Boolean,
    onToggle: (Boolean) -> Unit
) {
    var isPressed by remember { mutableStateOf(false) }

    val trackColor by animateColorAsState(
        targetValue = if (isEnabled) Color(0xFF6650A4) else Color(0xFFCAC4D0),
        animationSpec = tween(durationMillis = 250),
        label = "track color"
    )

    val thumbOffset by animateDpAsState(
        targetValue = if (isEnabled) 20.dp else 2.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        label = "thumb offset"
    )

    val rowScale by animateFloatAsState(
        targetValue = if (isPressed) 0.98f else 1f,
        animationSpec = tween(durationMillis = 100),
        label = "row press scale"
    )

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .graphicsLayer {
                scaleX = rowScale
                scaleY = rowScale
            }
            .clickable(
                indication = null,
                interactionSource = remember { MutableInteractionSource() }
            ) {
                isPressed = true
                onToggle(!isEnabled) // toggle value
                isPressed = false
            }
            .padding(horizontal = 16.dp, vertical = 12.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column(modifier = Modifier.weight(1f)) {
            Text(
                text = label,
                fontWeight = FontWeight.SemiBold,
                fontSize = 16.sp
            )
            Text(
                text = description,
                fontSize = 13.sp,
                color = Color.Gray
            )
        }

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

        Box(
            modifier = Modifier
                .width(48.dp)
                .height(28.dp)
                .background(trackColor, shape = RoundedCornerShape(50))
                .padding(horizontal = 2.dp),
            contentAlignment = Alignment.CenterStart
        ) {
            Box(
                modifier = Modifier
                    .size(24.dp)
                    .offset(x = thumbOffset)
                    .background(Color.White, shape = CircleShape)
            )
        }
    }
}

@Preview(showBackground = true)
@Composable
fun AnimatedSettingsTogglePreview() {
    var isEnabled by remember { mutableStateOf(false) }

    CenteredPreview {
        AnimatedSettingsToggle(
            label = "Wi-Fi",
            description = "Enable wireless connectivity",
            isEnabled = isEnabled,
            onToggle = { }
        )
    }
}

Three animate*AsState calls, no third-party library, no animation framework — just state and a handful of composable functions.

Common Mistakes

Animating inside a LazyColumn without stable keys. Each item gets its own animation instance, which is correct — but if your remember isn’t keyed to the item’s identity, Compose may reuse the state for a different item when the list scrolls. Always key your remember calls to something stable and unique per item.

Expecting finishedListener to fire on every frame. It fires once, when the animation settles. If you want per-frame callbacks, you need Animatable with a custom coroutine loop.

Using animate*AsState for enter/exit animations. When a composable leaves the composition, it’s gone — animate*AsState has nothing to animate. Use AnimatedVisibility for any case where the composable needs to animate out before being removed.

Conclusion

animate*AsState in Jetpack Compose is one of those APIs you end up using all the time.

It keeps animation logic simple and close to your UI state, which is exactly how Compose is meant to work.

Start with small interactions. Once you get comfortable, you’ll naturally move to more advanced animation APIs when needed.

@Preview

Stop Copy-Pasting @Preview Functions. Here’s What Real Developers Do Instead

If you’ve been building UI with Jetpack Compose for more than a week, you already know the drill. You write a clean Composable — maybe a PrimaryButton, or a UserProfileCard — and then you immediately have to write something like this below it:

Kotlin
@Preview(showBackground = true)
@Composable
fun PrimaryButtonPreview() {
    PrimaryButton(
        text = "Click Me",
        onClick = {}
    )
}

Doesn’t look like much, right..? Nine lines. 

But think about how many Composables you build across a full project. Ten screens, each with five or six components — that’s fifty-plus preview functions you’re hand-typing (or copy-pasting and then refactoring). The mental cost isn’t the typing itself; it’s the context switching. You just nailed your component logic, and now you have to stop, copy the function name, jump below, paste, rename, and restructure. It breaks the creative flow completely.

The good news: Android Studio already has a built-in system designed exactly for this. You just need to set it up once.

Quick answer for the impatient: Go to Settings → Editor → Live Templates, create a new template with the abbreviation prev (or use the one already available in Android Studio), and paste the template code from the next section. That’s it. The rest of this post goes deeper into why and when to use each approach.

Live Templates — The Fastest Fix You’re Not Using

Live Templates are one of Android Studio’s most underused features. They’re essentially smart text snippets that expand when you type a short abbreviation and press Tab. IntelliJ has had them for years — Android Studio inherits them from the same codebase. Kotlin developers who come from other editors sometimes have no idea this exists.

For @Preview specifically, the goal is: you type prev, hit Tab, and the entire preview scaffold appears with your cursor already positioned inside it, ready to fill in any needed parameters.

Setting Up Your First Preview Live Template

Here’s the exact step-by-step process — no skipping ahead:

1. Open Settings

On macOS press ⌘ ,

On Windows/Linux go to File → Settings

In the search bar, type “Live Templates” to jump straight to it.

2. Create a new Template Group 

In the Live Templates panel, click the + button on the right → select Template Group→ name it something like Compose. This keeps things organized and separate from Android Studio’s built-in templates.

3. Add a new Live Template

With your new Compose group selected, click + again → this time select Live Template.

4. Fill in the abbreviation and description

Set Abbreviation to prev and give it a Description like “Compose @Preview function”. The description shows up in autocomplete hints, so keep it readable.

5. Paste the template text

This is the core part. Paste the code below exactly as shown — the $VARIABLE$ syntax is how Android Studio knows where to place your cursor and what to ask you to fill in.

Template Code — Basic Preview

Live Template Text

Kotlin
@Preview(showBackground = true)
@Composable
fun $COMPOSABLE_NAME$Preview() {
    $COMPOSABLE_NAME$($END$)
}

The $COMPOSABLE_NAME$ variable is smart — when you tab into the template, Android Studio highlights every occurrence at once. Type the name once and it fills in both places simultaneously (the function name and the call inside it). The $END$ marker tells the editor where to park your cursor after you’re done naming — right inside the parentheses where you’d add parameters.

If you want, you can wrap it in your existing app theme or a Material theme like this:

Kotlin
@Preview(showBackground = true)
@Composable
fun $COMPOSABLE_NAME$Preview() {
    MaterialTheme {
      $COMPOSABLE_NAME$($END$)
    }
}

6. Define the applicable context

At the bottom of the template editor, click Define and check Kotlin. Without this step, the template won’t activate inside .kt files.

7. Hit OK and test it

Open any Kotlin file, type prev, and press Tab. The scaffold should appear.

What It Looks Like In Practice

Before & After

Kotlin
// You write this composable first:
@Composable
fun UserProfileCard(
    name: String,
    avatarUrl: String,
    isOnline: Boolean
) {
    // ... your component logic
}




// Then type "prev" + Tab, type "UserProfileCard", Tab again:

@Preview(showBackground = true)
@Composable
fun UserProfileCardPreview() {
    UserProfileCard(
        // ← cursor lands here, ready for sample data
    )
}

One thing to remember: The template inserts the composable name as a plain text call. If your Composable has required parameters, Android Studio won’t auto-fill them — you’ll need to add sample data yourself. That’s expected and by design; previews should use meaningful placeholder values, not auto-generated garbage.

Multi-Variant Previews: Light, Dark, and Different Screen Sizes

A single light-mode preview is fine for early development. But before you ship anything, you want to see your component in at least two states: light theme and dark theme. You might also want to check it on a compact phone screen vs a larger device. Doing this manually every time is even more tedious than writing a basic preview.

There are two solid ways to handle this in Compose. The cleaner of the two is a custom annotation that stacks multiple @Preview declarations — this keeps your composable files lean and consistent across your entire project.

Approach A: Custom Multi-Preview Annotation

Create a single annotation class in a shared file (something like PreviewAnnotations.kt in your UI module’s utils package):

PreviewAnnotations.kt

Kotlin
import android.content.res.Configuration
import androidx.compose.ui.tooling.preview.Preview

@Preview(
    name = "Light Mode",
    showBackground = true
)
@Preview(
    name = "Dark Mode",
    showBackground = true,
    uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Preview(
    name = "Small Phone",
    showBackground = true,
    widthDp = 320
)
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Preview
annotation class DevicePreviews

Now your preview functions become genuinely compact. One annotation, three rendered variants:

Usage

Kotlin
@DevicePreviews
@Composable
fun UserProfileCardPreview() {
    YourAppTheme {
        UserProfileCard(
            name = "Priya Rao",
            avatarUrl = "https://softaai.com/avatar.jpg",
            isOnline = true
        )
    }
}

Approach B: Multi-Variant Live Template

If you prefer keeping everything in Live Templates instead of a shared annotation file, create a second template with abbreviation prevmulti:

Live Template Text — prevmulti

Kotlin
@Preview(name = "Light", showBackground = true)
@Preview(
    name = "Dark",
    showBackground = true,
    uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun $COMPOSABLE_NAME$Preview() {
    $APP_THEME$ {
        $COMPOSABLE_NAME$($END$)
    }
}

Between these two approaches, the custom annotation (Approach A) is better for teams and larger projects. It lives in source control, everyone uses the same preview config automatically, and updating it once updates every preview across the codebase. Live Templates are per-developer and per-machine — great for solo work, less ideal for shared codebases.

Note: Instead of defining custom @DevicePreviews like above, you can use the built-in@PreviewScreenSizes.

File Templates: Scaffold Both the Composable and Preview at Once

Live Templates solve the in-file boilerplate problem. But what if your workflow always starts with creating a new file? If you find yourself doing File → New → Kotlin File/Class and then manually typing the @Composable and @Preview blocks from scratch, File Templates take this even further.

A File Template is a pre-defined structure that Android Studio uses when you create a new file through the right-click menu. You can define your own and make “New Composable File” a real option.

1. Open Settings → File and Code Templates

Navigate to Editor → File and Code Templates. You’ll see the default list of templates on the left (Kotlin File, Interface, Class, etc.).

2. Click + to create a new template

Name it something like Composable, set the extension to kt.

3. Paste the template body

Use the $NAME variable — Android Studio prompts the user to fill this in when they create the file.

File Template Body

File Template — Composable.kt

Kotlin
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
#end

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun ${NAME}() {

}

@Preview(showBackground = true)
@Composable
fun ${NAME}Preview() {
    MaterialTheme {
       ${NAME}()
    }
}

After saving this, right-clicking any package in your project tree will show New → Composable in the menu. You type the component name once, and you get a file with proper package declaration, imports, a blank composable, and its preview — all ready to go.

Which Approach Should You Actually Use?

The honest answer: it depends on your context. Here’s a clear breakdown to help you decide without overthinking it.

For most individual developers: start with a Live Template. It takes five minutes, pays off immediately, and you don’t need to touch it again. If you’re working on a team or a long-lived codebase, invest the extra ten minutes to set up a custom @DevicePreviews annotation and commit it to the repo. That way the entire team benefits without any individual setup.

Conclusion

The friction of writing @Preview boilerplate is real, but it’s entirely self-imposed. Android Studio has the tools to make this near-instant — you just need to spend fifteen minutes setting them up once. A Live Template handles the basic case in two keystrokes. A custom annotation handles multi-variant previews for teams. File Templates handle the new-file workflow.

Pick the one that fits your current workflow and set it up today. The next time you build a Composable, you’ll feel the difference immediately.

Jetpack Compose Animation System

Jetpack Compose Animation System Explained: A Beginner Guide

Animations are one of those things that feel easy until you actually try to wire them into a real screen. You start with a simple fade or size change, and suddenly you’re juggling state, re-composition, and timing issues that don’t behave the way you expected.

I ran into this while building a product listing screen. Small interactions like expanding cards, animating filters, and handling loading states quickly became messy. That’s when the Jetpack Compose Animation System started to make sense — not as a set of APIs, but as a model tied directly to state.

This post breaks that down in a practical way.

What the Jetpack Compose Animation System Actually Is

The core idea is straightforward:

Your UI depends on state, and animations happen when that state changes.

You don’t trigger animations manually. You describe what the UI should look like for a given state, and Compose handles the transition.

Instead of writing something like “start animation on click”, you write:

  • if expanded → height = 200dp
  • if collapsed → height = 100dp

When the state changes, Compose animates between those values.

Understanding this mental model will make everything else click. In Compose, your UI is a function of state:

UI = f(state) — When state changes, Compose re-renders the UI. Animations are just a smooth interpolation between two states over time. You don’t “run” an animation — you change state and tell Compose how to animate the transition.

The animation system in Compose has three layers, and it’s worth knowing which layer you’re working at:

Layer 1 — High-level APIs: AnimatedVisibility, AnimatedContent, Crossfade. These handle the most common cases with zero configuration needed.

Layer 2 — Value-based APIs: animate*AsState, updateTransition, InfiniteTransition. These animate specific values (Float, Dp, Color, etc.) that you then apply in your composables.

Layer 3 — Low-level APIs: Animatable, coroutine-based. Full manual control for complex sequencing, interruptions, or physics-based motion.

The golden rule: start at the highest level that solves your problem. Only go deeper when you genuinely need more control. Most production animations live happily in layers 1 and 2.

The Core Building Blocks

Before writing any animations, it helps to understand the main APIs you’ll actually use:

1. animate*AsState

For simple, one-off animations tied to a single value.

2. updateTransition

For animating multiple values based on the same state.

3. AnimatedVisibility

For showing and hiding composables with animation.

4. AnimatedContent

For switching between UI states.

5. rememberInfiniteTransition

For looping animations.

You don’t need all of them at once. Most real screens use 1–2 of these consistently.

Why This Model Works Well

Once you lean into this approach, a few things improve right away:

  • No need to manage animation lifecycle
  • No manual cancellation logic
  • UI stays consistent with state
  • Less glue code

This becomes especially useful when multiple properties change together. You don’t coordinate them manually. You just describe the end result.

Your First Real Animation

Let’s build something practical: a card that expands when clicked.

Step 1: Define State

Kotlin
@Composable
fun ExpandableCard() {
    var expanded by remember { mutableStateOf(false) }

This is the trigger. Everything depends on this boolean.

Step 2: Animate a Value

Kotlin
val height by animateDpAsState(
        targetValue = if (expanded) 200.dp else 100.dp,
        label = "cardHeight"
    )

What’s happening here:

  • targetValue changes when expanded changes
  • Compose animates between old and new values
  • The result (height) updates continuously during animation

You don’t write animation logic. You describe the end state.

Step 3: Apply It to UI

Kotlin
Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(height)
            .clickable { expanded = !expanded }
    ) {
        Text(
            text = if (expanded) "Expanded content" else "Collapsed",
            modifier = Modifier.padding(16.dp)
        )
    }
}

That’s it. No animator objects, no listeners.

Full Code with Working Preview

Kotlin
@Composable
fun ExpandableCard() {
    var expanded by remember { mutableStateOf(false) }

    val height by animateDpAsState(
        targetValue = if (expanded) 200.dp else 100.dp,
        label = "cardHeight"
    )
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .height(height)
            .clickable { expanded = !expanded }
    ) {
        Text(
            text = if (expanded) "Expanded content" else "Collapsed",
            modifier = Modifier.padding(16.dp)
        )
    }
}

@Preview(showBackground = true)
@Composable
private fun ExpandableCardPreview() {
    MaterialTheme {
        ExpandableCard1()
    }
}

Controlling Animation Behavior

The default animation works, but real apps need control.

Custom Animation Spec

Kotlin
val height by animateDpAsState(
    targetValue = if (expanded) 200.dp else 100.dp,
    animationSpec = tween(
        durationMillis = 500,
        easing = FastOutSlowInEasing
    ),
    label = "cardHeight"
)

Now you control:

  • Duration
  • Easing curve

Spring Animation

Kotlin
animationSpec = spring(
    dampingRatio = Spring.DampingRatioMediumBouncy,
    stiffness = Spring.StiffnessLow
)

Spring animations feel more natural for things like cards or draggable UI.

Animating Multiple Properties Together

This is where updateTransition becomes useful.

Let’s animate both height and color based on the same state.

Kotlin
val transition = updateTransition(
    targetState = expanded,
    label = "cardTransition"
)

Animate Height

Kotlin
val height by transition.animateDp(
    label = "height"
) { state ->
    if (state) 200.dp else 100.dp
}

Animate Color

Kotlin
val backgroundColor by transition.animateColor(
    label = "color"
) { state ->
    if (state) Color.Blue else Color.Gray
}

Apply to UI

Kotlin
Card(
    modifier = Modifier
        .fillMaxWidth()
        .height(height)
        .clickable { expanded = !expanded },
    colors = CardDefaults.cardColors(containerColor = backgroundColor)
) {
    Text(
        text = "Tap to expand",
        modifier = Modifier.padding(16.dp)
    )
}

Now both properties animate in sync, driven by the same state.

Showing and Hiding Content

For visibility changes, don’t animate alpha manually. Use AnimatedVisibility.

Kotlin
AnimatedVisibility(visible = expanded) {
    Text(
        text = "Extra details shown here",
        modifier = Modifier.padding(16.dp)
    )
}

By default, it fades and expands. You can customize it:

Kotlin
AnimatedVisibility(
    visible = expanded,
    enter = fadeIn() + expandVertically(),
    exit = fadeOut() + shrinkVertically()
) {
    Text("Details")
}

This keeps your intent clear: you’re not animating alpha, you’re controlling visibility.

Switching Between UI States

For replacing content, use AnimatedContent.

Kotlin
AnimatedContent(targetState = expanded, label = "content") { state ->
    if (state) {
        Text("Expanded View")
    } else {
        Text("Collapsed View")
    }
}

This automatically animates between the two layouts.

Infinite Animations

For loaders or subtle UI effects:

Kotlin
val infiniteTransition = rememberInfiniteTransition(label = "pulse")val scale by infiniteTransition.animateFloat(
    initialValue = 1f,
    targetValue = 1.1f,
    animationSpec = infiniteRepeatable(
        animation = tween(800),
        repeatMode = RepeatMode.Reverse
    ),
    label = "scale"
)

Apply it:

Kotlin
Box(
    modifier = Modifier
        .size(100.dp)
        .scale(scale)
        .background(Color.Blue)
)

Good for:

  • Loading indicators
  • Attention hints
  • Micro-interactions

Conclusion

The Jetpack Compose Animation System feels strange at first because it flips the mental model. You’re not telling the UI how to animate. You’re describing how it should look in different states.

Once that clicks, animations become predictable.

Start small:

  • Animate size
  • Then color
  • Then combine them

After a few screens, you’ll stop thinking about “animations” entirely and just think in terms of state transitions.

That’s when Compose starts to feel natural.

A Complete Developer's Guide to Faster Apps

Android 16 KB Page Size: A Complete Developer’s Guide to Faster Apps

Most Android performance improvements land as a framework update or a new API. This one is different. Starting with Android 15, Google added support for a 16 KB page size on ARM64 devices — and with Android 16, it’s becoming a hard requirement for apps that target new hardware.

If you haven’t looked into this yet, now is a good time. Apps that ship 4 KB-aligned native libraries will fail to load on 16 KB page-size devices. The failure isn’t graceful — it’s an UnsatisfiedLinkError and a crash.

This guide covers what the change is, which apps are affected, how to check your own APK, and what to actually do about it.

Memory Pages: A Quick Refresher

The OS doesn’t allocate memory one byte at a time — it works in fixed-size blocks called pages. For decades, Android (like most Linux systems) used a 4 KB page size. That made sense when RAM was limited and apps were simpler.

Modern flagship devices are a different story. They have multiple gigabytes of RAM, 64-bit ARM processors, and apps that load dozens of native libraries at startup. Managing all of that in 4 KB chunks means more page table entries, more TLB pressure, and more overhead on every app launch.

16 KB pages reduce that overhead. The OS manages fewer, larger chunks — fewer page faults at startup, fewer TLB misses during execution, and less kernel bookkeeping overall.

Why Google Made the Change

The performance case is real:

Faster cold starts. Fewer pages need to be mapped during app startup. Google’s benchmarks showed cold launch improvements of up to 30% on devices running a 16 KB page-size kernel.

Better TLB efficiency. The TLB (Translation Lookaside Buffer) is a small hardware cache that maps virtual addresses to physical memory. With 16 KB pages, each TLB entry covers four times more memory, which means fewer misses on cache-heavy operations.

Less kernel overhead. Fewer pages means a smaller page table. The kernel spends less time on memory management and more time running your code.

Industry alignment. Apple has used 16 KB pages on ARM devices for years. The mainline Linux kernel has progressively added support too. Android isn’t ahead of the curve here — it’s catching up.

Where Things Stand in 2026

  • Android 15 introduced 16 KB page size support in the emulator so developers could start testing.
  • Android 16 is expected to require 16 KB compliance for apps targeting API 36 on supported hardware.
  • Pixel 9 and later are expected to ship with kernels configured for 16 KB pages.
  • Play Console already shows warnings for apps that bundle 4 KB-aligned .so files when targeting API 35+.

The install base of 16 KB devices is still small, but it will grow quickly as new flagships ship. Getting ahead of this now is much easier than scrambling when Play starts rejecting updates.

Does This Affect Your App?

It depends entirely on whether your app includes native code.

Pure Kotlin or Java apps

You’re largely fine. The Android Runtime handles .dex alignment automatically, so managed code isn’t affected. The one thing to watch is third-party SDKs — they sometimes bundle native .so files you didn’t write and may not have checked.

Apps with NDK or native libraries

This is where the requirement has real teeth. If your app includes:

  • Native libraries (.so files) built with the NDK
  • Pre-built .so files from third-party SDKs
  • A game engine like Unity or Cocos2d
  • Audio, video, or image processing libraries with native bindings

…then every one of those .so files needs to be compiled with 16 KB-aligned ELF segments. If any aren’t, the OS on a 16 KB device will refuse to load them.

Check Your APK

Before touching any build config, find out where you actually stand.

Use readelf on your .so files

Bash
# Unzip the APK
unzip your-app.apk -d app-contents

# Inspect a native library
readelf -l app-contents/lib/arm64-v8a/libyourlibrary.so | grep LOAD

Look at the alignment column on the right side of each LOAD segment line:

  • 0x4000 = 16384 bytes = 16 KB compliant
  • 0x1000 = 4096 bytes = 4 KB needs recompiling

Compliant output:

LOAD  0x000000 ... 0x001abc 0x001abc R   0x4000
LOAD 0x002000 ... 0x005def 0x005def R E 0x4000

Non-compliant output:

LOAD  0x000000 ... 0x001abc 0x001abc R   0x1000

Do this for every .so in the APK, not just the ones you wrote. Third-party libraries need to pass too.

Run AGP’s built-in lint check

Android Gradle Plugin 8.5+ includes a lint check specifically for this. Run:

./gradlew lint

Look for warnings tagged PageSizeAlignment. They’ll call out each non-compliant library by name.

Fix Your Own Native Libraries

If you maintain native code with the NDK, the fix is a single linker flag.

With CMake

CMake
# CMakeLists.txt

cmake_minimum_required(VERSION 3.22.1)
project(MyNativeLib)

add_library(
    mynativelib
    SHARED
    src/main/cpp/mynativelib.cpp
)

# Tell the linker to align ELF LOAD segments to 16 KB boundaries
target_link_options(mynativelib PRIVATE "-Wl,-z,max-page-size=16384")

find_library(log-lib log)

target_link_libraries(
    mynativelib
    ${log-lib}
)

The flag -Wl,-z,max-page-size=16384 passes max-page-size=16384 directly to the linker. It sets the alignment of every LOAD segment in the output .so to 16 KB. That’s all the change requires on your end.

With ndk-build

CMake
# Android.mk

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE    := mynativelib
LOCAL_SRC_FILES := mynativelib.cpp

# 16 KB page size alignment
LOCAL_LDFLAGS   := -Wl,-z,max-page-size=16384

include $(BUILD_SHARED_LIBRARY)

After rebuilding, re-run the readelf check to confirm the alignment value changed from 0x1000 to 0x4000.

One thing worth knowing: a 16 KB-aligned .so runs fine on 4 KB devices too. The extra alignment padding is harmless on older hardware. You don’t need separate builds — one .so covers both.

Kotlin: What You Need to Handle

Kotlin doesn’t control ELF alignment, but there are places where Kotlin code loads native libraries and should handle failures gracefully.

Safe native library loading

System.loadLibrary() throws UnsatisfiedLinkError if a .so fails to load — which on a 16 KB device usually means the library isn’t aligned. Without handling this, the app just crashes.

Kotlin
// NativeLibraryLoader.kt

object NativeLibraryLoader {

    private const val TAG = "NativeLibraryLoader"

    /**
     * Loads a native library and returns false (instead of crashing)
     * if it fails. On 16 KB page-size devices, an UnsatisfiedLinkError
     * usually means the .so wasn't compiled with max-page-size=16384.
     */
    fun loadSafely(libraryName: String): Boolean {
        return try {
            System.loadLibrary(libraryName)
            Log.d(TAG, "Loaded: lib$libraryName.so")
            true
        } catch (e: UnsatisfiedLinkError) {
            Log.e(
                TAG,
                "Failed to load lib$libraryName.so — possible 16 KB alignment issue. " +
                "Recompile with: -Wl,-z,max-page-size=16384",
                e
            )
            false
        } catch (e: SecurityException) {
            Log.e(TAG, "Security exception loading lib$libraryName.so", e)
            false
        }
    }
}

Use it in your Activity or Application:

Kotlin
// MainActivity.kt

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val loaded = NativeLibraryLoader.loadSafely("mynativelib")

        if (!loaded) {
            showCompatibilityError()
        }
    }

    private fun showCompatibilityError() {
        AlertDialog.Builder(this)
            .setTitle("Compatibility Issue")
            .setMessage(
                "A required component couldn't load on this device. " +
                "Try updating the app to get the latest compatibility fixes."
            )
            .setPositiveButton("OK", null)
            .show()
    }
}

This avoids a crash and gives the user a message they can actually act on, instead of a silent ANR.

Detecting page size at runtime

Sometimes you need to know which page size the device is using — for example, to decide whether to enable a feature backed by a library you haven’t fully audited yet.

Kotlin
// PageSizeDetector.kt

import android.system.Os
import android.system.OsConstants

/**
 * Reads the system page size at runtime using the POSIX sysconf API.
 * Returns 4096 on standard devices, 16384 on 16 KB page-size devices.
 */
object PageSizeDetector {

    fun getPageSizeInBytes(): Long {
        return Os.sysconf(OsConstants._SC_PAGESIZE)
    }

    fun is16KBPageSize(): Boolean {
        return getPageSizeInBytes() == 16384L
    }

    fun description(): String {
        return when (getPageSizeInBytes()) {
            4096L  -> "4 KB"
            16384L -> "16 KB"
            else   -> "${getPageSizeInBytes()} bytes (unknown)"
        }
    }
}

Log it at startup — it takes one line and has saved debugging time more than once when a crash report comes in from an unfamiliar device:

Kotlin
// App.kt

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Log.i("App", "Page size: ${PageSizeDetector.description()}")
    }
}

When you see a crash log from a device you can’t reproduce locally, the page size entry tells you whether you’re looking at an alignment problem or something else entirely.

Auditing bundled native libraries at debug time

This helper scans your app’s native library directory and lists every .so it finds. It won’t tell you the alignment directly (use readelf for that), but it gives you a complete list to work through — which matters when you’re auditing a project with a lot of dependencies.

Kotlin
// SdkCompatibilityChecker.kt

import java.io.File

/**
 * Lists all native libraries bundled in the APK at runtime.
 * Run this in debug builds to build your audit list.
 * For actual alignment verification, use readelf on each file.
 */
object SdkCompatibilityChecker {

    private const val TAG = "SdkCompatibilityChecker"

    fun findNativeLibraries(context: android.content.Context): List<String> {
        val nativeLibDir = File(context.applicationInfo.nativeLibraryDir)

        if (!nativeLibDir.exists() || !nativeLibDir.isDirectory) {
            Log.w(TAG, "No native library directory found.")
            return emptyList()
        }

        return nativeLibDir
            .listFiles { file -> file.name.endsWith(".so") }
            ?.map { it.name }
            ?: emptyList()
    }

    fun auditAndLog(context: android.content.Context) {
        val libs = findNativeLibraries(context)

        if (libs.isEmpty()) {
            Log.i(TAG, "No native libraries found.")
            return
        }

        Log.w(TAG, "Found ${libs.size} native libraries — verify each with readelf:")
        libs.forEach { Log.w(TAG, "  -> $it") }
    }
}

Wire it into your Application class behind a BuildConfig.DEBUG check:

Kotlin
class App : Application() {

override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            SdkCompatibilityChecker.auditAndLog(this)
        }
    }
}

Every debug run now logs a full list of native libraries. Paste it into a spreadsheet, mark which ones you own, and track the audit from there.

Test on a 16 KB Emulator

You don’t need a physical device for this. Android Studio ships with 16 KB emulator images.

Create the emulator

  1. Open Android StudioDevice Manager
  2. Click Create Device
  3. Pick a Pixel 8 or later hardware profile
  4. On the system image screen, select an image labelled “16k page size” (available for API 35 and API 36)
  5. Finish the setup and start the emulator

Confirm it’s configured correctly

adb shell getconf PAGE_SIZE

16384 means you’re on a 16 KB device. 4096 means something went wrong with the AVD setup.

What to watch for when running your app

  • Crash on launch → a native library failed to load; check Logcat for the library name
  • UnsatisfiedLinkError in Logcat → that specific .so is 4 KB aligned
  • App runs normally → you’re compliant

Dealing With Third-Party Libraries You Can’t Recompile

Your code might be clean, but one of your dependencies is shipping a 4 KB-aligned .so that you have no control over.

Option 1 — Contact the vendor. File a GitHub issue or support ticket referencing the Android 16 KB page size requirement. Most major SDKs (Firebase, Google Play Services, Crashlytics) are already compliant. Smaller or older SDKs may need a nudge.

Option 2 — Gate the feature at runtime. While you wait for the vendor to ship a fix, use PageSizeDetector to disable the feature on affected devices:

Kotlin
// FeatureManager.kt

object FeatureManager {

    /**
     * Returns false on 16 KB page-size devices if the underlying
     * native library hasn't been verified as compliant yet.
     * Flip this to true once your SDK vendor ships a fix.
     */
    fun isNativeFeatureEnabled(): Boolean {
        if (PageSizeDetector.is16KBPageSize()) {
            Log.w("FeatureManager", "Skipping native feature on 16 KB device — awaiting SDK update.")
            return false
        }
        return true
    }
}

Option 3 — Write a Kotlin fallback. For features where a fallback is feasible, have two paths: the native implementation for standard devices, and a pure Kotlin path for 16 KB devices until the library is updated.

Kotlin
// ImageProcessor.kt

class ImageProcessor {

    /**
     * Uses the fast native path on verified devices, falls back to
     * Kotlin on 16 KB page-size devices until the native library is updated.
     */
    fun processImage(bitmap: android.graphics.Bitmap): android.graphics.Bitmap {
        return if (FeatureManager.isNativeFeatureEnabled()) {
            processImageNative(bitmap)  // C++ via JNI
        } else {
            processImageKotlin(bitmap)  // Pure Kotlin fallback
        }
    }

    private external fun processImageNative(
        bitmap: android.graphics.Bitmap
    ): android.graphics.Bitmap

    private fun processImageKotlin(
        bitmap: android.graphics.Bitmap
    ): android.graphics.Bitmap {
        val copy = bitmap.copy(bitmap.config, true)
        // apply transformations
        return copy
    }
}

This keeps the app working on all devices. The Kotlin path is slower, but it beats a crash.

Google Play Requirements

Play Console already flags 4 KB-aligned libraries as warnings when you target API 34 or lower. However, for API 35 (Android 15) and above, 16 KB compliance is now mandatory for all new apps and updates. While Google initially allowed extensions, as of 2026, non-compliant apps with native code will face immediate rejection during the upload process.

Check Play Console → Release → App bundle explorer → [Select Version] → Supported page sizes for any warnings or “Not Supported” labels regarding native library alignment. Deal with them immediately to ensure your releases are not blocked.

Real Performance Numbers

The gains are genuine but not uniform across all app types:

Metric4 KB Pages16 KB Pages
Cold app launchBaselineUp to 30% faster
TLB miss rateHigherLower
Kernel page table sizeLargerSmaller
Memory fragmentationMoreLess
App RAM footprintBaselineMarginally higher

The trade-off: small allocations get rounded up to the next 16 KB boundary, so there’s a slight increase in memory usage. For most apps it’s a few hundred KB at most — well worth the startup speed improvement.

Migration Checklist

Kotlin
Android 16 KB Page Size - Pre-ship Checklist
=============================================

□ Unzipped APK and located all .so files under lib/arm64-v8a/
□ Ran readelf -l on each .so - confirmed LOAD alignment is 0x4000
□ Added -Wl,-z,max-page-size=16384 to CMakeLists.txt or Android.mk
□ Rebuilt native libraries - re-verified alignment with readelf
□ Audited all third-party .so files - opened tickets with non-compliant vendors
□ Added NativeLibraryLoader with UnsatisfiedLinkError handling
□ Added PageSizeDetector and logging to Application.onCreate()
□ Added SdkCompatibilityChecker to debug builds
□ Created a 16k page size AVD in Android Studio
□ Ran the app on the 16k emulator - no crashes, no UnsatisfiedLinkError
□ Ran ./gradlew lint - no PageSizeAlignment warnings
□ Checked Play Console - no native library alignment warnings

FAQ

Does this affect all Android devices right now?

No. The 16 KB page size requires specific kernel and hardware support. Older devices will keep using 4 KB pages. But as Pixel 9 and later devices ship with 16 KB kernels, the affected install base will grow steadily.

My app is pure Kotlin with no NDK. Do I need to do anything?

Probably not. ART handles alignment for managed code automatically. Just double-check your Gradle dependencies for any SDKs that bundle .so files — those are the only risk for a pure Kotlin app.

Will a 4 KB-aligned .so actually crash the app?

Yes. On a 16 KB page-size device, System.loadLibrary() will throw UnsatisfiedLinkError if the .so isn’t properly aligned. That’s an app crash unless you catch it.

Can one .so file work on both 4 KB and 16 KB devices?

Yes. A library compiled with -Wl,-z,max-page-size=16384 works fine on 4 KB devices — the extra alignment is just padding that gets ignored. You don’t need separate builds for different page sizes.

What about Unity?

Unity generates native .so files, so yes, it’s affected. Unity has been shipping fixes in recent LTS versions. Make sure you’re on an up-to-date Unity LTS release and rebuild your project after upgrading.

Conclusion

The Android 16 KB page size change is the kind of requirement that’s easy to ignore until it starts causing crashes on new hardware. The fix is straightforward if you own your native code — it’s one linker flag and a rebuild. The harder work is tracking down third-party SDKs that haven’t updated yet and building a plan for those.

Start by running the readelf check on your APK today. If everything comes back as 0x4000, you’re done. If not, the checklist above has every step you need.

Android Emulator Settings

Android Emulator Settings for Speed & Performance: A Practical Guide for Real-World Development

If you’ve worked with Android long enough, you already know this: emulator performance isn’t just about speed, it’s about consistency.

A fast emulator that behaves unpredictably is worse than a slightly slower one that’s stable.

This guide focuses on Android Emulator Settings that hold up in real-world development. Not just for solo projects, but for teams, CI pipelines, and production-grade workflows.

How to Think About Emulator Performance

Before changing settings, it helps to understand what actually impacts emulator performance.

There are three main bottlenecks:

  1. CPU virtualization overhead
  2. Memory pressure (host + emulator)
  3. GPU rendering pipeline

Most “tuning tips” online ignore this and suggest arbitrary numbers. In practice, performance tuning should be constraint-driven, not guesswork.

Core Android Emulator Settings That Make a Difference

Let’s go through the settings that consistently make a difference.

CPU Allocation: Less Is Often More

A common mistake is over-allocating CPU cores.

What works in practice:

  • 2 cores → stable baseline (recommended for most cases)
  • 3–4 cores → only if profiling shows CPU bottlenecks

Why this matters:
The emulator runs inside a virtualized environment. Giving it too many cores can increase context switching and hurt overall system responsiveness.

Rule of thumb:
If your host machine slows down, your emulator will too.

RAM Allocation: Avoid Starving the Host

This is where people usually overdo it.

  • Start with 2–4 GB
  • Increase only if you see real issues (UI lag, memory errors)

Giving the emulator too much RAM can slow down everything else on your system, which ends up hurting performance overall.

VM Heap Size

This one gets confused with RAM, but it’s not the same thing.

VM Heap controls how much memory an app inside the emulator can use, not the emulator itself.

  • Default value is usually fine
  • Increase only if you’re testing memory-heavy apps (large bitmaps, video, complex Compose UIs)

If you set it too high without a reason:

  • You won’t see real benefits
  • You may hide memory issues that show up on real devices

Practical note:
 If your app only runs after increasing VM Heap, that’s a signal to fix memory usage, not raise limits.

Watch for:

  • OutOfMemoryError
  • Frequent GC activity in Logcat
  • UI stutter caused by memory pressure

System Images: x86_64 vs ARM (Context Matters in 2026)

For most desktop environments:

  • x86_64 images → still the default for performance

However:

  • On Apple Silicon (ARM hosts), ARM images can perform better due to reduced translation overhead.

Takeaway:
Choose the image based on your host architecture, not habit.

Hardware Acceleration: Non-Negotiable

Without hardware acceleration, nothing else will save you.

  • Windows → WHPX / Hyper-V
  • Linux → KVM
  • macOS → Hypervisor.framework

If virtualization isn’t enabled in BIOS/UEFI, performance will collapse.

GPU Rendering: Prefer Hardware, Validate When Needed

Set graphics to:

  • Hardware (GLES 2.0 or 3.0)

This improves:

  • UI responsiveness
  • Frame rendering
  • Animation smoothness

When to switch to software:

  • Debugging rendering issues
  • Investigating device-specific GPU bugs

Resolution and Device Profile

Higher resolution increases GPU load.

Practical setup:

  • Use 720p or 1080p for daily development
  • Use higher resolutions only for layout validation

Avoid treating the emulator like a flagship device unless required.

Quick Boot vs Cold Boot: Know the Trade-Off

Quick Boot is convenient, but not always safe.

Use Quick Boot when:

  • Iterating during development
  • You need faster startup

Use Cold Boot when:

  • Running tests
  • Debugging inconsistent behavior
  • Working in CI environments

Snapshots can introduce subtle state issues that are hard to trace.

Settings for CI/CD and Teams Environments

This is where things usually break if you’re not careful.

Headless Emulator Configuration

In CI, always run the emulator in headless mode:

emulator -avd Pixel_API_34 -no-window -no-audio -no-boot-anim

Why:

  • Reduces resource usage
  • Improves startup time
  • Avoids GPU dependency issues

This reduces overhead and avoids GPU-related issues in CI.

Avoid Snapshots in CI

Snapshots make runs inconsistent.

  • Always use a clean start
  • Prefer cold boot

Consistency matters more than startup time in pipelines.

Resource Limits

If you’re running multiple emulators:

  • Stick to 2 cores and ~2 GB RAM per instance
  • Avoid running heavy builds on the same machine at the same time

Otherwise, performance becomes unpredictable.

Storage Still Matters

Run the emulator on an SSD.

You’ll notice faster:

  • Boot times
  • App installs
  • General responsiveness

On HDDs, even a well-configured setup will feel slow.

Internal Storage vs Expanded Storage

This part is easy to overlook, but it matters depending on what you’re testing.

Internal Storage (Data Partition):

  • Used by apps for installs, cache, databases
  • Affects app install speed and runtime behavior

Expanded Storage (SD Card):

  • Simulates external storage
  • Used for media, file access, downloads

What to do in practice:

  • Keep internal storage reasonable (2–6 GB) for most dev work
  • Increase this only if you frequently install large apps or test media-heavy use cases.
  • Use expanded storage only if your app relies on file APIs, media handling, or scoped storage.

Why this matters: Over-allocating storage doesn’t improve performance. It just increases disk usage and snapshot size.

For most workflows, default values are fine unless you have a specific need.

Kotlin Example: Detecting Emulator

Sometimes you want to adjust behavior when running on an emulator.

Kotlin
fun isProbablyEmulator(): Boolean {
    val fingerprint = android.os.Build.FINGERPRINT.lowercase()
    val model = android.os.Build.MODEL.lowercase()
    val manufacturer = android.os.Build.MANUFACTURER.lowercase()
    val brand = android.os.Build.BRAND.lowercase()
    val device = android.os.Build.DEVICE.lowercase()

    return (fingerprint.startsWith("generic") ||
        fingerprint.contains("vbox") ||
        model.contains("emulator") ||
        manufacturer.contains("genymotion") ||
        (brand.startsWith("generic") && device.startsWith("generic")))
}

When to Use This

This works for:

  • Debug toggles
  • Logging changes
  • Small performance adjustments

Don’t use it for anything security-related. It’s not reliable enough.

Common Pitfalls in Real Projects

These show up often in production teams:

  • Over-allocating CPU/RAM and slowing the host
  • Relying entirely on emulators instead of real devices
  • Using snapshots in automated testing
  • Ignoring host machine constraints
  • Running multiple heavy processes alongside the emulator

What This Guide Doesn’t Replace

Even with perfect Android Emulator Settings, you still need:

Real Device Testing

Emulators don’t fully replicate:

  • Thermal throttling
  • OEM customizations
  • Real GPU behavior

Performance Profiling

Use tools like:

  • Android Profiler
  • Systrace
  • Frame timing metrics

Tuning without measurement is guesswork.

Test Strategy

A solid setup includes:

  • Emulator for fast iteration
  • Real devices for validation
  • Cloud testing (e.g., device farms) for scale

A Reliable Baseline Configuration

If you want something that works in most environments:

  • CPU: 2 cores
  • RAM: 2–4 GB
  • Graphics: Hardware (GLES 2.0/3.0)
  • System Image: x86_64 (or ARM on Apple Silicon)
  • Storage: SSD
  • Boot Mode: Use Quick Boot for development and Cold Boot for CI.

This setup prioritizes stability over raw speed.

FAQs

What are the best Android Emulator settings in 2026?

Use hardware acceleration, x86_64 (or ARM on Apple Silicon), 2–4 GB RAM, and hardware GPU rendering.

How many CPU cores should I use?

Start with 2. Increase only if you actually need more.

Is Quick Boot safe?

Fine for development. For testing or CI, use cold boot.

Do I still need real devices?

Yes. Emulators don’t fully match real-world behavior.

Conclusion

There’s no perfect configuration that works for every setup.

The goal is simple: keep your system responsive and your emulator predictable.

Once your Android Emulator Settings are in a good place, you’ll spend less time waiting and more time building.

error: Content is protected !!