A Kotlin Multiplatform (KMP) module whose user interface is written with Compose Multiplatform builds as a Codename One application. The shared commonMain code is compiled unchanged, the composables render as Codename One components, and the result ships to every Codename One target: iOS, Android, the web, macOS, Windows and Linux (as native binaries with no JVM), watchOS and tvOS.

Motivation

Compose Multiplatform draws its whole user interface with Skia, and on iOS it runs on Kotlin/Native. Both come with the application: a large native runtime, a drawing engine that doesn’t use the platform’s text input, accessibility or look, and a second toolchain to keep in step with the JVM one.

Codename One takes the JVM half of a KMP project instead. kotlinc already compiles commonMain to ordinary class files for Android and the desktop. Those classes, together with the real Compose runtime and the real kotlinx.coroutines, are exactly what Codename One’s translator reads. Only the UI layer — androidx.compose.ui, foundation and material3 — is replaced, by one written over Codename One components and Graphics.

The same application, built from the same commonMain both ways and measured on the same iPhone simulator:

Executable, bundle, memory and start-up of the same Compose application built by Codename One and by Compose Multiplatform

The executable is 28% smaller, although the Codename One one still carries the whole iOS port and the full Material icon font. Memory and start-up are level: an empty UIKit application takes 640-750 ms to its first frame on the same simulator, so neither side has much start-up left to lose there. The benchmark, its method and how to run it are in scripts/kmp-bench in the Codename One repository.

The Keynote schedule screen rendered by Compose Multiplatform (left) and by Codename One (right)

Beyond size, the composables become real components, so a Compose screen uses the platform’s own text input and accessibility, can sit inside a Codename One form next to any other component, and reaches every Codename One API directly — push notifications, in-app purchase, the native maps, the backend — with no expect/actual plumbing per platform.

Quick start

