How compose-preview works

July 11, 2026 · View on GitHub

End-to-end view of how a @Preview composable becomes a PNG. For the contributor-oriented architecture map (which class lives where, why each backend made the choices it made), see AGENTS.md.

Discovery

Scan compiled class files for @Preview annotations → build/compose-previews/previews.json.

For each method in each compiled class:

  1. Check for direct @Preview or @Preview.Container annotations on the method.
     If found, extract preview parameters (name, device, dimensions, backgroundColor, etc.)
     and emit a preview entry.

  2. Otherwise, walk the method's annotations looking for multi-preview meta-annotations.
     For each annotation, check whether *its* annotation class carries @Preview.
     Recurse through meta-annotations (with cycle detection via a visited set).
     Emit a preview entry for each @Preview found transitively.

Deduplicate by fully-qualified name + preview name + device + dimensions.

Rendering (Desktop)

Launch a subprocess with the module's full classpath plus the renderer-desktop module.

1. Load the target class by name and resolve the composable function
   via the Compose runtime's reflection API.

2. Create a headless ImageComposeScene at the target dimensions (2x density).

3. Set the scene content to: a background fill (from the @Preview annotation's
   backgroundColor), with the composable function invoked inside it.
   LocalInspectionMode is enabled so preview-aware composables render correctly.

4. Render two frames (the second allows animations and effects to settle).

5. Encode the Skia surface to PNG and write to the output file.

Rendering (Android)

Launch a Gradle Test task inside a Robolectric sandbox with native graphics (graphicsMode=NATIVE, pixelCopyRenderMode=hardware).

1. Bootstrap a ComponentActivity through `createAndroidComposeRule`.
   Apply the @Preview qualifiers (size, density, locale, uiMode, round,
   orientation) via `RuntimeEnvironment.setQualifiers` and `setFontScale`.

2. Set the activity content to a background fill + reflected composable
   invocation, with `LocalInspectionMode = true`.

3. Pause Compose's main clock (`autoAdvance = false`) and step it by a
   fixed amount so infinite animations terminate deterministically instead
   of hanging the idling resource.

4. Capture the root view via `captureRoboImage`, which routes ShadowPixelCopy
   through HardwareRenderer + ImageReader to replay Compose's RenderNodes,
   compress as PNG, write to file.

Caching

Both discovery and rendering are Gradle-cacheable tasks with declared input/output contracts. Unchanged source files produce no re-work on subsequent runs. Configuration caching is strict (problems=fail).

Plugin configuration

Apply the plugin to a module (see samples/android/build.gradle.kts, samples/wear/build.gradle.kts, or samples/cmp/build.gradle.kts for working examples):

composePreview {
    variant.set("debug")     // Android build variant (default: "debug")
    sdkVersion.set(35)       // Robolectric SDK version (default: 35)
    enabled.set(true)        // disable to skip registration (default: true)
}

When enabled = false, the plugin skips registering the preview tasks (composePreviewDiscover / render / daemon-start) but still writes the build/compose-previews/applied.json marker (carrying enabled: false). The VS Code extension reads that marker: the module stays visible in discovery and the doctor report, but the extension never schedules a preview task for it — so opening or saving a file in a disabled module no longer produces a "task not found" failure. (This is the "keep but flag" choice from #2016: ModuleInfo.enabled gates scheduling in GradleService, leaving visibility intact. A missing enabled — legacy markers, scan-detected modules — is treated as enabled.)

Project structure

ModulePurpose
gradle-plugin/Gradle plugin — discovery, rendering task orchestration
renderers/desktop/Desktop renderer — ImageComposeScene + Skia PNG capture
renderers/android/Android renderer — Robolectric harness
api/preview-annotations/Shared annotations consumed by samples (@ScrollingPreview, etc.)
cli/CLI — Tooling-API driver over composePreviewDiscover / composePreviewRenderAll
vscode-extension/VS Code extension that surfaces rendered previews in the editor
samples/android/Android sample with colored box @Preview composables
samples/android-library/Android library variant — exercises AAR class-jar discovery
samples/android-screenshot-test/Co-existence with com.android.compose.screenshot
samples/wear/Wear OS sample — Material 3 Expressive, EdgeButton, tile previews
samples/cmp/CMP Desktop sample with colored box @Preview composables
samples/remotecompose/Remote Compose sample — wrapper-inside-Composable vs. @PreviewWrapper(RemotePreviewWrapper::class) against wear-compose-remote-material3

Requirements

Consumer (what your project needs to apply the plugin):

  • Gradle 8.13+ (enforced at apply-time by GradleVersionCheck)
  • Java 17 or newer (renderer / plugin target JDK 17 bytecode; any newer JDK runs them)
  • AGP 8.13.0+ (Android projects)
  • Kotlin 2.0.21+ (the published-API floor — kotlinCoreLibraries in gradle/libs.versions.toml, enforced by tapmoc on every plugin/renderer build)
  • Compose Multiplatform 1.10.3+ (Desktop projects)

The bottom edge is exercised end-to-end on every push by the agp8-min job in .github/workflows/integration.yml against the fixture under .github/ci/fixtures/agp8-min/. The project's own build toolchain (what contributors use) is documented in AGENTS.md and is intentionally much newer than the consumer floor.