- Requirements
- Quick start
- Writing screens
- Previews
- Using it with other Compose libraries
- How it works
- Limitations
Requirements
| Tool | Minimum |
|---|---|
| Xcode | 27.2 (Swift 6.4) |
| Kotlin | 2.4.20 |
| JDK | 17 |
| Deployment target | iOS 17, macOS 14, tvOS 17, watchOS 10 |
Xcode 27.2 is needed to build, because the Swift renderer calls the newest SwiftUI APIs behind #available checks. Apps still run on the older OS versions above.
Quick start
- Apply the plugin in your shared KMP module:
plugins {
kotlin("multiplatform")
id("dev.nucleusframework.compose-swiftui") version "0.4.0"
}
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework { baseName = "Shared" }
}
}
The plugin adds the dev.nucleusframework:compose-swiftui library, applies the Compose compiler and exports the library from your frameworks.
- Build the app once in Xcode. The plugin writes the Swift renderer to
iosApp/ComposeSwiftUIduring the standardembedAndSignAppleFrameworkForXcodebuild phase, and rewrites it on every build after that.
- Drag the
iosApp/ComposeSwiftUIfolder into your app target. You only do this once.
- Write a screen in Kotlin and show it from Swift:
import dev.nucleusframework.composeswiftui.*
fun helloScreen() = composeSwiftUI {
Text("Hello from Kotlin", modifier = Modifier.padding())
}
ComposeView { HelloScreenKt.helloScreen() }
ComposeView is a regular SwiftUI view, so you can put it in a TabView, a sheet or next to SwiftUI views.
To try it without setting anything up, open samples/kmp-app/iosApp/iosApp.xcodeproj and run it. The sample builds the library and the plugin from the sources of this repository and shows Navigation 3, a ViewModel, Compose Multiplatform content and Compose Multiplatform string resources.
To change where the renderer is written or which framework it imports:
composeSwiftUI {
swiftOutputDir = layout.projectDirectory.dir("../iosApp/ComposeSwiftUI")
frameworkName = "Shared"
}Writing screens
The Kotlin API is generated from Apple's SwiftUI SDK, so names match SwiftUI. These rules cover most of the translation:
| SwiftUI | Compose SwiftUI |
|---|---|
Text("Hi").font(.title).padding() | Text("Hi", modifier = Modifier.font(Font.title).padding()) |
VStack(spacing: 8) { … } | VStack(spacing = 8.0) { … } |
Image(systemName: "star") | Image(systemName = SFSymbol.star) |
Section { … } header: { Text("A") } | Section(header = { Text("A") }, content = { … }) |
Toggle("Wi-Fi", isOn: $on) | Toggle("Wi-Fi", isOn = on, onIsOnChange = { on = it }) |
ForEach(items, id: \.id) { … } | for (item in items) key(item.id) { … }, or LazyForEach(items, key = { it.id }) { … } for long lists |
Color.red.opacity(0.5), .secondary | Color.red.opacity(0.5), ShapeStyle.secondary |
CGFloat | Double |
"<strong>Bold</strong>" or a localization key | LocalizedKey("hello.title"). A plain String is shown verbatim. |
Modifiers apply in the order you chain them, exactly as in Swift.
A few SwiftUI features need closures, property wrappers or Swift types that Kotlin can't express. They have their own API:
| SwiftUI | Compose SwiftUI |
|---|---|
TabView(selection:) + Tab(_:systemImage:value:) | TabView(selection, onSelectionChange) { Tab("Home", systemImage = SFSymbol.house, value = AppTab.Home) { … } } |
Text concatenation, AttributedString | Text(buildStyledString { withStyle(SpanStyle(color = Color.gray, fontSize = 13.0)) { append("…") } }) |
NavigationStack(path:) + navigationDestination(for:) | NavigationStack(path, onPathChange, destination = { value -> }) { … } |
sheet(item:), fullScreenCover(item:), popover(item:) | Modifier.sheet(item, onDismiss) { item -> }, same for the others |
alert(_:isPresented:presenting:), confirmationDialog(…presenting:) | Modifier.alert(title, item, onDismiss, actions, message) |
@Environment(\.colorScheme), \.dismiss, size classes, locale… | EnvironmentReader { env -> } |
@FocusState + .focused($x) | Modifier.focused(isFocused, onFocusChange) |
DragGesture, MagnifyGesture, RotateGesture, SpatialTapGesture | Modifier.dragGesture(…), magnifyGesture, rotateGesture, onTapGesture { x, y -> } |
@Namespace + matchedGeometryEffect | NamespaceScope { ns -> } + Modifier.matchedGeometryEffect(id, ns) |
withAnimation(.spring) { … } | withAnimation(Animation.spring) { … } |
GeometryReader { proxy in … } | GeometryReader { size -> } |
Custom Layout | CustomLayout(policy) { … } with a Kotlin LayoutPolicy |
Custom Shape | PathShape { moveTo(0.0, 0.0); lineTo(1.0, 0.5); close() } |
Circle().trim(from:to:).stroke(…) | ShapeView(Circle(), stroke = …, trimFrom = 0.0, trimTo = 0.7) |
A Swift view (MapKit, UIViewRepresentable…) | NativeView("map") + ComposeRegistry.nativeViews["map"] = { args in … } |
| Values passed from Swift | ComposeView(input: value) { … } + hostInput<T>() in Kotlin |
Long lists
A Kotlin loop composes every row up front. For a long list, use LazyForEach: inside a List, each row is composed only while SwiftUI shows it, and disposed when it scrolls away.
List {
LazyForEach(messages, key = { it.id }) { message ->
MessageRow(message)
}
}
A row recomposes when its item changes, so prefer immutable items such as data classes. Each row should be one view (wrap several in an HStack or VStack). The list is fastest when the LazyForEach is the only child of its List.
Previews
Compose SwiftUI draws with real SwiftUI, so previews render with SwiftUI: in IntelliJ IDEA / Android Studio with the Compose SwiftUI IDE plugin, or in Xcode's canvas. Annotate a top-level, parameterless composable with @Preview:
@Preview(name = "Counter")
@Composable
fun CounterPreview() {
NavigationStack { CounterScreen() }
}
In IntelliJ IDEA and Android Studio
Previews in the IDE need the Compose SwiftUI IDE plugin. It is not on the JetBrains Marketplace yet, so for now it is installed manually:
- Download
compose-swiftui-intellij-plugin-<version>.zipfrom the GitHub releases, or build it with./gradlew -p intellij-plugin buildPlugin(the zip lands inintellij-plugin/build/distributions/). - In the IDE, open *Settings › Plugins › ⚙ › Install Plugin from Disk…*, pick the zip, and restart the IDE when it asks.
Repeat these steps to update: the IDE won't offer updates for a plugin installed from disk.
A Kotlin file with previews opens in a split editor, code on the left and the rendered previews on the right, like Compose's own preview. Each preview has a gutter icon that renders it and scrolls to it, and the previews are rendered again every time the file is saved.
Rendering runs the renderComposeSwiftUIPreviews Gradle task: it links the debug framework of the simulator target (iosSimulatorArm64, or iosX64 on Intel Macs), builds a small host app with the renderer, and snapshots every preview on the booted simulator (or boots the last available iPhone) into build/composeSwiftUI/previews/. It needs macOS with Xcode. The first run compiles the renderer (about a minute); after that, a Kotlin change only costs the framework link. Choose the simulator with composeSwiftUI { previewDevice.set("iPhone 17 Pro") }.
In Xcode
On every build, the plugin writes ComposeSwiftUIPreviews.swift next to the renderer (iosApp/ComposeSwiftUI), with one Swift #Preview per annotated function. Open that file in Xcode to see the previews in the canvas. The same file declares ComposeSwiftUIPreviewGallery, a SwiftUI view that lists every preview, so you can also browse them on a simulator or device, for example from a debug menu:
.sheet(isPresented: $showPreviews) { ComposeSwiftUIPreviewGallery() }
- Previews are found by scanning the sources, so any annotation named
Previewworks, includingorg.jetbrains.compose.ui.tooling.preview.Preview. The function must be declared at the start of a line (not indented), must not be private and must take no parameters. - Only source sets compiled into every Apple framework are scanned:
commonMain,appleMain, andiosMainfor an iOS-only app. A preview iniosMainis skipped if the module also has macOS targets. - If you added the
ComposeSwiftUIfolder to Xcode as a group before this version, add the newComposeSwiftUIPreviews.swiftfile to the app target once.
Using it with other Compose libraries
Navigation 3
NavDisplay shows any observable back stack with SwiftUI navigation, so you can keep the Navigation 3 runtime (NavKey, entryProvider, your back stack) and get the native push animation and swipe back:
val backStack = remember { mutableStateListOf<Route>(Route.Home) }
val entries = entryProvider<Route> {
entry<Route.Home> { HomeScreen(onOpen = { backStack.add(it) }) }
entry<Route.Detail> { key -> DetailScreen(key.id) }
}
NavDisplay(backStack) { route -> entries(route).Content() }
Push with backStack.add(...). The SwiftUI back button and swipe remove keys from the back stack. NavDisplay from navigation3-ui draws with Compose UI, so use this one instead.
Compose Multiplatform content (iOS)
Embed a Compose Multiplatform screen in a Compose SwiftUI screen the same way you would in SwiftUI, through its view controller:
// iosMain
UIViewControllerView(modifier = Modifier.frame(height = 260.0)) {
ComposeUIViewController { SalesChart() }
}
By default the embedded view takes the size SwiftUI proposes, so give it a frame. For content of variable height, such as a multi-line label, pass sizing = NativeViewSizing.FitHeight: the view takes the proposed width and the height its content needs. update runs on every recomposition, so the view follows Kotlin state:
UIKitView(
sizing = NativeViewSizing.FitHeight,
update = { (it as UILabel).text = paragraph },
) { UILabel().apply { numberOfLines = 0 } }
The embedded content has its own composition. Share state between the two through a ViewModel or a StateFlow. Your app's Info.plist needs CADisableMinimumFrameDurationOnPhone set to true, as for any Compose Multiplatform app. UIKitView, NSViewControllerView and AppKitView embed other UIKit and AppKit views the same way.
Compose Multiplatform resources
stringResource(Res.string.title) works in Compose SwiftUI screens once you provide Compose UI's LocalDensity, which Compose Multiplatform resources read:
fun app() = composeSwiftUI {
CompositionLocalProvider(LocalDensity provides Density(UIScreen.mainScreen.scale.toFloat())) {
Text(stringResource(Res.string.title))
}
}
The suspending getString(Res.string.title) works without it, for example in a ViewModel.
How it works
Kotlin Swift
@Composable VStack / Text / … ComposeView
→ one node per view, with its arguments ──▶ one NodeView per node
and modifier chain → builds the SwiftUI view and applies its modifiers
Recomposition updates changed nodes ──▶ only those NodeViews re-render
Kotlin callbacks ◀── actions and bindings
Each node has an observable model on the Swift side. When state changes, Compose recomposes and updates only the nodes whose arguments changed, and SwiftUI re-renders only those views. Children keep stable identities, so SwiftUI animations, transitions and the state of native controls behave as in a SwiftUI app.
Bindings such as TextField, Toggle and Slider are controlled by Kotlin state. A user edit writes the new value, calls your Kotlin callback and recomposes before SwiftUI draws the next frame, so typing doesn't jump.
The Kotlin API and the matching Swift renderer are generated from the symbol graphs of the iOS, macOS, tvOS and watchOS SDKs. Every generated API is compiled for all four platforms before it ships.
Limitations
- visionOS isn't supported: Kotlin/Native has no visionOS target.
- APIs that take Swift-only types (
UTType,Transferable,KeyPath…) aren't available. For mixed-style text, useText(StyledString)instead ofAttributedString. docs/COVERAGE.md gives the reason for each one. - Data-driven containers such as
List(data)andTabletake a Kotlin loop orLazyForEachinstead of a collection. - On iOS, two benchmark screens differ from plain SwiftUI by less than a pixel (one SF Symbol, and the search field placeholder after updates).