Create a Codename One application (for example at https://start.codenameone.com) and import the KMP project into it:

mvn cn1:import-kmp-project -Dcn1.kmp.import=/path/to/MyKmpApp

The goal copies the module’s commonMain and its JVM source set (jvmMain, else desktopMain) into common/src/main/kmp, and rewrites the application’s main class so that it shows the module’s root composable. Two options cover projects it can’t decide alone:

-Dcn1.kmp.module=composeApp

The module to import, when more than one has a src/commonMain.

-Dcn1.kmp.entry=com.example.App

The fully qualified root composable, when commonMain declares no single @Composable fun App().

The Android and iOS source sets aren’t copied. They call those platforms directly, and Codename One supplies both. A JVM file whose main opens a desktop window (application { Window { App() } }) is left out too: the generated main class starts the application instead.

Then build and run as usual:

mvn cn1:run                         # the simulator
mvn cn1:build -Dcodename1.platform=ios   # an iOS build on the build server

Gradle

A Gradle application needs the Compose compiler plugin, id("org.jetbrains.kotlin.plugin.compose") at the application’s Kotlin version, applied next to the Codename One plugin. The build stops with the line to add when it’s missing.

convert-to-gradle writes it for you. The Codename One settings plugin adds Google’s Maven repository, limited to the androidx.* groups, which is where the Compose runtime is published.

Placing the sources by hand

common/src/main/kmp/commonMain/kotlin and common/src/main/kmp/jvmMain/kotlin are ordinary Kotlin source roots. The archetype’s compose profile turns on whenever common/src/main/kmp exists: it adds codenameone-compose-compat, applies the Compose compiler plugin and sets both kotlinc’s jvmTarget and javac’s source and target to 11, because the Compose runtime’s inline functions are JVM 11 bytecode. The Compose compiler plugin’s version must equal the Kotlin version, which is why both come from one property.

The entry point

A composition is shown in a ComposeForm, or embedded anywhere as a ComposeView, which is an ordinary Codename One Container:

class MyApp : Lifecycle() {
    override fun runApp() {
        ComposeForm { LayoutScreen() }.show()
    }
}

Or beside other components:

val form = Form("Mixed", BorderLayout())
form.add(BorderLayout.NORTH, Label("Codename One"))
form.add(BorderLayout.CENTER, ComposeView { ListScreen() })
form.show()

Both live in com.codename1.compose. From Java, a ComposeView is created by a small Kotlin function that returns one, since a composable lambda can only be written in Kotlin.

How the build works

KMP sources compiled by kotlinc with the Compose plugin, shipped with the unmodified Compose runtime and coroutines, then translated by ParparVM

There is no transpiler. kotlinc compiles the sources for the JVM, with the Compose compiler plugin, against compose-compat. The application’s classes, the unmodified Compose runtime, kotlinx.coroutines and compose-compat are then handled like any other Codename One code: the bytecode compliance check runs over them, and ParparVM translates them to C for iOS, the watch, TV and the native desktop targets. Android runs the classes on ART and the simulator on the JVM.

androidx/compose/ is never relocated, not even when the Android compatibility layer is active in the same application: the Compose runtime the application was compiled against is the one it runs.

How a composable becomes components

The application’s composables over the unmodified Compose runtime, over compose-compat, over Codename One core and the port

Composition, snapshots, remember, LaunchedEffect and recomposition are the real Compose runtime’s. What it emits are UiNode objects, each a Codename One Container:

  • Measuring is Compose’s. A node walks its modifier chain outermost first under Compose constraints (Box, Row/Column and leaf policies). The Codename One layout pass only copies the result out, so weight, fillMaxWidth, wrapContentSize and intrinsic padding behave as they do in Compose.

  • Frames come from the Codename One animation loop. ComposeHost runs the Recomposer with a BroadcastFrameClock that Form.registerAnimated ticks, so withFrameNanos, animateFloatAsState and AnimatedVisibility run in step with every other Codename One animation, and an idle screen costs nothing.

  • Dispatchers.Main is the EDT. kotlinx.coroutines finds its main dispatcher through a service file. There is no service loader on the device, so the translator compiles that file into a registry at build time.

  • Lazy lists compose what’s in view. LazyColumn and LazyRow compose each item as its own composition when it comes within a quarter of a view of the window and dispose of it half a view past it. scrollToItem works on items that were never measured.

  • The system bars are respected. TopAppBar, CenterAlignedTopAppBar and NavigationBar pad themselves by the display’s safe area, read when they’re measured, so a notch or a home indicator that the native view reports late still moves them.

Supported API

compose-compat covers what typical application screens use. Anything else is a build error (see below), never a silent no-op.

Layout

Box, Row, Column, Spacer, LazyColumn, LazyRow, Arrangement, Alignment, PaddingValues, Canvas.

Modifiers

size (size, width, height, fillMax*, wrapContent*, In, required, defaultMinSize, aspectRatio), padding, offset, weight, background, border, clip, alpha, drawBehind, clickable, combinedClickable, verticalScroll, horizontalScroll, testTag.

Text

Text, BasicText, TextField, OutlinedTextField, BasicTextField, TextStyle, FontWeight, the Material 3 type scale.

Material 3

MaterialTheme and ColorScheme, Scaffold, Surface, TopAppBar, CenterAlignedTopAppBar, NavigationBar and NavigationBarItem, Card, ElevatedCard, OutlinedCard, Button, ElevatedButton, FilledTonalButton, OutlinedButton, TextButton, IconButton, FloatingActionButton, ExtendedFloatingActionButton, AssistChip, FilterChip, Checkbox, Switch, the progress indicators, dividers, Icon with Icons.Filled, Icons.Outlined and Icons.AutoMirrored.

Animation

AnimatedVisibility, animateFloatAsState, animateDpAsState, animateColorAsState, rememberInfiniteTransition with animateFloat.

Images

Image with ImageVector and ContentScale.

LazyColumn(reverseLayout = true) throws: it’s not implemented.

Unsupported API is a build error

The bytecode compliance check runs over the compiled classes. A composable or modifier that compose-compat doesn’t provide is a reference to something that doesn’t exist, and the build reports it under its original name with the source line, before anything is translated. Port such a screen by replacing the call, or by writing the missing piece in the application with the supported building blocks — most custom components are a Box with a drawBehind.

Testing

Compose screens are tested the way any Codename One screen is: a ComposeView is a component, so screenshot tests and the component inspector see it like any other. The Codename One test application (scripts/hellocodenameone) carries Compose screens under common/src/main/kmp — layout and modifiers, typography and buttons, a Scaffold over a LazyColumn of cards, and state with LaunchedEffect and AnimatedVisibility — and its KmpCompat* screenshot tests compare them on every platform the test suite runs on.

Troubleshooting

The Compose compiler plugin reports a Kotlin version mismatch

The plugin and kotlinc are different versions. Take both from one property.

kotlinc or javac reject JVM 11 bytecode or inline functions

The module is compiling below JVM 11. The compose profile sets it: check that common/src/main/kmp exists and that nothing in the pom overrides kotlin.compiler.jvmTarget.

The Gradle build stops asking for org.jetbrains.kotlin.plugin.compose

Add the plugin line shown in the Gradle section.

Incremental compilation failed after changing the Kotlin version

Kotlin’s incremental cache from the old compiler is in target. Build once with clean.