Every Android animation explained — from Views to Compose.
Android has accumulated nine distinct animation systems over fifteen years. Each was the right answer at the time. Together they make the platform powerful and confusing — pick the wrong one and you'll fight the framework for weeks. This guide covers every animation API a Kotlin Android developer can use today: View.animate(), ObjectAnimator, AnimatedVectorDrawable, the Transition framework, MotionLayout, physics-based springs, Lottie, and the full Jetpack Compose animation surface — animate*AsState, AnimatedVisibility, AnimatedContent, updateTransition, Animatable, shared element transitions, and lookahead layouts. With opinions about when each one earns its place.
01The animation systems Android has
Listing them in chronological order, because that's how the mess accumulated:
- View Animation (API 1, 2008) — XML-driven, animates only visual representation. Never use this for new code.
- Drawable animations (API 1) — frame-by-frame sequences via
AnimationDrawable. Mostly replaced by AVDs. - Property Animation (API 11, 2011) —
ObjectAnimatorandValueAnimator. Animates any property of any object. Still the right call for many View-based scenarios. - Transitions / Scenes (API 19, 2014) —
TransitionManager.beginDelayedTransition()and shared element transitions. The bridge from one layout state to another. - AnimatedVectorDrawable (API 21, 2014) — vector graphics that animate via XML or Kotlin. The right tool for icon morphs.
- MotionLayout (2018) — declarative motion design built on top of ConstraintLayout. The most ambitious of the View-era systems.
- Physics-based animation (2017, AndroidX) —
SpringAnimation,FlingAnimation. Time-independent motion that responds to interruption naturally. - Lottie (Airbnb, 2017) — third-party, JSON files exported from After Effects. The escape hatch for designer-driven animation.
- Jetpack Compose animations (2021) — an entirely new system built around state and recomposition. The default for new code in 2026.
02When to use what
The short version:
- New code, Compose UI: use Compose animations exclusively. Skip the rest unless you're interoperating with View-based code.
- New code, View-based UI: use
View.animate()for simple property changes,MotionLayoutfor choreographed motion,SpringAnimationfor natural-feeling reactive motion, and AVDs for icon morphs. - Legacy code with XML animations: migrate when you touch that screen, don't migrate proactively. The old systems still work and shipping today.
- Anything designed in After Effects: Lottie. Don't reimplement.
The rest of this guide goes through each system, what it does well, where it bites you, and the patterns that hold up in production.
03View.animate() — the fluent API
The simplest property animation API on Android. Every View exposes animate() which returns a ViewPropertyAnimator — a chainable builder for animating alpha, translation, rotation, scale, and a few others. This is the API to reach for first when you need a View to fade, slide, rotate, or scale.
// Fade in and slide up simultaneously
view.animate()
.alpha(1f)
.translationY(0f)
.setDuration(300)
.setInterpolator(AccelerateDecelerateInterpolator())
.withStartAction { view.visibility = View.VISIBLE }
.withEndAction { onComplete() }
.start()
// Slide off-screen and remove on completion
view.animate()
.translationX(view.width.toFloat())
.alpha(0f)
.setDuration(250)
.withEndAction { parent.removeView(view) }
Three traps worth knowing:
withEndActionfires on cancel too. If the animation is interrupted by anotheranimate()call, your end action still runs. Check state before acting on it, or usesetListenerwith theonAnimationEnd(isReverse: Boolean)overload that distinguishes cancellation.- Animations leak Views. If the View is detached before the animation completes, you can leak it through an internal reference. Cancel animations in
onDetachedFromWindowor useWeakReferencein callbacks. - Only specific properties are animatable.
alpha,translationX/Y/Z,rotation,rotationX/Y,scaleX/Y,x,y,z. Anything else needsObjectAnimator.
04ObjectAnimator and ValueAnimator
When View.animate() isn't enough, ObjectAnimator can animate any property on any object that has a setter — your custom views, drawables, whatever. ValueAnimator is the lower-level building block: it produces values over time without binding to any property.
// Animate elevation on a CardView
ObjectAnimator.ofFloat(cardView, "elevation", 0f, 12f).apply {
duration = 200
interpolator = DecelerateInterpolator()
start()
}
// Animate a color (handles ARGB interpolation correctly)
ObjectAnimator.ofArgb(
view, "backgroundColor",
Color.RED, Color.BLUE
).apply {
duration = 800
start()
}
// Multiple properties at once via PropertyValuesHolder
val scaleX = PropertyValuesHolder.ofFloat("scaleX", 1f, 1.2f)
val scaleY = PropertyValuesHolder.ofFloat("scaleY", 1f, 1.2f)
ObjectAnimator.ofPropertyValuesHolder(view, scaleX, scaleY).apply {
duration = 150
repeatMode = ValueAnimator.REVERSE
repeatCount = 1
start()
}
// Pure value stream — apply to anything you want
ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1000
interpolator = LinearInterpolator()
addUpdateListener { animator ->
val fraction = animator.animatedValue as Float
// Use the fraction however you need — drawing, audio, network throttle, anything
canvas.drawProgress(fraction)
}
start()
}
Orchestrate multiple animators with AnimatorSet:
val fadeIn = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f)
val slideUp = ObjectAnimator.ofFloat(view, "translationY", 100f, 0f)
val scale = ObjectAnimator.ofFloat(view, "scaleX", 0.8f, 1f)
AnimatorSet().apply {
playTogether(fadeIn, slideUp, scale) // or playSequentially(...)
duration = 300
start()
}
XML-defined animators live in res/animator/ and load with AnimatorInflater.loadAnimator(). Useful when designers want to author the animation, but adds indirection — most teams just write the animator in Kotlin where the rest of the logic lives.
05Interpolators — the timing curves
An interpolator maps linear time (0 → 1) to a non-linear progress curve. Picking the right interpolator is what separates "competent animation" from "feels alive." Android ships several built-ins:
LinearInterpolator— no easing. Use only for things like continuous rotation where easing would look wrong.AccelerateInterpolator— starts slow, ends fast. For "off-screen exits."DecelerateInterpolator— starts fast, ends slow. For "on-screen entrances."AccelerateDecelerateInterpolator— sigmoid curve. The default for in-place changes.OvershootInterpolator— overshoots the target then settles. Playful, use sparingly.AnticipateInterpolator— pulls back before going forward. The flip side of overshoot.BounceInterpolator— bounces at the end. Almost always too cute for real apps.PathInterpolator— accepts cubic-bezier control points. The right tool when you want a specific feel.
// Material 3 "emphasized" easing — perfect for primary motion
val emphasized = PathInterpolator(0.2f, 0f, 0f, 1f)
// Material 3 "standard" easing — for short, mechanical motion
val standard = PathInterpolator(0.2f, 0f, 0f, 1f)
view.animate().translationY(0f).setInterpolator(emphasized).setDuration(400).start()
The rule of thumb: use deceleration for entrances (slowing into place), acceleration for exits (gathering speed off-screen), and a sigmoid for transformations in place. Avoid linear timing for anything except mechanical loops.
06Drawable animations — AnimatedVectorDrawable
AnimatedVectorDrawable (AVD) is the right tool for icon morphs — hamburger to X, play to pause, checkmark draw-on. AVDs are vector drawables (XML) with attached animations (also XML) that drive their path data or transform properties.
<!-- res/drawable/play_icon.xml — VectorDrawable -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:name="morph"
android:fillColor="#FFF"
android:pathData="M8,5 L8,19 L19,12 Z" />
</vector>
<!-- res/drawable/play_to_pause.xml — AnimatedVectorDrawable -->
<animated-vector xmlns:android="..."
android:drawable="@drawable/play_icon">
<target android:name="morph">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="pathData"
android:valueFrom="M8,5 L8,19 L19,12 Z"
android:valueTo="M6,5 L10,5 L10,19 L6,19 Z M14,5 L18,5 L18,19 L14,19 Z"
android:valueType="pathType"
android:duration="300" />
</aapt:attr>
</target>
</animated-vector>
val avd = AppCompatResources.getDrawable(context, R.drawable.play_to_pause)
as AnimatedVectorDrawableCompat
imageView.setImageDrawable(avd)
avd.start()
// Listen for completion (e.g. to swap to the reverse drawable)
avd.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable?) {
// Pause → play swap
}
})
Two crucial constraints for pathData morphs: the source and target paths must have the same number of commands AND the same command types in the same order. Otherwise the morph silently fails to interpolate. ShapeShifter (the web tool) helps build valid path-morph pairs.
Related drawables worth knowing:
AnimatedStateListDrawable— define drawables for different View states (pressed, focused, etc.) AND the transitions between them. The cleanest way to animate state changes on a button.AnimationDrawable— frame-by-frame from a list of static drawables. Mostly obsolete; use AVDs or Lottie instead.
07The Transition framework
The Transition framework solves a specific problem: you have layout state A, you change the layout to state B, and you want every property change in between to animate automatically. Done by hand this is painful; the Transition framework makes it one line.
// Before changing layout state, call beginDelayedTransition.
// All View property changes until next frame will be animated.
TransitionManager.beginDelayedTransition(rootViewGroup, AutoTransition())
expandedView.visibility = View.VISIBLE
titleView.textSize = 24f
iconView.animate().rotation(180f).start()
The framework captures the "before" state of every View in the hierarchy, runs your layout change, captures the "after" state, then animates the differences. Position, size, visibility, even background changes — all animated automatically.
Built-in Transition types:
AutoTransition— fade out leaving, move/resize existing, fade in arriving. The sensible default.Fade— cross-fade only.Slide— slide in/out from a configurable edge.Explode— fly out from the center, or fly in to it.ChangeBounds— animate position and size changes.ChangeTransform— animate scale and rotation.ChangeImageTransform— animate ImageView scale type changes.
For activity-to-activity transitions, the most valuable feature is shared element transitions: an element appears in both activities, and the framework morphs it from its position/size in screen A to its position/size in screen B. This is what makes "tap an image, it expands to fill the detail screen" feel cinematic.
// In the launching Activity
val options = ActivityOptions.makeSceneTransitionAnimation(
this, sharedImageView, "hero_image"
)
startActivity(intent, options.toBundle())
// In the destination Activity — element with the same transitionName
<ImageView android:transitionName="hero_image" ... />
Custom Transitions extend the Transition class with captureStartValues, captureEndValues, and createAnimator. Most teams stick with built-ins; custom Transitions are powerful but verbose.
08MotionLayout — declarative motion
MotionLayout is the most ambitious View-era animation system. It extends ConstraintLayout with the ability to define multiple ConstraintSet states (a "start" and an "end") plus the trigger and timing for interpolating between them. The full motion is defined in XML; the Kotlin side just tells it when to play.
<!-- res/xml/scene_collapse.xml -->
<MotionScene xmlns:app="...">
<Transition
app:constraintSetStart="@id/expanded"
app:constraintSetEnd="@id/collapsed"
app:duration="500">
<OnSwipe
app:touchAnchorId="@id/header"
app:dragDirection="dragUp" />
<KeyFrameSet>
<KeyAttribute
app:motionTarget="@id/title"
app:framePosition="50"
android:rotation="-5" />
</KeyFrameSet>
</Transition>
<ConstraintSet android:id="@+id/expanded">...</ConstraintSet>
<ConstraintSet android:id="@+id/collapsed">...</ConstraintSet>
</MotionScene>
motionLayout.setTransitionListener(object : MotionLayout.TransitionListener {
override fun onTransitionCompleted(layout: MotionLayout, state: Int) {
if (state == R.id.collapsed) { /* ... */ }
}
})
motionLayout.transitionToEnd() // or .transitionToStart(), .setProgress(0.5f)
Where MotionLayout shines: complex choreographed motion (collapsing toolbars, swipe-driven reveal, multi-step onboarding) where the animation is the UI rather than decoration. Where it stops being worth it: anything Compose can express in fewer lines.
09Physics-based animation — Spring and Fling
Time-based animations (everything covered so far) have a problem: they're brittle to interruption. If a user touches a falling card mid-animation, what should happen? Time-based animators stop, snap, or fight the user. Physics-based animations don't — they treat the property as a physical body and apply forces to it.
val spring = SpringAnimation(view, DynamicAnimation.TRANSLATION_Y).apply {
spring = SpringForce().apply {
finalPosition = 0f
stiffness = SpringForce.STIFFNESS_MEDIUM
dampingRatio = SpringForce.DAMPING_RATIO_LOW_BOUNCY
}
}
view.translationY = 300f
spring.start()
// User can touch and drag the view mid-animation; just call spring.start() again with new endpoint
Two parameters define the feel:
stiffness— how strongly the spring pulls toward the target. Higher = faster settling. Constants:STIFFNESS_VERY_LOW(50),STIFFNESS_LOW(200),STIFFNESS_MEDIUM(1500, the default),STIFFNESS_HIGH(10000).dampingRatio— how much oscillation. 0.0 = endless bounce (don't use). 0.2 = playful bounce. 0.5 = LOW_BOUNCY constant. 0.75 = MEDIUM_BOUNCY. 1.0 = critically damped (no oscillation, fastest settle).
FlingAnimation handles momentum-based motion — exactly what you want for scroll fling or swipe-to-dismiss. Initial velocity, friction, optional minimum/maximum values.
FlingAnimation(view, DynamicAnimation.TRANSLATION_X).apply {
setStartVelocity(2500f) // pixels per second
setMinValue(-screenWidth.toFloat())
setMaxValue(screenWidth.toFloat())
friction = 1.1f
addEndListener { _, _, value, _ ->
if (kotlin.math.abs(value) > dismissThreshold) dismiss()
}
start()
}
Physics-based animations are interruptible by design. Touch a spring mid-animation, change the target, and it smoothly transitions to the new endpoint without snapping. This is the property that makes them indispensable for gesture-driven UI.
10Lottie — when third-party makes sense
Lottie (from Airbnb) renders animations exported from After Effects as JSON. The designer authors the animation in After Effects, exports via Bodymovin to JSON, you drop the JSON file in res/raw/ and add a LottieAnimationView. The animation plays at full vector quality on any screen, with no further engineering.
<com.airbnb.lottie.LottieAnimationView
android:layout_width="200dp"
android:layout_height="200dp"
app:lottie_rawRes="@raw/celebration"
app:lottie_autoPlay="true"
app:lottie_loop="false" />
// Programmatic control
animationView.setAnimation(R.raw.celebration)
animationView.addAnimatorListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) { onComplete() }
})
animationView.playAnimation()
When Lottie is the right call:
- The animation was designed in After Effects (most professional animations are)
- The animation is complex enough that reimplementing it in code would take days
- The animation needs to be iterated on by a designer without engineering involvement
- Brand/celebration moments where the animation IS the product (Duolingo's character animations, Headspace's onboarding)
When Lottie is the wrong call:
- Simple property animations a junior engineer could write in 10 minutes
- Anything that needs to respond dynamically to runtime state (Lottie playback is largely pre-baked)
- When the JSON file is over a few hundred KB — Lottie has performance cliffs with very complex animations
11Jetpack Compose — animate*AsState, the daily driver
Compose's animation surface is the most coherent of any UI toolkit on Android. The fundamental idea: any state change can be animated by wrapping the value in animateXAsState. The composable reads the animated value and recomposes; the actual animation happens automatically.
var expanded by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
animationSpec = tween(durationMillis = 300),
label = "chevron-rotation"
)
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.rotate(rotation).clickable { expanded = !expanded }
)
That's the whole pattern. Change the target, the value animates. The full family:
animateFloatAsState— for any FloatanimateColorAsState— for any Color (correctly interpolates through color space)animateDpAsState— for Dp values (sizes, paddings)animateIntAsState,animateIntOffsetAsState,animateIntSizeAsStateanimateOffsetAsState,animateSizeAsState,animateRectAsStateanimateValueAsState— for any type, given aTwoWayConverter
var selected by remember { mutableStateOf(false) }
val bg by animateColorAsState(if (selected) Color.Magenta else Color.Gray, label = "bg")
val scale by animateFloatAsState(if (selected) 1.1f else 1f, label = "scale")
val radius by animateDpAsState(if (selected) 24.dp else 8.dp, label = "radius")
Box(modifier = Modifier
.scale(scale)
.clip(RoundedCornerShape(radius))
.background(bg)
.clickable { selected = !selected }
)
The label parameter shows up in Android Studio's Animation Preview, letting you scrub through animations in the IDE. Always set it for any animation you'd want to debug visually.
12Compose — AnimatedVisibility and AnimatedContent
Two composables handle the "something appears or changes" cases that animate*AsState can't express:
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
DetailContent() // only composed while visible (or animating in/out)
}
The enter/exit transitions compose with +. The full set:
- Fade:
fadeIn,fadeOut - Slide:
slideIn,slideOut,slideInVertically,slideOutHorizontally, etc. - Scale:
scaleIn,scaleOut - Expand/Shrink:
expandIn,shrinkOut,expandHorizontally,shrinkVertically
AnimatedContent(
targetState = currentTab,
transitionSpec = {
// Direction-aware slide
(slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it } + fadeOut())
},
label = "tab-content"
) { tab ->
when (tab) {
Tab.Home -> HomeScreen()
Tab.Search -> SearchScreen()
Tab.Profile -> ProfileScreen()
}
}
AnimatedContent animates whenever the targetState value changes, crossfading or sliding the old content out and the new content in. The transitionSpec defines how — fadeIn() togetherWith fadeOut() for a simple crossfade, or directional slides for navigation transitions.
Crossfade is the convenience wrapper for the most common case:
Crossfade(targetState = loadingState, label = "loading-state") { state ->
when (state) {
Loading -> LoadingSpinner()
Success -> Content()
Error -> ErrorView()
}
}
13Compose — orchestrated animations
For animations where multiple values need to stay in sync (a parent state drives several child animations), updateTransition gives you one source of truth.
val transition = updateTransition(targetState = isExpanded, label = "expand")
val elevation by transition.animateDp(label = "elevation") { expanded ->
if (expanded) 16.dp else 2.dp
}
val padding by transition.animateDp(label = "padding") { if (it) 24.dp else 12.dp }
val color by transition.animateColor(
transitionSpec = { tween(600) }, // custom spec per value
label = "color"
) { if (it) Color.Magenta else Color.DarkGray }
Card(modifier = Modifier.shadow(elevation).padding(padding), color = color) { ... }
All three animations run from the same transition, which guarantees they're perfectly in sync. Per-value transitionSpecs let you customize each one (different durations, different easings) while keeping them coordinated.
For imperative control — animating from a coroutine, animating in response to events that aren't reducible to state changes — use Animatable:
val offset = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(modifier = Modifier
.offset { IntOffset(offset.value.toInt(), 0) }
.clickable {
scope.launch {
offset.animateTo(200f, spring(stiffness = Spring.StiffnessLow))
offset.animateTo(0f, tween(400))
}
}
)
Three things make Animatable special: it's interruption-safe (calling animateTo again cancels the in-flight animation and smoothly transitions to the new target), it lives outside recomposition (suitable for gesture handling), and you can snapTo a value instantly without animation when needed.
14Compose — infinite, content-size, shared elements
rememberInfiniteTransition for loops:
val infinite = rememberInfiniteTransition(label = "pulse")
val alpha by infinite.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
Box(Modifier.alpha(alpha).background(Color.Red))
Modifier.animateContentSize() auto-animates size changes when content grows or shrinks:
Text(
text = if (expanded) longText else shortText,
maxLines = if (expanded) Int.MAX_VALUE else 2,
modifier = Modifier.animateContentSize(
animationSpec = tween(durationMillis = 300)
).clickable { expanded = !expanded }
)
Shared element transitions (stable in Compose 1.7+) — the Compose equivalent of activity shared element transitions. Useful for image-to-detail navigation and similar element morphs across screens.
SharedTransitionLayout {
AnimatedContent(targetState = currentDetail, label = "detail-nav") { detail ->
if (detail == null) {
ListScreen(
onItemClick = { currentDetail = it },
animatedVisibilityScope = this@AnimatedContent
)
} else {
DetailScreen(
item = detail,
animatedVisibilityScope = this@AnimatedContent
)
}
}
}
// Inside both ListScreen and DetailScreen:
Image(
modifier = Modifier.sharedElement(
rememberSharedContentState(key = "image-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope
)
)
Matching sharedElement modifiers in both screens with the same key — and the framework morphs the element from its position/size in the source screen to its position/size in the destination. The motion is butter-smooth and impossible to achieve manually with reasonable effort.
15Compose — animation specs in depth
Every Compose animation function accepts an animationSpec that controls timing and curve. The five built-in spec types:
// 1. tween — duration-based with easing curve
tween(
durationMillis = 300,
delayMillis = 0,
easing = FastOutSlowInEasing
)
// 2. spring — physics-based, no fixed duration
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
visibilityThreshold = 0.01f
)
// 3. keyframes — multiple waypoints with custom timing
keyframes {
durationMillis = 600
0f at 0
0.5f at 100 using LinearOutSlowInEasing
0.8f at 300
1f at 600
}
// 4. snap — no animation, instant value change with optional delay
snap(delayMillis = 100)
// 5. repeatable / infiniteRepeatable — wrap any of the above
repeatable(
iterations = 3,
animation = tween(400),
repeatMode = RepeatMode.Reverse
)
Easing curves available out of the box: LinearEasing, FastOutSlowInEasing (the Material default), FastOutLinearInEasing, LinearOutSlowInEasing, plus CubicBezierEasing(a, b, c, d) for custom curves.
Defaults that are worth memorizing:
- Most
animate*AsStatefunctions default tospring()withDampingRatioNoBouncyandStiffnessMedium— a fast, no-bounce settle. Override with explicittween()when you want predictable duration. spring()is preferred for interactive/gesture-driven motion.tween()for orchestrated, predictable animations (transitions, loops).visibilityThresholdon spring controls when the animation "snaps" to the target. The default works for most cases; adjust for very small or very large values to avoid over/under-shoot.
16Compose — animating custom types
For types Compose doesn't ship animators for, write a TwoWayConverter that maps your type to/from an AnimationVector (1D, 2D, 3D, or 4D). Then any animateValueAsState or Animatable works with it.
data class PolarOffset(val radius: Float, val angle: Float)
val polarConverter = TwoWayConverter<PolarOffset, AnimationVector2D>(
convertToVector = { AnimationVector2D(it.radius, it.angle) },
convertFromVector = { PolarOffset(it.v1, it.v2) }
)
val position by animateValueAsState(
targetValue = targetPolar,
typeConverter = polarConverter,
label = "polar"
)
17Custom Canvas animations — when nothing else fits
Sometimes none of the above is right and you need frame-perfect control — a particle system, a physics simulation, a custom waveform. In Compose, the pattern is withFrameNanos inside a LaunchedEffect:
var phase by remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
val start = withFrameNanos { it }
while (isActive) {
withFrameNanos { now ->
val elapsed = (now - start) / 1_000_000_000f
phase = elapsed * 2f * PI.toFloat()
}
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.width
val h = size.height
val path = Path().apply {
moveTo(0f, h/2)
for (x in 0..w.toInt()) {
val y = h/2 + sin(x * 0.02f + phase) * 50
lineTo(x.toFloat(), y)
}
}
drawPath(path, color = Color.Magenta, style = Stroke(width = 3.dp.toPx()))
}
withFrameNanos suspends until the next frame and gives you the frame's nanosecond timestamp. Looping this gives you per-frame execution that's automatically tied to the display's refresh rate. The animation pauses when the composable leaves the composition; it survives configuration changes naturally.
18Performance — what's expensive, what's cheap
Animation performance on Android comes down to a few rules:
- Hardware-accelerated properties are nearly free.
alpha,translation,rotation,scaleon a View are composited on the GPU. Animating them costs almost nothing. - Property changes that trigger layout are expensive.
width,height, padding changes, anything insiderequestLayout(). Avoid animating these on the main path; use a scale transform instead and rely on layout only at the endpoints. - Drawable allocation per frame is the silent killer. If your animation creates a new
BitmaporPathon every frame, you'll see jank on weaker devices. Cache objects outside the animation loop;rememberthem in Compose. - Compose's recomposition is cheap, not free. An
animate*AsStaterecomposes the reading composable every frame. Keep the reading scope tight — wrap the animated value in a small composable that doesn't pull in expensive content. - Profile, don't guess. Android Studio's Profiler shows you exactly which frames missed deadline. The Layout Inspector's Animation panel scrubs through running animations.
19Accessibility — respect reduce-motion
Some users have animation disabled at the OS level for vestibular reasons. Animations that ignore this can trigger nausea, headaches, or seizures. The Android API to check:
val animatorScale = Settings.Global.getFloat(
contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
)
if (animatorScale == 0f) {
// User has disabled animations — skip the animation, jump to end state
} else {
// Optionally multiply your durations by animatorScale
}
Compose makes this simpler — the LocalAccessibilityManager will tell you whether reduce-motion is enabled, and any animation built with the standard APIs respects the global scale automatically. If you write custom animations with withFrameNanos, check the scale manually and bypass when it's zero.
20The decision matrix
For a quick reference:
- Fading a View in/out:
view.animate().alpha() - Same, but in Compose:
animateFloatAsState+Modifier.alpha - Sliding a panel in:
SpringAnimationon Views,AnimatedVisibilitywithslideInVerticallyin Compose - Icon morph (hamburger to X):
AnimatedVectorDrawable - Auto-animate layout change after data updates:
TransitionManager.beginDelayedTransitionon Views;animateContentSizeorupdateTransitionin Compose - Collapsing toolbar with multi-element choreography:
MotionLayouton Views;SubcomposeLayout+updateTransitionin Compose - After-Effects-quality designer animation: Lottie
- Gesture-driven motion (drag-to-dismiss, swipe, drag-and-drop):
SpringAnimation/FlingAnimationon Views;Animatable+ gesture detector in Compose - Tab swap with directional motion:
AnimatedContentwith directional spec - Image-to-detail morph across screens: Activity shared element transitions;
SharedTransitionLayoutin Compose - Custom particle / waveform / canvas animation:
withFrameNanosin Compose - Anything that runs forever:
rememberInfiniteTransitionin Compose;ObjectAnimatorwithrepeatCount = INFINITEon Views
∞The bigger picture
Android animation is a microcosm of the platform itself: every system that exists today existed for a good reason at the time, and the result is more API surface than any one developer can keep in their head. The good news is that 95% of new code only needs Compose's animation surface — animate*AsState for the easy cases, AnimatedVisibility/AnimatedContent for appearance and content changes, updateTransition for orchestration, Animatable for full control, and withFrameNanos when nothing else fits.
The View-era systems still ship today and will for the foreseeable future. Knowing them isn't optional if you maintain existing apps. But for green-field code, the rule is simple: use Compose, write less, ship better animations.
The premium feeling of the best Android apps — Threema's transitions, Linear's motion, Things' physics — comes from picking the right system for each moment. None of this is hidden API. The patterns above are everything those teams use, available to every developer with the right tool selection.