Skip to main content

Released documentation snapshot. This immutable manual describes viewcompose-animation:0.1.0-alpha04 from source revision 143b09ac. For current guidance, open the current module catalog.

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, and measured-size animation.

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.

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> exposes value, 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(1f, tween(durationMillis = 240))
Command.Close -> progress.animateTo(0f, spring())
Command.Stop -> progress.stop()
}
}

Every animateTo, 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. animateTo retargets from the last accepted value. snapTo publishes immediately; stop preserves the current value. Cancellation and failures leave the latest sample and reset the target to it. The Q3 Animatable contract publishes target/running mutation start together, and publishes the retained target/idle completion together; frame samples remain independent value commits.

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 frame timeline for multiple derived channels:

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(
animationSpec = { spring(durationMillis = 320) },
) { 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 and registers its duration. The longest channel decides when currentState commits targetState; shorter channels remain at their endpoint. Retargeting cancels the old frame effect and starts each existing channel from its latest sampled value. The Q3 Transition publishes its logical state, target, running flag, segment identity, endpoints, and play time in one snapshot transaction per target or frame update. MutableTransitionState mirrors its framework-owned current/target/idle tuple through the same atomic boundary.

Channel factories currently receive no segment object; they provide one specification per channel and map logical state to Float, Int, packed ARGB, or UiDp. The label is captured for future diagnostics and does not alter identity.

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 combines alpha and measured-size channels while controlling content lifetime:

AnimatedVisibility(
visible = showDetails,
enter = fadeIn(tween(durationMillis = 160)) + expandVertically(),
exit = shrinkVertically() + fadeOut(tween(durationMillis = 120)),
) {
Text("Details")
}

The first composition is settled and does not run enter motion. Later exit keeps content mounted until every channel finishes, then removes the content subtree. 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 from current alpha and size samples. Every new segment samples its live reset play time; it never reuses the preceding segment's terminal time from a pinned composition snapshot. Size motion clips content to animated bounds. When the host is a direct Row or Column child, surrounding item spacing follows the applicable width or height progress; the lifecycle endpoints therefore 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 fade or applicable size element wins for a duplicate channel.

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.

Crossfade​

Crossfade implements an alpha-only transition. During a transition it invokes content for the last committed state and latest target, stacks both fill-size subtrees, and removes outgoing content in a post-composition side effect after it becomes transparent. Descendant state should be keyed by the logical state when each content identity needs independent retention.

A new target arriving mid-fade replaces incoming content at the existing progress rather than restarting from zero. The last committed state remains outgoing. Nullable states are retained through an explicit displayed-state wrapper and follow the same lifecycle as non-null states.

The former AnimatedContent name was removed because it promised broader transition semantics than the implementation supplied. Crossfade does not provide content keys, transition scopes, size transforms, slide motion, or per-state pair specifications.

animateContentSize and native layout cost​

Modifier.animateContentSize serializes the selected core specification into the renderer contract. The renderer inserts a synthetic native host around the modified node, moves parent layout elements to that host, and animates measured width and height with Android ValueAnimator:

Column(
modifier = Modifier.animateContentSize(spring(durationMillis = 320)),
) {
// 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 repeats continuously request layout until detach and are rarely appropriate.

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.

Testing​

  • Provide deterministic frame clocks for imperative animation and cancellation tests.
  • 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 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.

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

Compatibility notes​

The 0.1.0-alpha03 line establishes composition-owned target animation, explicit last-mutation-wins Animatable, shared-duration transitions, continuous channels, exit-aware visibility lifetime, alpha-only content replacement, and renderer-hosted measured-size motion. Similar API names do not imply complete Jetpack Compose Animation parity; the behavioral differences above are part of this release's public contract.