Compose
Compose Multiplatform lets you build user interfaces in Kotlin for the desktop, the browser, iOS and Android, from shared code. Kargo sets up the compiler plugin, the Compose libraries of each target, Material 3 and Compose resources. It reloads a running desktop app when you save, renders @Preview functions to images, and packages desktop apps as installers.
Create the project
Section titled “Create the project”kargo new counter -w composecd counterkargo runA window opens with a button that counts clicks:
package com.example.counter
import androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.material3.Buttonimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Textimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.window.Windowimport androidx.compose.ui.window.application
fun greeting(name: String) = "Hello, $name!"
fun main() = application { Window(onCloseRequest = ::exitApplication, title = "counter") { MaterialTheme { var clicks by remember { mutableStateOf(0) } Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Text(greeting("Compose")) Button(onClick = { clicks++ }) { Text("Clicked $clicks times") } } } } }One UI for every target
Section titled “One UI for every target”--with compose-multiplatform creates a project whose UI lives in src/App.kt and runs on the desktop and in the browser:
kargo new counter -w compose-multiplatformcd counterkargo run # the desktop appkargo run -t wasm # the same UI in the browser, at http://127.0.0.1:8080/counter/ kargo.toml [target.jvm], [target.wasm] environment = "browser", [frameworks] compose = true src/App.kt @Composable fun App(), shared by every target src-jvm/Main.kt the desktop window, and a @Preview of App() src-wasm/Main.kt ComposeViewport(document.body!!) { App() } tests/GreetingTest.ktAdd -t android for an Android app whose activity shows App(), and -t native for an iOS framework (see iOS). Each target keeps its entry point in its own src-<target>/ directory, and src/ holds what they share. See Multiplatform projects for how the directories combine.
How state works
Section titled “How state works”remember { mutableStateOf(0) } creates a value that survives redraws. When it changes, Compose redraws the parts of the UI that read it. Split your UI into @Composable functions:
import androidx.compose.runtime.Composable
@Composablefun TodoList(items: List<String>, onRemove: (String) -> Unit) { Column { for (item in items) { Row(verticalAlignment = Alignment.CenterVertically) { Text(item, Modifier.weight(1f)) TextButton(onClick = { onRemove(item) }) { Text("Remove") } } } }}Coroutines in the UI
Section titled “Coroutines in the UI”Enable coroutines too and Kargo adds kotlinx-coroutines-swing, which provides the main (UI) dispatcher on desktop:
[frameworks]compose = truecoroutines = trueResources
Section titled “Resources”Put images, fonts, strings and other files in composeResources/, next to src/. Kargo generates the Res class that Compose Multiplatform code uses, the same one the Compose Gradle plugin generates, and puts the files on the classpath:
composeResources/├── drawable/logo.png Res.drawable.logo├── drawable-dark/logo.png the same image, in dark mode├── font/Inter.ttf Res.font.Inter├── values/strings.xml Res.string.*, Res.array.*, Res.plurals.*├── values-de/strings.xml the German strings└── files/data.json Res.readBytes("files/data.json")<resources> <string name="app_name">Notes</string> <string name="greeting">Hello, %1$s!</string> <string-array name="colors"> <item>Red</item> <item>Green</item> </string-array> <plurals name="notes"> <item quantity="one">%1$d note</item> <item quantity="other">%1$d notes</item> </plurals></resources>import com.example.notes.generated.resources.*import org.jetbrains.compose.resources.painterResourceimport org.jetbrains.compose.resources.pluralStringResourceimport org.jetbrains.compose.resources.stringResource
@Composablefun Header(count: Int) { Image(painterResource(Res.drawable.logo), contentDescription = null) Text(stringResource(Res.string.greeting, "Kargo")) Text(pluralStringResource(Res.plurals.notes, count, count))}The rules follow Compose Multiplatform’s:
- A resource’s name is its file name without the extension, with
-written as_(compose-logo.pngisRes.drawable.compose_logo). Strings take thenameattribute. - Directory qualifiers pick a variant: a language (
values-de), a language and region (values-de-rAT),lightordark, and a density (drawable-xxhdpi). They combine, as indrawable-de-dark. files/holds anything else, read withRes.readBytes(path)orRes.getUri(path). It takes no qualifiers.- String values support Android’s escapes:
\n,\t,\',\",\@and\uXXXX.
Res lives in <group>.<name>.generated.resources (lower case, - as _) and is internal. Both are configurable:
[frameworks]compose = { resources-package = "com.example.notes.res", public-resources = true }Kargo adds the resources runtime (org.jetbrains.compose.components:components-resources) once composeResources/ exists, writes Res.kt into target/<profile>/<target>/generated/compose-resources/ (target/<profile>/wasm-generated/compose-resources/ on Wasm) on every build and rebuilds when a resource changes, also under kargo run --watch. Res belongs to the code every target shares, so src/ can use it. The same directory works for the Android target, where the files become assets of the APK, for the web, where they are served next to the page, and for iOS.
Compiler options
Section titled “Compiler options”The Compose compiler takes the options its Gradle plugin’s composeCompiler { } block sets, as keys of [frameworks.compose]:
[frameworks]compose = { stability-config = "compose-stability.conf", metrics = true, reports = true }// Classes the compiler should treat as stable.java.time.*com.example.notes.model.Note| Key | Default | What it does |
|---|---|---|
stability-config |
none | One or more stability configuration files, relative to the package. The build fails if one is missing |
strong-skipping |
true |
Strong skipping mode. false makes composables with unstable parameters non-skippable again |
feature-flags |
[] |
Compose compiler feature flags: StrongSkipping, IntrinsicRemember, OptimizeNonSkippingGroups and PausableComposition. Prefix one with - to disable it, as in "-IntrinsicRemember" |
metrics |
false |
Write the compiler’s metrics (<module>-module.json) to target/compose/<target>/metrics/ |
reports |
false |
Write the compiler’s stability reports (<module>-classes.txt, <module>-composables.txt, <module>-composables.csv) to target/compose/<target>/reports/ |
<target> is jvm or android; in a workspace the directories are target/compose/<member>/<target>/. The options apply to both targets: on the JVM Kargo passes them to the compiler plugin, on Android it adds them to the generated Gradle project. Changing them rebuilds the package.
The Compose compiler ships with Kotlin, so its version is always [kotlin] version: there is no separate compiler version to match. The runtime (version, default 1.12.1) can be any Compose Multiplatform release that supports that Kotlin version.
Running
Section titled “Running”kargo run starts the application with -Dcompose.application.configure.swing.globals=true, as the Compose Gradle plugin does. On macOS it also passes the application’s name and icon to the Dock (-Xdock:name, -Xdock:icon) from [package.installer], so a development build looks like the installed one:
[package.installer]name = "Notes"icon = { windows = "icons/notes.ico", macos = "icons/notes.icns", linux = "icons/notes.png" }On Windows and Linux the taskbar shows the window’s title and the icon you pass to Window(icon = ...).
Hot reload
Section titled “Hot reload”$ kargo run --hot-reload Running `java com.example.counter.MainKt` Changed src/App.kt Compiling counter v0.1.0 (jvm) Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.64s Reloaded 2 classes--hot-reload works like --watch: Kargo rebuilds when a file changes. The desktop app keeps running, though. Kargo swaps the classes that changed into it and Compose recomposes the window with the new code, keeping what remember holds, such as the click count. A build that fails leaves the app as it was.
The app runs on JetBrains Runtime (JBR) with -XX:+AllowEnhancedClassRedefinition, so new functions, fields and classes reload too. Kargo downloads the JBR of your project’s Java version into ~/.kargo/toolchains/jbr/ the first time (about 300 MB) and checks it against the SHA-512 checksum JetBrains publishes. Offline, or where there is no JBR, the app runs on the project’s JDK with a warning, and only changes inside function bodies reload.
Kargo restarts the app instead when a change cannot be applied in place, and when the command line changes, as with a new dependency or JVM option. The app runs from a copy of its classes in target/<profile>/jvm/hot-reload/, which also holds agent.log, a record of each reload.
--hot-reload needs [frameworks] compose and the jvm target. It takes the options of --watch (--clear, --ignore), and not --debug or --native-agent.
Previews
Section titled “Previews”Annotate a top-level @Composable function without parameters with @Preview, and kargo preview renders it to a PNG file:
import androidx.compose.runtime.Composableimport androidx.compose.ui.tooling.preview.Preview
@Preview@Composablefun AppPreview() { App()}$ kargo preview --listcom.example.counter.AppPreview src-jvm/Main.kt:18$ kargo preview --density 2 Compiling the preview renderer Rendering 1 preview Rendered target/preview/com.example.counter.AppPreview.pngWithout names it renders every preview; kargo preview AppPreview renders one, by its function or qualified name. Each image is --width by --height density-independent pixels (800 by 600 by default) times --density. It is drawn off screen, without a window, so kargo preview also works on a CI machine without a display. --out-dir writes the files somewhere other than target/preview/.
Previews come from the JVM sources (src/ and src-jvm/) and are drawn by Compose Desktop, so the project needs the jvm target. Any of the Compose @Preview annotations counts. A preview with parameters (@PreviewParameter) or inside a class is left out.
Editors see them too: kargo lsp lists each preview as a runnable of kind preview, and the IntelliJ plugin puts a render icon in the gutter of each one and a Previews group in the Kargo tool window.
Compose for the web
Section titled “Compose for the web”Compose for Web draws the UI on a canvas with Skia compiled to WebAssembly. Enable Compose and the Wasm target for the browser:
[target.wasm]environment = "browser"
[frameworks]compose = trueimport androidx.compose.ui.ExperimentalComposeUiApiimport androidx.compose.ui.window.ComposeViewportimport kotlinx.browser.document
@OptIn(ExperimentalComposeUiApi::class)fun main() { ComposeViewport(document.body!!) { App() }}kargo run -t wasm builds the app and serves it. kargo build -t wasm writes the site into target/<profile>/wasm/:
- the module (
<name>.mjs,<name>.wasm) and Skiko’s runtime (skiko.mjs,skiko.wasm), taken from theskiko-js-wasm-runtimejar the Compose libraries depend on, composeResources/, the filesResfetches,- an
index.htmlwhose canvas fills the window, unlessresources/index.htmlis your own page.
Compose for Web needs environment = "browser"; with js or wasi the build stops with an error. A release build runs wasm-opt on the module and reports its size, as for any Wasm app.
Compose for iOS is a framework that an Xcode project shows through a UIViewController. Set the Native target to iOS platforms and kind = "framework":
[target.native]platforms = ["ios_arm64", "ios_simulator_arm64"]kind = "framework"
[frameworks]compose = trueimport androidx.compose.ui.window.ComposeUIViewControllerimport platform.UIKit.UIViewController
fun MainViewController(): UIViewController = ComposeUIViewController { App() }kargo build -t native writes target/<profile>/native/<platform>/<name>.framework, and kargo build -t native --xcframework one <name>.xcframework for the devices and the simulators (see Kotlin/Native). In Swift, show the controller:
import SwiftUIimport counter
struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { MainViewControllerKt.MainViewController() } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}}Compose for iOS reads resources from a compose-resources folder in the app bundle. Kargo writes that folder to target/<profile>/native/compose-resources/; add it to the app target’s Copy Bundle Resources phase as a folder reference.
The platforms can be ios_arm64, ios_simulator_arm64 and ios_x64. Building them needs macOS with Xcode.
Distributing
Section titled “Distributing”kargo package --desktop builds an installer for the OS it runs on: an .msi on Windows, a .dmg on macOS and a .deb on Linux, with a Java runtime trimmed by jlink, the application’s name and icon and a Start menu or launcher entry:
[package.installer]name = "Notes"vendor = "Example"icon = { windows = "icons/notes.ico", macos = "icons/notes.icns", linux = "icons/notes.png" }type = ["msi", "dmg", "deb"]menu = truefile-associations = [{ extension = "note", mime-type = "application/x-note", description = "Note" }]kargo package --desktop --releasetype lists one installer per OS; each machine builds the ones for its OS, so the same kargo.toml works on a Windows, a macOS and a Linux CI runner. Distributing applications describes every [package.installer] key.
For Compose, the installer adds what the Compose Gradle plugin’s nativeDistributions would:
- the JDK modules Compose needs (
java.desktop,java.logging,jdk.unsupported) besides the onesjdepsfinds; - Skiko’s native library unpacked next to the jars (
-Dskiko.library.path=$APPDIR) instead of inside a jar, so macOS signs and notarizes it with the rest of the application; -Dcompose.application.configure.swing.globals=trueand, on JDK 21 and newer,--enable-native-access=ALL-UNNAMED.
Add [target.jvm] jvm-args or a profile’s jvm-args for options of your own, such as -Xmx512m.
Signing and notarizing on macOS
Section titled “Signing and notarizing on macOS”Gatekeeper opens a downloaded application only when it is signed with a Developer ID and notarized by Apple. Set the signing identity and the notarization credentials:
[package.installer]bundle-id = "com.example.notes"signing-identity = "Developer ID Application: Example Inc (ABCDE12345)"notarize = { apple-id-env = "APPLE_ID", password-env = "APPLE_APP_PASSWORD", team-id = "ABCDE12345" }export APPLE_ID=you@example.comexport APPLE_APP_PASSWORD=abcd-efgh-ijkl-mnop # an app-specific passwordkargo package --desktop --releasejpackage signs the application, Skiko’s library included. Kargo then sends each .dmg and .pkg to Apple’s notary service with xcrun notarytool submit --wait and staples the ticket to it with xcrun stapler staple. The credentials stay in environment variables, so kargo.toml holds no secret. With credentials that xcrun notarytool store-credentials saved in the keychain, use notarize = { keychain-profile = "notes" } instead. On Windows and Linux, notarize is ignored.
One jar for every OS
Section titled “One jar for every OS”kargo package --desktop --format jar --platforms alljava -jar target/debug/jvm/notes-0.1.0-all.jarA plain kargo package jar carries Skiko’s library for the OS it was built on only. --platforms sets the platforms whose libraries it carries: all, or a list of windows-x64, windows-arm64, macos-x64, macos-arm64, linux-x64 and linux-arm64. Skiko loads the right one at startup. The jar needs a Java runtime on the machine that runs it, and each platform adds about 10 MB to it.
Android
Section titled “Android”The same compose = true turns on Jetpack Compose for the Android target. kargo new app -t android -w compose creates a Compose MainActivity. composeResources/ and Res work there too.