Skip to main content

Animation

viewcompose-animation integrates the platform-neutral animation engine with ViewCompose state, composition effects, Modifier, UI node emission, and the Android View renderer. It provides state-driven value animation, imperative last-writer mutations, synchronized transitions, infinite channels, visibility/content transitions, measured-size animation, and real layout-bounds motion.

Artifact and stability

dependencies {
implementation("com.viewcompose:viewcompose-animation:0.1.0-alpha04")
}
  • Stability: Alpha. State ownership, cancellation, retargeting, content retention, and renderer handoff have reviewed contracts; the APIs are intentionally smaller than Compose Animation and may expand between alphas.
  • Platform: Android library, minimum SDK 24.
  • Animation Core, Runtime, UI Contract, and UI Foundation are exposed transitively because their state, clock, modifier, unit, and builder types appear in the public animation surface.
  • viewcompose-animation-core can also be used independently from an Android UI host.
  • Android View property animation interop belongs to viewcompose-host-android, not this module.

Composition animation environment

Composition-owned APIs use LocalMonotonicFrameClock for frame timestamps and LocalAnimationCoroutineContext for dispatcher and context selection. The animation context must not contain a Job: LaunchedEffect supplies the structured parent job, so installing another one would detach cancellation from the composition.

Changing the frame clock or animation context restarts affected effects. Removing an animation call from composition cancels it. Samples are written through ViewCompose observable state and invalidate their readers.

Design-system components may resolve a semantic MotionScheme before calling these APIs. The scheme remains immutable policy from animation-core; Animatable, target-as-state APIs, and Transition remain the only composition-owned runners. Rapid retargeting therefore keeps the existing last-writer cancellation and stale-frame rejection semantics instead of creating a component-private animation loop.

Shape transitions and fallback

interpolateUiShape(start, end, fraction) interpolates corresponding corners only when each pair uses the same family and both size representations are absolute or both are relative. The result reports UiShapeInterpolationMode.Compatible for that path. Family or size-kind mismatches select the start shape before the midpoint and the destination at and after it, reporting DiscreteFallback for diagnostics.

The helper owns no clock, View, or state. Drive its finite progress through Animatable, animateFloatAsState, or Transition. It deliberately does not offer arbitrary Path Morph; a component that cannot prove compatible geometry retains a deterministic static/discrete fallback without changing bounds, input ownership, or semantics.

Target-as-state animation

animateFloatAsState, animateIntAsState, animateColorAsState, animateDpAsState, and the generic animateValueAsState turn a changing target into stable composition-owned State<T>:

val alpha = animateFloatAsState(
targetValue = if (enabled) 1f else 0.5f,
animationSpec = tween(durationMillis = 180),
)

The first composition exposes its target immediately. Later target, specification, converter, clock, or context changes cancel the previous effect and restart from the latest published value. These APIs have no imperative cancellation handle or completion callback; use Animatable when commands, stopping, or mutation arbitration are required.

All target-as-state APIs accept FiniteAnimationSpec; infinite specifications fail at compile time. They share AnimatableCore mutation and physical sampling rather than owning a second runner. Integer animation truncates samples toward zero. Color animation interpolates encoded ARGB channels and is not gamma-correct. UiDp animation interpolates the density-independent number rather than resolved pixels, so density changes do not restart it by themselves. A custom converter must keep a stable dimension count and should itself remain stable across composition.

Imperative Animatable

Animatable<T, V> exposes value, typed velocity, targetValue, isRunning, and the stable observable asState while accepting suspending commands:

val progress = rememberAnimatable(
initialValue = 0f,
converter = AnimationConverters.Float,
)

LaunchedEffect(command) {
when (command) {
Command.Open -> progress.animateTo(
targetValue = 1f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = 220f),
)
Command.Close -> progress.animateDecay(AnimationVelocity(-2.4f))
Command.Stop -> progress.stop()
else -> Unit
}
}

