Diff-driven animated column for Jetpack Compose, built for small and medium non-lazy keyed lists whose rows need a predictable enter/exit/reinsert lifecycle.
The main value is not replacing every Compose list. It is making row presence explicit and stable under churn: inserts, removals, undo, optimistic updates, reorder, queues, and status UI where a disappearing item must stay alive long enough to animate out, or recover cleanly if it is reinserted before the exit finishes.
For thousands of rows, viewport recycling, paging, or general-purpose scrolling,
use LazyColumn
and its item animation APIs. AnimatedColumn is intentionally a specialized
tool for bounded keyed collections where lifecycle semantics matter more than
lazy virtualization.
Default path: apply row visuals with Modifier.animatedItem and keep the column on AnimatedItemDefaults.none() so motion is not applied twice.
Advanced path: use ItemPhase and progress on AnimatedItemScope for custom graphics tied to the column’s own transition spec.
- You render a small or medium non-lazy list with stable keys.
- Items frequently enter, leave, return, or reorder under UI churn.
- Rows represent pending work, upload/download queues, status chips, snackbars, undoable actions, optimistic changes, or short-lived live data.
- A removed row should remain composed as Exiting until its exit animation completes.
- A row reinserted during exit should recover predictably instead of starting a fresh enter lifecycle.
- You want one place to reason about row phases: Entering, Visible, and Exiting.
- You need to render thousands of items or an unbounded feed.
- You need lazy viewport composition, paging, prefetching, or item recycling.
LazyColumnalready gives you the scrolling and animation behavior you need.- Your rows do not have stable unique keys.
- You only need a one-off size change animation around static content.
Short recordings from the sample app show the main cases the library is built for: basic list changes, side-by-side comparison, stable-key reorder, heavier update pressure, animation presets, and edge cases.
| Basic insert/remove | Animated vs plain |
|---|---|
![]() |
![]() |
| Stable-key shuffle | Many updates |
|---|---|
![]() |
![]() |
| Animation variants | Edge cases |
|---|---|
![]() |
![]() |
The library is published on Maven Central:
dependencies {
implementation("io.github.rend1x:compose-animated-list:<latest-version>")
}Use the latest version shown in the Maven Central badge at the top of this README.
Make sure your project resolves Maven Central:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}AnimatedColumn still runs diffing and lifecycle; AnimatedItemDefaults.none() takes a fast path that skips shell Animatable state and turns off shell fade/slide/height so your modifier owns how the row appears. If you used a preset such as fadeSlide() here and animatedItem on the same surface, both would drive opacity/offset and the animation would look doubled. The published AAR includes an Android Lint rule that warns in the IDE for the common doubled-animation case: default or non-none transitionSpec plus Modifier.animatedItem inside the row content.
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.rend1x.composeanimatedlist.AnimatedColumn
import com.rend1x.composeanimatedlist.ItemPhase
import com.rend1x.composeanimatedlist.animation.AnimatedItemDefaults
import com.rend1x.composeanimatedlist.animatedItem
AnimatedColumn(
items = items,
key = { it.id },
transitionSpec = AnimatedItemDefaults.none(),
) { item ->
Card(
modifier = Modifier
.fillMaxWidth()
.animatedItem(this, slideRevealOffset = 24.dp),
) {
Text(item.title)
}
if (phase == ItemPhase.Exiting) {
Text("Removing…")
}
}Need the displayed position too? Use the indexed overload:
AnimatedColumn(
items = items,
key = { it.id },
) { item, index ->
Text("${index + 1}. ${item.title}")
}The index is the current internal render index, so it includes rows retained while exiting.
| Layer | Use when |
|---|---|
Modifier.animatedItem(scope, …) |
Normal rows: fade + slide from visibilityProgress without hand-writing graphicsLayer. Pair with AnimatedItemDefaults.none() on the column. |
| Named modifier helpers | Use animatedItemFadeSlide, animatedItemScale, animatedItemCollapse, animatedItemSharedAxisY, or animatedItemSwipeOut when you want a named preset instead of custom graphicsLayer code. animatedItem remains the recommended default alias for fade + slide. |
phase |
Lifecycle copy, icons, conditional UI (ItemPhase). |
visibilityProgress / placementProgress / progress |
Custom motion, or aligning your own graphicsLayer with the column’s non-none AnimatedItemTransitionSpec. |
Named helpers are intentionally small:
Modifier
.animatedItemCollapse(this) // layout size only
.animatedItemFadeSlide(this) // alpha + translationY
.animatedItemScale(this) // scale onlyCombine helpers only when they own different properties. For example, animatedItemScale is safe with
animatedItemFadeSlide; animatedItemFadeSlide and animatedItemSharedAxisY both write alpha/translationY, so
choose one. animatedItemSwipeOut(fade = false) can be used when another helper already owns alpha.
Each item’s content lambda receives AnimatedItemScope. You do not need the progress fields for the default fade + slide; use them for advanced customization.
| API | Meaning |
|---|---|
phase |
ItemPhase — primary lifecycle: Entering, Visible, or Exiting (mutually exclusive). |
visibilityProgress |
0f..1f: fade/slide completion from enter/exit specs (not row clip height). Entering: toward 1. Exiting: toward 0. Visible: 1 when settled; may be < 1 briefly during present-settle after enter (see AnimatedItemScope KDoc). |
placementProgress |
0f..1f for the row’s layout height when placement is animated; otherwise 1f. Same present-settle note as visibility when applicable. |
progress |
min(visibilityProgress, placementProgress) — use when one combined completion value is enough; prefer the split fields when fade/slide and height should diverge. |
See AnimatedItemScope KDoc for the authoritative contract.
Pass callbacks to rememberAnimatedListState to observe lifecycle changes:
val listState = rememberAnimatedListState(
onItemPhaseChanged = { key, phase -> /* update analytics or external state */ },
onEnterFinished = { key -> /* enter completed */ },
onExitFinished = { key -> /* exit completed */ },
)AnimatedListState also exposes visibleKeys, exitingKeys, clearExiting(key), and clearExitingNow() for controlled retention. visibleKeys contains Entering and Visible keys; exitingKeys contains keys retained only for exit animation.
If you prefer the list to own enter/exit motion, pass a non-none spec (e.g. AnimatedItemDefaults.fadeSlide()) and omit animatedItem on the same surface, or use phase / progress only for extra effects.
The bundled lint warning catches the accidental mix of a column-owned transition and Modifier.animatedItem. Intentional custom effects should use phase, visibilityProgress, or placementProgress directly.
These rules are what the implementation and tests commit to—use them when reasoning about production behavior under load or overlapping updates.
| Topic | Guarantee |
|---|---|
| Exiting retention | If a key disappears from items, that row stays in the internal render list with phase Exiting until its exit animation finishes and the row is removed, or until you call AnimatedListState.clearExitingNow(). Exiting rows keep the relative order they had among themselves and stable neighbors (order is derived from the previous render list). |
| Reinsertion | If a key comes back in items while its row was still Exiting, that row becomes Visible with the latest element value. It does not go through Entering again—only keys that were not in the previous render snapshot enter as Entering. |
| Zero-duration transitions | EnterSpec.None / ExitSpec.None use visibility tween duration 0. For Fade / SlideVertical / FadeAndSlide, setting durationMillis = 0 means the same: tweens complete immediately in the same coroutine work, then exit completion runs. For PlacementBehavior.Animated, durationMillis = 0 collapses height animation the same way. After Visible, a short internal “present settle” (120 ms) may still run to align alpha, offset, and height—see AnimatedColumn. |
| Update ordering | Each time items or key changes, the list diffs from the previous internal render snapshot to the new items. Rapid updates are applied in composition order: the LaunchedEffect tied to items restarts, so intermediate items values may be skipped if the composition never commits them, but every applied step is a well-formed diff. The final render state matches the last applied items snapshot. |
| Keys / duplicates | key must return a unique value for every element in items for that snapshot. Debug builds of the animatedlist AAR validate this and throw IllegalStateException with the conflicting indices. Release builds do not throw: duplicates are normalized by keeping the last occurrence of each key; sanitized order is increasing index of that last occurrence (see Behavior contract). Fix callers so debug and release agree. |
Rules for rapid items updates and transitionSpec changes while row shell animations are in flight. Stated here and in KDoc on AnimatedColumn / AnimatedItemScope. Core: AnimatedListRenderEngineContractTest (phase/value snapshots). Adapter: AnimatedColumnInterruptionInstrumentedTest (phases under a frozen main clock, spec swap, and multi-frame rapid updates).
| Rule | Meaning |
|---|---|
| Latest state wins | After each applied update, each row’s phase and element value match the diff of the last committed items snapshot. Snapshots that never commit may be skipped. |
| Continuity from current values | When a shell animation is interrupted (presence changes, or transitionSpec restarts the effect), the next animation runs from the current animated alpha, translation, and height progress—not from enter “initial” values. Targets come from the new step: enter toward on-screen rest, exit toward off-screen rest, visible row toward rest after present-settle. |
| Reinsert during Exiting | The row becomes Visible with the latest value (not Entering again). The shell does not reset to enter-start; it moves from current values toward visible rest (present-settle). |
| Remove during Entering | The row becomes Exiting. The shell animates from current values toward exit targets. There is no guarantee of a literal reverse of the enter curve—only continuity toward the exit end state. |
Changing transitionSpec mid-animation |
The animation effect restarts; tweens begin from current values and run to targets implied by the new spec and current phase. |
AnimatedItemDefaults.none() // pair with Modifier.animatedItem for modifier-first visuals
AnimatedItemDefaults.fade()
AnimatedItemDefaults.slide()
AnimatedItemDefaults.fadeSlide() // column-owned motion; avoid stacking animatedItem on the same surfaceAnimatedColumn still defaults to fadeSlide() when you omit transitionSpec; override with none() for the modifier-first quick start above.
animatedlist-core— JVM-only diff/render engine (AnimatedListRenderEngine,AnimatedListDiffer); no Composeanimatedlist— Compose UI adapter overanimatedlist-core(minSdk 23; current Compose Material and merged AndroidTest manifests require it)sample— easy path (none+animatedItem) vs advanced (tunabletransitionSpec+ phase/progress)
The library module keeps its published dependency surface small and stable-only:
- API dependencies:
androidx.compose.foundation:foundation,androidx.compose.runtime:runtime, andandroidx.compose.ui:ui. These are exposed because public APIs use@Composable,Modifier,Alignment,Dp, andColumn/layout types. - Implementation dependencies:
animatedlist-coreandandroidx.compose.animation:animation. Consumers get them transitively when depending onanimatedlist, but they are not part of the public API contract. - BOM-managed Compose versions: Compose artifacts are versioned by
androidx.compose:compose-bom; individual Compose module versions are intentionally not repeated. - Debug-only preview/tooling: Compose Material 2,
ui-tooling-preview,ui-tooling, andui-test-manifestare only used by the library debug/preview source set and are not release dependencies. - Sample-only dependencies:
androidx.activity:activity-compose,androidx.appcompat:appcompat,androidx.core:core-ktx, Compose Material 3, Material icons, Espresso, AndroidX Test JUnit, and Macrobenchmark/UIAutomator are only needed by the sample, android tests, or macrobenchmark module.
Main transitive dependencies you will see from Compose include animation-core, foundation-layout, ui-graphics, ui-text, ui-unit, Kotlin stdlib, and AndroidX support libraries such as annotation, collection, lifecycle, savedstate, and tracing. Do not add these manually unless your app uses their APIs directly.
Use the core benchmark runner when you want to estimate the raw overhead of the diff/render engine before layering Compose animation and drawing on top:
./gradlew :animatedlist-core:runBenchmarks
./gradlew :animatedlist-core:runBenchmarks --args="--profile=full"What it measures:
rebuild-present— rebuild a plain present-only render snapshot for each updatediff+clear— runAnimatedListDiffer.diffand immediately clear exiting rowsengine.update+clear— runAnimatedListRenderEngine.updatewith the same immediate-clear policy
The workloads cover fixed-size window churn, random insert/remove/update churn, reinsert bursts on hot keys, and pure value updates across stable keys.
The report includes:
- Cost metrics — median and p90 nanoseconds per update plus a ratio against the plain rebuild baseline
- Input churn metrics — insert/remove/position-change/value-change counts and average touched keys per update
- Animation-value metrics — exit events, updates that keep exiting rows alive, reinsert recoveries without re-enter, and render-size amplification from retained exits
These numbers are intentionally engine-only. They answer “how expensive is the diff/lifecycle logic itself?” If you want to measure scroll smoothness, frame time, or jank in the sample/app process, add a separate Android macrobenchmark layer on top.
There is also an on-device comparison suite in sample/src/androidTest for comparing:
AnimatedColumnwith the recommendednone() + animatedItem(...)pathColumnwith the closest built-in baseline:animateContentSize()LazyColumnwith the built-in item animation path:animateItem()
Use this when the question is not just “what does the core engine cost?” but also “how does this compare to the default Compose list animation building blocks on-device?” The suite measures update -> waitForIdle() timings and logs median/p90 results for each implementation on the device or emulator.
The Column baseline is intentionally not apples-to-apples: Compose does not provide a built-in diff-driven keyed item animation primitive for plain Column, so animateContentSize() is the nearest default baseline rather than a full feature match.
For frame-time and jank-tail analysis on a real device, run macrobenchmarks:
ANDROID_SERIAL=<device-id> ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.rend1x.composeanimatedlist.macrobenchmark.ListAnimationMacrobenchmarkCurrent macro suite configuration:
FrameTimingMetric()only (focus on frame behavior during churn scenarios)CompilationMode.None()repeatIterations = 7- deterministic scenario launch via benchmark extras (
implementation,workload,profile)
Results are exported to:
macrobenchmark/build/outputs/connected_android_test_additional_output/benchmark/connected/.../com.rend1x.composeanimatedlist.macrobenchmark-benchmarkData.jsonmacrobenchmark/build/outputs/connected_android_test_additional_output/benchmark/connected/.../additionaltestoutput.benchmark.message_*.txt
This project should be evaluated as a specialized semantic tool, not as a universal “fastest list animation” replacement.
- Core runner answers: “what does diff/lifecycle logic cost?”
- UI comparison runner answers: “what does
update -> waitForIdle()cost versus default Compose paths?” - Macrobenchmark answers: “what frame tails (
P90/P95/P99) do users pay under churn?”
Use all three layers together. For this library, semantic metrics such as reinsert recovery and controlled exit retention are first-class outcomes and should be interpreted alongside frame tails.
P90 frameDurationCpuMs / P90 frameOverrunMs:
| Scenario | AnimatedColumn | Column baseline | LazyColumn baseline |
|---|---|---|---|
reinsert / fast |
110.8 / 110.4 |
65.1 / 81.9 |
85.7 / 103.7 |
reinsert / default |
135.4 / 121.9 |
94.1 / 107.8 |
74.7 / 85.4 |
windowed / fast |
109.5 / 97.4 |
80.9 / 91.7 |
78.5 / 95.1 |
windowed / default |
85.6 / 96.3 |
81.2 / 78.7 |
102.4 / 87.6 |
Notes:
- Median (
P50) frame duration is close across implementations (roughly4-5 ms); differences are mostly in tail latency. cpuLocked=falsein these runs, so treat absolute values as comparative, not lab-grade.- Practical takeaway: the library is strongest when semantic guarantees matter (reinsert continuity, controlled exiting lifecycle), not when optimizing for minimum tail latency at all costs.
These are the engine-level guarantees enforced by AnimatedListRenderEngine / AnimatedListDiffer and covered by AnimatedListRenderEngineContractTest:
| Topic | Contract |
|---|---|
| Idempotent updates | Two consecutive update calls with the same list and key selector leave the render snapshot unchanged (including rows still Entering if their value is unchanged). |
| New key | A key that appears in the new input but not in the previous snapshot is inserted as Entering. |
| Removed key | A key that disappears from the new input becomes Exiting and stays in the snapshot until clearExitingNow() or onExitAnimationFinished(key). |
| Reinsert while exiting | If a key is Exiting and appears again in the new input, the row becomes Present with the latest value; it does not become Entering again. |
| Order | Relative order of retained rows follows the previous snapshot; new keys are placed deterministically from the new list’s ordering (see KDoc on AnimatedListDiffer.diff). |
| clearExitingNow | Removes every Exiting row and leaves Present and Entering rows unchanged. |
| Strict keys | Duplicate keys in one input snapshot throw IllegalStateException. |
| Last-wins keys | Duplicates collapse to the last occurrence per key; sanitized order is by increasing index of that last occurrence in the original list. |
Compose timing (LaunchedEffect, animation frames) is layered on top; see Behavior guarantees above for UI-facing rules.
Copyright 2026 rend1x (Maxim Grishin)
Licensed under the Apache License, Version 2.0. See LICENSE.





