Engineering

@Preview is Android-only, and your shared UI has no preview at all

The annotation lives in an Android artifact, so commonMain cannot use it — and that shapes how you write composables.

MobileByteSensei 8 min read
kmpcomposetooling

TL;DR

Compose Multiplatform shares @Composable. It does not share @Preview — the Android annotation lives in androidx.compose.ui:ui-tooling-preview, an Android artifact, and the IDE renderer that draws it is Android-specific.

So the UI code you moved into commonMain for the sharing benefit loses its previews, which is a real and annoying cost. The useful part is what the workaround forces: a stateless composable taking a state object, with the stateful wrapper separate. That is the structure previews wanted all along, and it is also what makes the UI screenshot-testable on the desktop target.

Design for state-in, events-out. Then the Android preview, the desktop preview, and the screenshot test all consume the same composable.

The case this post is about

A screen moved from the Android app module into the shared module. Before the move, it previewed fine:

// androidApp — before
@Composable
fun ReadingListScreen(viewModel: ReadingViewModel = koinViewModel()) {
    val state by viewModel.state.collectAsState()
    Column { … }
}

@Preview(showBackground = true)
@Composable
private fun ReadingListPreview() {
    ReadingListScreen()     // worked, sort of — see below
}

Moved verbatim into commonMain, and the annotation does not resolve:

e: Unresolved reference: Preview
e: Unresolved reference: androidx.compose.ui.tooling.preview

Worth noting the "sort of" above: that preview was already poor. It constructed a real ViewModel through DI, so the render depended on whatever the DI graph produced — usually an empty state, and occasionally a crash in the IDE renderer. The move exposed a problem that predated it.

The symptom

Three distinct losses, and one of them is not what people expect.

No preview in the IDE for shared composables. The annotation does not exist in commonMain, so there is nothing to render. For a team used to iterating on layout in the preview pane, this is the one that hurts daily.

@Preview in androidMain cannot see commonMain private composables. The obvious workaround — put the previews in the Android source set — only reaches internal or public composables, so the small private pieces you most want to preview are unreachable.

And the loss nobody mentions: no design review artifact. Previews were doubling as the thing a designer looked at. After the move there was no rendered image of the shared UI anywhere, on any platform, which is a review-process gap rather than a tooling one.

Why the obvious fix failed

"Add ui-tooling-preview to commonMain." It has no multiplatform artifact, so resolution fails for the native and wasm targets. This is the same class as any Android-only dependency in shared code.

"Declare expect annotation class Preview." Clever, compiles, and useless: the IDE's preview renderer looks for the specific Android annotation by its fully-qualified name. An expect/actual alias satisfies the compiler and the tooling never sees it. I spent a while on this one before understanding that the annotation is not the mechanism — the renderer is.

"Keep the UI in androidMain so previews work." Gives up the sharing that motivated the move. It also leads to the worst outcome: two implementations of the same screen, one shared and one Android, drifting.

"Use the desktop app as the preview." Closer to right, and running the whole app to see one component is a slow loop. Useful as a fallback, not as the iteration tool.

The actual mechanism

Three separate things, and they are usually conflated into one word.

@Composable is multiplatform. The compiler plugin and the runtime are, which is why the UI code itself moves cleanly.

@Preview is an Android annotation. It ships in an Android library and carries Android configuration — device, uiMode, fontScale, showSystemUi. Those parameters describe an Android rendering environment, which is a real reason the annotation is not portable rather than an oversight.

The renderer is IDE tooling, bound to the Android rendering stack. The preview pane runs your composable against a stub Android runtime (the same mechanism Layoutlib provides). There is no equivalent host for iOS or wasm, so there is nothing to render into.

Compose Multiplatform does ship a desktop @Preview in org.jetbrains.compose.ui.tooling.preview — available in desktop source sets, not commonMain, and with its own tooling support. So there are two preview annotations, and neither lives where the shared code does.

The three separable pieces usually called preview: the Composable annotation which is multiplatform, the Android Preview annotation which ships in an Android artifact and carries Android-specific configuration, and the IDE renderer which runs against an Android rendering stack with no iOS or wasm equivalent — so a composable in commonMain can be shared while its preview cannot

@Composable shares because it is a compiler feature. @Preview does not because it is a tooling feature bound to one platform's renderer. The split between portable language features and non-portable tooling is the thing to expect at every boundary.

And the boundary worth stating before the fix, because the fix does not remove it: an Android preview of a shared composable tells you how it renders on Android. Default text styles, font metrics, density rounding and the platform ripple are not identical on iOS, so a layout that is balanced in the preview pane can still clip on a phone.

The practical shape of that: a two-line label that fits at Android's default bodyMedium metrics and wraps to three on iOS — which changes the row height, which changes what fits on screen. The preview is a fast check on structure and a weak one on fit, and the gap widens exactly where text is near a boundary.

So previews answer "did I wire this correctly" on every platform, and "does this look right" on one. Treat the second answer as provisional until something has rendered it on the target.

The fix