Every animateTo, animateDecay, snapTo, and stop call is a mutation. A newer mutation from another coroutine job cancels the old job, and stale frames are rejected by mutation identity. Physical animateTo retargets from one atomic value/velocity snapshot when its nullable initialVelocity is omitted; an explicit AnimationVelocity<V> replaces only that captured velocity. An invalid replacement is rejected before mutation ownership changes and therefore does not cancel the active animation. snapTo publishes immediately; stop preserves the current value. Both reset velocity to zero. Cancellation and failures leave the latest sample and reset the target to it. Normal completion returns AnimationResult<T, V> with Finished, BoundReached, or DurationLimitReached; cancellation still throws. The Q3 Animatable contract publishes target/running mutation start together for frame-driven animation, and publishes the retained target/idle completion together; frame samples remain independent value commits. snapTo and stop instead publish one atomic final idle snapshot without a transient running state. Invalid construction or snap input fails before any state or mutation ownership changes.

updateBounds(lowerBound, upperBound) installs inclusive component-wise value bounds. A running spring or decay clamps its crossing sample before publication, zeros velocity, and returns BoundReached. An idle update or later snapTo clamps immediately. Density, RTL, and gesture-axis conversion remain responsibilities of the caller that constructs V.

rememberAnimatable uses initialValue only when an instance is first created. Changing the converter creates a new instance; changing only initialValue does not reset it. The current frame clock is rebound on every composition. A directly constructed instance can receive an explicit clock; without one, only snapTo and stop are usable and animateTo reports a configuration error.

Shared-state Transition

updateTransition(targetState) creates one logical segment and one autonomous frame timeline for multiple derived channels. Every channel receives the stable TransitionSegment<S> selected for that segment, so direction-specific timing is type-safe and evaluated once:

val transition = updateTransition(
targetState = if (expanded) PanelState.Expanded else PanelState.Collapsed,
label = "panel",
)
val alpha = transition.animateFloat { state ->
if (state == PanelState.Expanded) 1f else 0.6f
}
val height = transition.animateDp(
transitionSpec = {
if (isTransitioningTo(PanelState.Collapsed, PanelState.Expanded)) {
spring(dampingRatio = 0.8f, stiffness = 240f)
} else {
tween(durationMillis = 180)
}
},
) { state ->
if (state == PanelState.Expanded) 240.dp else 80.dp
}

The first composition is settled at the initial target. Each channel freezes its current sample and new target when a later segment begins. The longest duration in the complete committed channel set decides when currentState commits targetState; shorter channels clamp at their own endpoints. Adding or removing a call position recomputes that maximum, including duration shrink. Retargeting cancels the old autonomous effect and starts each existing channel from its latest sampled value and retained physical velocity. The Q3 Transition publishes its logical state, target, running flag, stable segment, and play time through atomic snapshots. MutableTransitionState mirrors its framework-owned current/target/idle tuple through the same boundary.

animateValue(converter, transitionSpec, targetValueByState) is the generic Q3 channel. Built-in animateFloat, animateInt, animateColor, and animateDp delegate to the same path. The typed channel named argument is now transitionSpec; the former animationSpec name has no compatibility overload. Infinite specifications remain excluded at compile time.

For gesture, scrubber, Preview, or predictive-progress ownership, bind one SeekableTransitionState<S> instead of calling updateTransition:

val seekState = remember { SeekableTransitionState(PanelState.Collapsed) }
val transition = rememberTransition(seekState, label = "seekable panel")
val position = transition.animateValue(
converter = pointConverter,
transitionSpec = { tween(durationMillis = 600) },
) { state ->
if (state == PanelState.Expanded) Point(96f, 32f) else Point(0f, 0f)
}

LaunchedEffect(command) {
when (command) {
Command.Preview -> seekState.seekTo(0.7f, PanelState.Expanded)
Command.Commit -> seekState.animateTo(PanelState.Expanded)
Command.Reset -> seekState.snapTo(PanelState.Collapsed)
else -> Unit
}
}

The state accepts exactly one active rememberTransition binding and one mutation writer. seekTo validates a finite 0f..1f fraction before taking ownership, cancels and joins an older command, maps the fraction to the longest committed channel duration, and samples every channel with zero physical velocity. A changed seek target freezes current channel samples as new starts. Channel additions and removals retain the normalized fraction and resample against the new maximum. Commands allow two frame opportunities for channels in the accepted segment to commit; a zero-channel segment then uses the coordinator's one-nanosecond fallback rather than waiting without bound.

