If you've been writing Jetpack Compose for more than a few weeks, you've felt this. You want to preview a screen across two devices, light and dark themes, and four state samples. You end up with a 30-line preamble of stacked @Preview annotations, a PreviewParameterProvider class you hand-wrote, and the uneasy feeling that Android Studio is about to start swapping.
It is. And the fix is a single annotation.
The problem, made concrete
A "well-previewed" screen in Compose today usually looks something like this:
class SettingsStateProvider : PreviewParameterProvider<SettingsState> {
override val values = sequenceOf(
SettingsState(),
SettingsState(notificationsEnabled = false),
SettingsState(username = "max"),
)
}
@Preview(name = "Phone · Light", device = Devices.PIXEL_7)
@Preview(name = "Phone · Dark", device = Devices.PIXEL_7, uiMode = UI_MODE_NIGHT_YES)
@Preview(name = "Tablet · Light", device = Devices.PIXEL_TABLET)
@Preview(name = "Tablet · Dark", device = Devices.PIXEL_TABLET, uiMode = UI_MODE_NIGHT_YES)
@Composable
internal fun SettingsScreenPreview(
@PreviewParameter(SettingsStateProvider::class) state: SettingsState,
) = SettingsScreen(state)
That is the small version. Add a foldable. Add a desktop target. Add three more state samples. Now multiply by 30 screens in a real app. Every screen has its own provider class, its own stack of @Preview lines, its own name = "..." strings that drift out of sync the moment somebody copy-pastes.
That is not a Compose problem — Compose is doing the right thing — but it is a tooling-API problem.
The hidden cost: bitmap cache bloat
The annotation tax is annoying. The runtime tax is worse.
Compose Preview's bitmap cache scales linearly with cell count. A 20-screen app rendering a full device × locale × theme Cartesian accumulates ~800 cells × ~3MB each ≈ 2.4 GB of bitmaps before any cell ages out. Studio lags. The preview pane stops repainting. Eventually you reach for Invalidate Caches and Restart and pretend you didn't.
The naive answer is "render fewer cells." The honest answer is "render the cells you actually look at." Those are not the same thing — and the difference is the axis people get wrong: locale.
The axis to skip is locale
When you debug a Compose layout, you look at devices side-by-side (phone vs tablet vs foldable) and themes stacked (light vs dark). You do not, in practice, sit there comparing English next to French next to Japanese in a 6-cell strip. Locale is a one-at-a-time axis: you switch to fr, eyeball it, switch to ja, eyeball it.
So a sane preview matrix is device × theme × samples at one locale. That collapses the Cartesian by however many locales you ship — for many apps, an order of magnitude.
This is the design insight behind compose-auto-preview: take a single locale: String, render the rest as a matrix, and let you change the locale field when you actually need to verify another one.
What the library does
You write one function with one annotation:
@AutoPreview(
samplesFrom = SettingsSamples::class,
devices = [Device.Phone, Device.Tablet],
themes = [Theme.Light, Theme.Dark],
)
@SettingsScreenAutoPreviews
@Composable
internal fun SettingsScreenPreview(
@PreviewParameter(SettingsScreenPreviewSamplesProvider::class) state: SettingsState,
) = SettingsScreen(state)
KSP generates two things on the first build:
SettingsScreenPreviewSamplesProvider— thePreviewParameterProvider<SettingsState>you would have hand-written, populated from yourSettingsSamplesobject in declaration order.@SettingsScreenAutoPreviews— a multi-preview annotation that expands into thedevice × themeCartesian at the chosen locale.
The samples come from a plain Kotlin object:
object SettingsSamples {
val Default = SettingsState()
val NotificationsOff = SettingsState(notificationsEnabled = false)
val Filled = SettingsState(username = "max")
}
That's the whole API surface for the common case. The example above renders 20 cells — 2 devices × 2 themes × 5 samples — and the file stays under 15 lines.
Sharing config across an app
Most apps want the same devices and themes for every screen. Hoist the config into a wrapper annotation once:
@AutoPreview(
samplesFrom = Unit::class, // placeholder, overridden at use site
devices = [Device.Phone, Device.Tablet, Device.Foldable, Device.Desktop],
themes = [Theme.Light, Theme.Dark],
)
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class AppPreview(val samplesFrom: KClass<*>)
Then each screen only declares what's actually screen-specific:
@AppPreview(samplesFrom = SettingsSamples::class)
@SettingsScreenAutoPreviews
@Composable
internal fun SettingsScreenPreview(
@PreviewParameter(SettingsScreenPreviewSamplesProvider::class) state: SettingsState,
) = SettingsScreen(state)
This is the part that scales: 30 screens × 3 lines of preview boilerplate instead of 30 screens × 30 lines.
Dialogs, bottom sheets, KMP
Three pragmatic notes:
- Dialogs and
ModalBottomSheetrender in a separate window, so the@Composablebody has nothing to size the canvas. Wrap them inBox(Modifier.fillMaxSize())and they render correctly. - Kotlin Multiplatform is supported by putting state and samples in
commonMainand the@AutoPreviewfunction inandroidMain— Compose Preview is still Android-only, and KSP only runs there. - First build shows red squigglies on the generated annotation and provider class. Type them anyway, build once, and Studio resolves them. This is the one piece of friction worth knowing up front.
Setup
plugins { alias(libs.plugins.ksp) }
dependencies {
implementation("io.github.drunkendealer:compose-auto-preview-annotations:3.1.1")
ksp("io.github.drunkendealer:compose-auto-preview-processor:3.1.1")
}
Requirements are Kotlin 2.0+, KSP 2.0+, Compose (Android) or Compose Multiplatform 1.7+, minSdk 28, JVM 11.
When not to use it
If your project has three screens and one device target, you do not need this. Stacked @Preview annotations are fine at that scale. The library earns its keep when:
- You have more than a handful of screens.
- You preview across multiple devices or both themes (most apps).
- You've already hit Studio bitmap-cache slowdowns, or you would like to never hit them.
It is also worth installing if you've ever copy-pasted a @Preview block, missed renaming the name = "..." string, and shipped a confusing preview to a teammate. That mistake stops being possible.
The short version
@Preview is a perfectly fine annotation that was never designed for the matrix-of-states reality of a modern Compose app. compose-auto-preview closes the gap: one annotation, one samples object, a device × theme × samples matrix at one locale, and Android Studio that stops choking on its own bitmap cache.
Try it on one screen. If you like it, the wrapper-annotation trick is the move.