cd ../writing
// android · kotlin · the complete reference

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.

9 animation systems real Kotlin code Compose first © use freely

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) — ObjectAnimator and ValueAnimator. 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, MotionLayout for choreographed motion, SpringAnimation for 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.

✓ View.animate() — chained property animation
// 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:

  • withEndAction fires on cancel too. If the animation is interrupted by another animate() call, your end action still runs. Check state before acting on it, or use setListener with the onAnimationEnd(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 onDetachedFromWindow or use WeakReference in callbacks.
  • Only specific properties are animatable. alpha, translationX/Y/Z, rotation, rotationX/Y, scaleX/Y, x, y, z. Anything else needs ObjectAnimator.

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.

✓ ObjectAnimator — any property, any object
// 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()
}
✓ ValueAnimator — values without binding
// 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:

✓ AnimatorSet — sequential and parallel
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 Design standard easing via PathInterpolator
// 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.

✓ play-to-pause icon morph (AVD setup)
<!-- 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>
✓ trigger from Kotlin
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.

✓ beginDelayedTransition — automatic layout animation
// 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.

✓ shared element transition between activities
// 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.

✓ MotionScene — collapsing header example
<!-- 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>
✓ trigger from Kotlin
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.

✓ SpringAnimation — natural settling motion
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 — momentum and friction
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.

✓ Lottie — JSON file from After Effects
<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.

✓ animateFloatAsState — the simplest pattern
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 Float
  • animateColorAsState — for any Color (correctly interpolates through color space)
  • animateDpAsState — for Dp values (sizes, paddings)
  • animateIntAsState, animateIntOffsetAsState, animateIntSizeAsState
  • animateOffsetAsState, animateSizeAsState, animateRectAsState
  • animateValueAsState — for any type, given a TwoWayConverter
✓ multiple animated properties on the same state
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 — appearing and disappearing
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 — content that changes by key
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 — when a simple fade is enough
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.

✓ updateTransition — multiple synchronized animations
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:

✓ Animatable — imperative animation in a coroutine
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:

✓ rememberInfiniteTransition — pulsing
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:

✓ animateContentSize — auto-animate text expansion
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 — 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:

✓ the five animation 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*AsState functions default to spring() with DampingRatioNoBouncy and StiffnessMedium — a fast, no-bounce settle. Override with explicit tween() when you want predictable duration.
  • spring() is preferred for interactive/gesture-driven motion. tween() for orchestrated, predictable animations (transitions, loops).
  • visibilityThreshold on 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.

✓ TwoWayConverter for a custom type
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:

✓ frame-perfect animation with withFrameNanos
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, scale on a View are composited on the GPU. Animating them costs almost nothing.
  • Property changes that trigger layout are expensive. width, height, padding changes, anything inside requestLayout(). 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 Bitmap or Path on every frame, you'll see jank on weaker devices. Cache objects outside the animation loop; remember them in Compose.
  • Compose's recomposition is cheap, not free. An animate*AsState recomposes 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:

✓ check animator duration scale
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: SpringAnimation on Views, AnimatedVisibility with slideInVertically in Compose
  • Icon morph (hamburger to X): AnimatedVectorDrawable
  • Auto-animate layout change after data updates: TransitionManager.beginDelayedTransition on Views; animateContentSize or updateTransition in Compose
  • Collapsing toolbar with multi-element choreography: MotionLayout on Views; SubcomposeLayout + updateTransition in Compose
  • After-Effects-quality designer animation: Lottie
  • Gesture-driven motion (drag-to-dismiss, swipe, drag-and-drop): SpringAnimation/FlingAnimation on Views; Animatable + gesture detector in Compose
  • Tab swap with directional motion: AnimatedContent with directional spec
  • Image-to-detail morph across screens: Activity shared element transitions; SharedTransitionLayout in Compose
  • Custom particle / waveform / canvas animation: withFrameNanos in Compose
  • Anything that runs forever: rememberInfiniteTransition in Compose; ObjectAnimator with repeatCount = INFINITE on 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.