animateTo leaves seeking and runs exactly one frame loop from the sampled values; because seeking does not infer physical velocity, that handoff starts with zero velocity. A newer seek, animation, or snap cancels and joins the old caller before publishing. snapTo uses no frame and atomically collapses current state, target state, and both segment endpoints onto one idle value. Removing the binding cancels its active writer and retains an unfinished visual sample as seeking state.

The seek state owns no coroutine scope, is not automatically saveable, and does not commit or roll back navigation. A navigation owner may pass predictive-Back progress to seekTo, but it remains responsible for the back-stack transaction and for choosing animateTo or snapTo after commit or cancel. The label remains diagnostic metadata and does not alter identity.

Optional animation timeline inspection port

viewcompose-animation exposes the Q3 com.viewcompose.animation.tooling contracts used by optional downstream development tooling. AnimationTimelineSource supplies one immutable, bounded snapshot of a committed Transition; AnimationTimelineTooling and its lifecycle registration decide whether one explicit request currently selects that transition. The runtime reads at most one provider from a frozen process-local in-memory slot. A downstream tooling artifact may call the Q3 installAnimationTimelineTooling integration hook during Android component initialization, before the first transition reads the port. Reinstalling the same instance is idempotent; distinct early providers disable the port, and late installation is ignored. The path performs no classpath scan, file I/O, or Android service lookup. Absence, ambiguity, provider failure, and disposal failure remain diagnostic no-ops.

Snapshots contain a process-lifetime transition identity, bounded label, safe logical-state summaries, segment version/time, running/idle/interrupted state, and at most 32 committed channels. Built-in Float, exactly representable Int, UiDp, and encoded ARGB channels expose bounded numeric components. Custom value domains deliberately expose null values rather than retaining application objects or calling application formatting. Each channel reports its deterministic runtime name, finite specification family, own duration, velocity where safe, completion, and physical DurationLimitReached terminal condition.

The animation artifact contains no concrete provider, Android receiver, file format, Studio API, thread, poll, or frame callback. With no optional provider it creates no source projection and pays only the immutable diagnostic identity metadata plus the nullable registration check already on an accepted transition publication. Optional-artifact presence may weakly retain the neutral source before the first request so an already composed transition remains discoverable. The concrete receiver rejects non-debuggable processes and performs no snapshot, serialization, or report I/O until a nonce-bearing bounded request selects that identity. The port is read-only: live application seeking is not part of the contract.

The initialization boundary is defined by ADR-0022.

InfiniteTransition

rememberInfiniteTransition scopes continuously repeating channels declared with animateFloat, animateInt, animateColor, animateDp, or generic animateValue:

val pulse = rememberInfiniteTransition(label = "pulse")
val scale = pulse.animateFloat(
initialValue = 0.9f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 600),
repeatMode = RepeatMode.Reverse,
),
)

Each call position owns its state and effect. Reverse mode swaps endpoints between cycles; restart mode republishes the initial value. Equal endpoints do not await frames. Endpoint, specification, clock, or context changes restart from the newly supplied initial value—not the old sample. Changing only a custom converter is not a restart key, so converter instances must remain stable.

Infinite channels run until removed from composition. Avoid using them for off-screen or invisible content that remains composed, and prefer a finite state-driven animation when continuous motion is not essential.

AnimatedVisibility

AnimatedVisibility coordinates alpha, measured reveal, measured-size-relative slide, pivoted visual scale, and descendant choreography while controlling one content lifetime:

AnimatedVisibility(
visible = showDetails,
enter = fadeIn(tween(durationMillis = 160)) +
slideInHorizontally(
from = SlideDirection.Start,
distanceFraction = 0.5f,
) +
scaleIn(
initialScale = 0.9f,
transformOrigin = TransformOrigin(0f, 1f),
) +
expandVertically(alignment = BoxAlignment.BottomStart),
exit = shrinkVertically(alignment = BoxAlignment.TopEnd) +
scaleOut(
targetScale = 0.92f,
transformOrigin = TransformOrigin(1f, 0f),
) +
slideOutHorizontally(towards = SlideDirection.End) +
fadeOut(tween(durationMillis = 120)),
) {
Text("Parent transition running: ${transition.isRunning}")
AnimatedEnterExit(
enter = slideInVertically(from = SlideDirection.Down),
exit = slideOutVertically(towards = SlideDirection.Up),
) {
Text("Descendant shares the parent clock")
}
}

The first composition is settled and does not run enter motion. Later exit keeps content mounted for drawing until every parent and descendant channel finishes, then removes the content subtree. Accepting an exit target immediately removes that retained subtree from pointer, focus, and accessibility ownership. The empty host remains mounted at zero size as an identity anchor, so later visibility changes do not recreate following unkeyed native sibling Views or truncate their pressed and focus state. An interrupted exit or enter retargets every channel from its current sample. Every new segment samples its live reset play time; it never reuses the preceding segment's terminal time from a pinned composition snapshot.

slideIn/slideOut accept non-negative finite fractions of the full measured width or height. Logical start/end resolve from the layout direction captured at segment start; up/down remain physical. Expand and shrink keep their declared BoxAlignment edge stable and clip drawing to the animated bounds. scaleIn/scaleOut use their explicit TransformOrigin. Translation and visual scale do not change parent measurement. When the host is a direct Row or Column child, surrounding item spacing follows the applicable width or height reveal progress, so lifecycle endpoints do not insert or remove a full gap in one frame.

Tree-builder defaults affect both axes. RowScope defaults affect width; ColumnScope defaults affect height. Transition + concatenates elements, and the last applicable alpha, size, slide, or scale element wins for a duplicate channel. AnimatedVisibilityScope.transition is the owning Boolean Transition; scoped AnimatedEnterExit adds descendant channels to that coordinator instead of starting another frame loop. Descendant-local alpha/translation/scale/reveal applies before the parent transform, with parent clipping last. A motion policy that resolves these finite specifications to snap still commits visibility endpoints and removes exit content correctly.

The content receiver is now the Q3 AnimatedVisibilityScope, not BoxScope. This is an intentional hard cut that makes shared transition ownership type-safe. Ordinary builder calls remain direct; callers that relied on BoxScope.align must emit an explicit Box and apply child alignment there. The slide/scale helpers, transition elements, scope, descendant host, renderer transport, and compiled richVisibilityTransitionsSample form one Q3 API family.

Use MutableTransitionState<Boolean> when code outside the call needs to set targetState and observe currentState or isIdle. One object should be bound to one active host. In this release, changing its target before the host's first composition does not play an initial enter; first compose the hidden state, then change the target if that motion is required.

AnimatedContent and Crossfade

AnimatedContent is the keyed full-content replacement API. Its typed transition scope selects one ContentTransform for the accepted initial/target pair and can combine fade, measured-item slide, scale origin, drawing order, and an optional SizeTransform:

AnimatedContent(
targetState = page,
contentKey = { it.id },
transitionSpec = {
val forward = targetState.index > initialState.index
val enter = fadeIn() + slideIntoContainer(
from = if (forward) ContentSlideDirection.End else ContentSlideDirection.Start,
distanceFraction = 0.35f,
) + scaleIn(initialScale = 0.96f)
val exit = fadeOut() + slideOutOfContainer(
towards = if (forward) ContentSlideDirection.Start else ContentSlideDirection.End,
distanceFraction = 0.2f,
)
(enter togetherWith exit) using SizeTransform(clip = true)
},
) { state ->
Page(state)
}

contentKey is subtree identity. Equal keys patch one retained tree without selecting a replacement transition. Unequal keys retain at most one outgoing and one incoming full tree; an A-to-B-to-C interruption promotes B from its last committed visual sample, releases A once, and preserves B's keyed descendant state. Nullable keys and states follow the same rule.

Both trees are measured under the same incoming parent constraints. A non-null SizeTransform interpolates from the last committed host size to the incoming size and controls clipping; null uses the maximum current child size. Slide distances are non-negative finite fractions of the participating item's measured axis, with start/end resolved from the layout direction captured for that segment. Callback-calculated offsets remain unsupported; general visibility slide/scale and descendant choreography are provided by the separate AnimatedVisibility family above.

Incoming content exclusively owns pointer input, focus traversal, and accessibility after the replacement transaction commits. Outgoing content remains draw-only until every channel settles. A changed request is admitted only after one successful candidate commit, so a renderer failure cannot publish candidate identity, focus ownership, descendant effects, or geometry. Host disposal cancels the shared frame loop and releases every retained tree once.