Split stateless from stateful. Then previews live in the platform source sets and consume the stateless half — which is also the half worth previewing.

// commonMain — STATELESS. State in, events out. No DI, no ViewModel, no side effects.
@Composable
fun ReadingListContent(
    state: ReadingState,
    onItemClick: (String) -> Unit,
    onRefresh: () -> Unit,
) {
    when (state) {
        is ReadingState.Loading -> LoadingList()
        is ReadingState.Empty   -> EmptyState(onRefresh)
        is ReadingState.Ready   -> LazyColumn {
            items(state.items) { ReadingRow(it, onClick = { onItemClick(it.id) }) }
        }
    }
}

// commonMain — STATEFUL wrapper. The only part that touches DI.
@Composable
fun ReadingListScreen(viewModel: ReadingViewModel = koinViewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    ReadingListContent(
        state = state,
        onItemClick = viewModel::onItemClick,
        onRefresh = viewModel::refresh,
    )
}
// androidMain — previews live here, and they get REAL states
@Preview(showBackground = true)
@Composable private fun PreviewLoading() =
    AppTheme { ReadingListContent(ReadingState.Loading, {}, {}) }

@Preview(showBackground = true)
@Composable private fun PreviewEmpty() =
    AppTheme { ReadingListContent(ReadingState.Empty, {}, {}) }

@Preview(showBackground = true, fontScale = 1.5f)
@Composable private fun PreviewReadyLargeFont() =
    AppTheme { ReadingListContent(ReadingState.Ready(sampleItems), {}, {}) }

Three things this buys beyond getting previews back:

Every state is previewable, including the ones you could not reach before. The old preview rendered whatever DI produced. These render Loading, Empty, and a large-font Ready — the states that actually need visual checking and that were previously unreachable from the preview pane.

The sample data lives in commonMain and is shared with tests:

// commonMain — one set of fixtures for previews, screenshot tests and unit tests
object ReadingSamples {
    val items = listOf(Item("1", "The Pragmatic Programmer"), Item("2", "Crafting Interpreters"))
    val ready = ReadingState.Ready(items)
}

And the desktop target becomes a real preview host, which is the part that recovers the design-review artifact:

// desktopTest — a screenshot test over the SAME stateless composable
@Test
fun readingListStates() = runDesktopComposeUiTest {
    listOf("loading" to ReadingState.Loading,
           "empty"   to ReadingState.Empty,
           "ready"   to ReadingSamples.ready).forEach { (name, state) ->
        setContent { AppTheme { ReadingListContent(state, {}, {}) } }
        onRoot().captureRoboImage("reading-list-$name.png")
    }
}

Three committed PNGs per screen, regenerated on demand, diffed in review. That is a better design artifact than an IDE pane, because it is durable and someone else can look at it.

How we proved it

That previews render every state. This is a visual claim, so the honest check is the generated images rather than an assertion:

$ ./gradlew :shared:desktopTest --tests '*ReadingListScreenshots*'
BUILD SUCCESSFUL
$ ls shared/src/desktopTest/roborazzi/
reading-list-empty.png
reading-list-loading.png
reading-list-ready.png

That the split did not change rendered output. The refactor claim is "same UI, different structure", and a screenshot comparison is exactly the tool for it:

$ ./gradlew :shared:compareRoborazziDesktop
reading-list-ready.png     0 pixels changed
reading-list-loading.png   0 pixels changed
reading-list-empty.png     NEW — state was unreachable before the split

Zero pixels changed on the two states that existed before is the evidence that this was a refactor. The third line is the honest note that one state is new, not unchanged.

That the large-font case was actually broken, which the preview found immediately:

PreviewReadyLargeFont (fontScale = 1.5)
  → title truncated mid-word, subtitle clipped out of the row

That bug existed before the move and had never been seen, because the old preview could not construct a Ready state. Getting previews back for real states found a live layout defect in the first ten minutes — which is the strongest argument for the split that I have.

One limit worth stating. The screenshot tests run on the desktop target, so they verify the shared composable's layout under Compose's desktop renderer — not iOS text metrics, not Android font scaling on a real device. They catch structural layout regressions and they are not a substitute for looking at the thing on a phone.

What this generalizes to

Language features travel; tooling features do not. The compiler plugin shares, the IDE renderer does not. Expect this split at every portability boundary — a portable test framework with a platform-specific runner, a portable schema with a platform-specific inspector. Ask which half of a feature you are actually depending on.

A constraint that forces stateless components is doing you a favour. State-in/events-out is the recommended Compose structure for reasons that have nothing to do with previews: it is testable, reusable, and recomposition-friendly. The preview loss pushed us into the structure we should already have had.

If a thing cannot be previewed, make it screenshot-tested. Both answer "what does this look like". One is ephemeral and local; the other is durable, reviewable, and runs in CI. When the ephemeral option disappears, the durable one is usually an upgrade.

Share your fixtures. The sample data that makes a preview useful is the same data that makes a test readable. Writing it once, in commonMain, means the preview and the test cannot disagree about what a realistic state looks like.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS