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:
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.

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=composeAppThe module to import, when more than one has a
src/commonMain.-Dcn1.kmp.entry=com.example.AppThe fully qualified root composable, when
commonMaindeclares 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
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
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/Columnand leaf policies). The Codename One layout pass only copies the result out, soweight,fillMaxWidth,wrapContentSizeand intrinsic padding behave as they do in Compose.Frames come from the Codename One animation loop.
ComposeHostruns theRecomposerwith aBroadcastFrameClockthatForm.registerAnimatedticks, sowithFrameNanos,animateFloatAsStateandAnimatedVisibilityrun in step with every other Codename One animation, and an idle screen costs nothing.Dispatchers.Mainis 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.
LazyColumnandLazyRowcompose 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.scrollToItemworks on items that were never measured.The system bars are respected.
TopAppBar,CenterAlignedTopAppBarandNavigationBarpad 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
MaterialThemeandColorScheme,Scaffold,Surface,TopAppBar,CenterAlignedTopAppBar,NavigationBarandNavigationBarItem,Card,ElevatedCard,OutlinedCard,Button,ElevatedButton,FilledTonalButton,OutlinedButton,TextButton,IconButton,FloatingActionButton,ExtendedFloatingActionButton,AssistChip,FilterChip,Checkbox,Switch, the progress indicators, dividers,IconwithIcons.Filled,Icons.OutlinedandIcons.AutoMirrored.- Animation
AnimatedVisibility,animateFloatAsState,animateDpAsState,animateColorAsState,rememberInfiniteTransitionwithanimateFloat.- Images
ImagewithImageVectorandContentScale.
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
composeprofile sets it: check thatcommon/src/main/kmpexists and that nothing in the pom overrideskotlin.compiler.jvmTarget.- The Gradle build stops asking for
org.jetbrains.kotlin.plugin.compose Add the plugin line shown in the Gradle section.
Incremental compilation failedafter changing the Kotlin versionKotlin’s incremental cache from the old compiler is in
target. Build once withclean.