Crossfade remains the smaller alpha-only contract. During a transition it invokes content for the last committed state and latest target, stacks two fill-size subtrees, and removes outgoing content after it becomes transparent. A new target replaces incoming content at the existing progress. Choose it when content keys, measured size, pair-specific transforms, slide, scale, and explicit interaction transfer are unnecessary.

animateContentSize and native layout cost

Modifier.animateContentSize serializes a finite core specification into the renderer contract. The renderer inserts a synthetic native host around the modified node and moves parent layout elements to that host. Duration specifications use Android ValueAnimator; physical springs use the shared animation-core solver and retain width/height velocity across retargeting:

Column(
modifier = Modifier.animateContentSize(
spring(dampingRatio = 0.75f, stiffness = 240f),
),
) {
// Content whose measured size changes.
}

The first measurement snaps. Later changes retarget from the in-flight size, and parent constraints continue to cap the result. Each frame requests Android layout, and the wrapper adds one View level; avoid applying it indiscriminately to large lists. Infinite size specifications are rejected at compile time because a layout animation must converge.

Built-in easing and cubic Bézier control points cross the renderer boundary. Unknown custom easing implementations fall back to FastOutSlowIn. When several animateContentSize elements occur in one modifier chain, the last specification wins.

animateBounds and real layout geometry

Modifier.animateBounds animates a node's position and size in its immediate ViewCompose layout parent after logical start/end and RTL resolution. It commits a real Android rectangle on every frame rather than applying draw translation or scale, so visible, pointer, focus, and accessibility geometry remain aligned:

Button(
text = "Move and resize",
onClick = onTargetClick,
modifier = Modifier
.width(if (expanded) 204.dp else 152.dp)
.height(if (expanded) 58.dp else 48.dp)
.align(if (expanded) BoxAlignment.BottomEnd else BoxAlignment.BottomStart)
.animateBounds(tween(durationMillis = 900)),
)

The first accepted layout is settled. A target change performs one target measurement; duration specifications retarget from the current rectangle with zero velocity, while physical springs retain all four sampled edge velocities. Property frames reuse the target measurement. Parent scrolling moves the complete local coordinate system; reparenting ends the old owner's motion and the destination starts settled. Detach and lazy-item cross-owner reuse also cancel and clear old motion before the next layout.

The renderer promotes same-chain size, margin, parent data, alignment, offset, visibility, and z-index to one transparent outer host. Drawing, content, input, focus, and semantics remain on the child. Repeated animateBounds elements are last-wins. Combining animateBounds and animateContentSize on one node is rejected before native mutation because both would own size. The host clips content to its sampled rectangle and adds one native View level. Use snap() or a resolved snap motion policy when layout motion must be disabled.

Testing

  • Provide deterministic frame clocks for imperative animation and cancellation tests.
  • Assert physical end reason, terminal velocity, bounds, decay direction, and rapid-retarget velocity continuity separately from duration behavior.
  • Verify first composition separately from subsequent target changes.
  • Test retargeting before completion and ensure stale jobs cannot publish.
  • For transitions, declare every channel in the same composition pass and assert the longest duration controls logical completion.
  • Test visibility content retention through the terminal exit frame.
  • Test animated-content equal and unequal keys, nullable targets, RTL slide resolution, midpoint interruption, removed effects, input/focus/accessibility transfer, rollback, and host disposal.
  • Test compatible and incompatible shape transitions separately, including fallback attribution.
  • Test size animation on the Android renderer when wrapper placement, constraints, or modifier routing matters; the animation module's unit tests verify only contract serialization.
  • Test bounds animation with real parent placement, RTL, active retargeting, detach/reuse, input and accessibility geometry, rollback, and target-measure counts; visual translation alone is not an acceptable substitute.

The complete generated reference is available in the viewcompose-animation API tree.

Compatibility notes

The Phase 1 alpha hard-cuts the fixed-duration spring and single-domain Animatable<T> surface. Callers use physical spring, Animatable<T, V>, typed velocity, decay, bounds, and structured results. animateContentSize shares that physical solver and no longer accepts infinite specifications. Additive animateBounds is immediate-parent-local and does not yet provide shared or cross-owner visual transitions. There are no deprecated compatibility overloads. Shared-duration transitions, continuous channels, exit-aware visibility lifetime, and alpha-only content replacement retain their documented ownership. Similar API names do not imply complete Jetpack Compose Animation parity; the behavioral differences above remain part of the public contract.