Unifying Android and iOS UI with Kotlin Multiplatform: Building a Shared Declarative Layout DSL that Compiles to Compose and SwiftUI
Developing mobile apps for both Android and iOS traditionally means maintaining two separate UI codebases. Kotlin Multiplatform (KMP) offers a powerful way to break that barrier by sharing business logic, but UI layers often remain fragmented. In this step‑by‑step guide, we’ll walk you through creating a **platform‑agnostic UI layer**: a shared declarative layout DSL written in Kotlin that compiles down to Jetpack Compose for Android and SwiftUI for iOS. This approach eliminates duplicate effort, guarantees design consistency, and keeps your UI logic in a single, testable source.
Why a Shared DSL? The Pain Points of Separate UI Code
- Design drift – Even minor changes on one platform can slip through unnoticed on the other.
- Increased maintenance – Every UI tweak requires two separate code reviews and merges.
- Testing overhead – Unit tests must be written for two frameworks, inflating QA time.
- Learning curve – New developers must master both Compose and SwiftUI syntax.
By centralizing UI definitions, you resolve these issues and empower your team to focus on feature development instead of framework differences.
Project Architecture Overview
Our sample project follows a clean‑architecture pattern with the following modules:
- commonMain – Shared domain logic and the UI DSL.
- androidApp – Android entry point, Compose renderer, and platform‑specific resources.
- iosApp – iOS target, SwiftUI renderer, and bridging code.
- sharedUI – The heart of the DSL, exposing composable widgets that map to Compose or SwiftUI components.
Each module has its own Gradle configuration, but the DSL lives exclusively in commonMain/src/commonMain/kotlin/ui.
Step 1: Setting Up Kotlin Multiplatform with Compose & SwiftUI Support
Begin by creating a new Gradle project with the Kotlin Multiplatform plugin and the Compose for Desktop plugin (for Android) plus the SwiftUI target. Add the following to your build.gradle.kts:
plugins {
id("org.jetbrains.kotlin.multiplatform") version "1.9.10"
id("org.jetbrains.compose") version "1.5.0"
id("org.jetbrains.kotlin.native.cocoapods") version "1.9.10"
}
kotlin {
android()
iosX64()
iosArm64()
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
// UI DSL dependencies
implementation("org.jetbrains.compose.ui:ui:1.5.0")
}
}
val androidMain by getting {
dependencies {
implementation("androidx.compose.ui:ui:1.5.0")
implementation("androidx.compose.material:material:1.5.0")
}
}
val iosMain by getting {
dependencies {
implementation("org.jetbrains.compose.ui:ui:1.5.0")
}
}
}
}
Configure the CocoaPods section to expose a shared framework that SwiftUI can consume:
cocoapods {
summary = "Shared UI DSL for Kotlin Multiplatform"
ios.deploymentTarget = "13.0"
framework {
baseName = "SharedUI"
}
}
Step 2: Defining the Shared DSL
Our DSL will mirror Compose’s declarative syntax while providing a mapping layer to SwiftUI. Create a Layout.kt file inside commonMain/src/commonMain/kotlin/ui and define basic primitives: Column, Row, Text, Button, etc.
DSL Basics
sealed class UIElement {
data class Column(val modifier: Modifier = Modifier, val content: List) : UIElement()
data class Row(val modifier: Modifier = Modifier, val content: List) : UIElement()
data class Text(val text: String, val modifier: Modifier = Modifier) : UIElement()
data class Button(val label: String, val onClick: () -> Unit, val modifier: Modifier = Modifier) : UIElement()
}
Use a builder pattern to simplify construction:
inline fun column(modifier: Modifier = Modifier, content: UIBlock.() -> Unit): UIElement.Column {
val block = UIBlock()
block.content()
return UIElement.Column(modifier, block.elements)
}
inline fun row(modifier: Modifier = Modifier, content: UIBlock.() -> Unit): UIElement.Row {
val block = UIBlock()
block.content()
return UIElement.Row(modifier, block.elements)
}
inline fun text(text: String, modifier: Modifier = Modifier) = UIElement.Text(text, modifier)
inline fun button(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) =
UIElement.Button(label, onClick, modifier)
class UIBlock {
val elements = mutableListOf()
fun add(element: UIElement) = elements.add(element)
}
Modifiers and Styling
To keep styling consistent, expose a Modifier type with common functions such as padding, background, and size. Internally, the modifier will map to Compose or SwiftUI style objects.
class Modifier private constructor(val params: List) {
companion object {
val Empty = Modifier(listOf())
}
fun padding(all: Dp) = Modifier(params + ModifierParam.Padding(all))
fun background(color: Color) = Modifier(params + ModifierParam.Background(color))
// Additional helpers...
}
sealed class ModifierParam {
data class Padding(val value: Dp) : ModifierParam()
data class Background(val color: Color) : ModifierParam()
}
Step 3: Rendering to Compose (Android)
Create an AndroidRenderer.kt in androidMain/src/main/kotlin/ui. This file will recursively translate UIElement instances into Compose composables.
Renderer Skeleton
@Composable
fun render(element: UIElement) {
when (element) {
is UIElement.Column -> {
Column(modifier = mapModifier(element.modifier)) {
element.content.forEach { render(it) }
}
}
is UIElement.Row -> {
Row(modifier = mapModifier(element.modifier)) {
element.content.forEach { render(it) }
}
}
is UIElement.Text -> {
Text(text = element.text, modifier = mapModifier(element.modifier))
}
is UIElement.Button -> {
Button(onClick = element.onClick, modifier = mapModifier(element.modifier)) {
Text(element.label)
}
}
}
}
fun mapModifier(modifier: Modifier): Modifier = composeModifier {
modifier.params.forEach { param ->
when (param) {
is ModifierParam.Padding -> padding(param.value)
is ModifierParam.Background -> background(param.color.toComposeColor())
}
}
}
The composeModifier builder is the standard Compose Modifier DSL. The toComposeColor() helper converts our shared Color type to androidx.compose.ui.graphics.Color.
Step 4: Rendering to SwiftUI (iOS)
In the iOS target, create SwiftUIRenderer.kt inside iosMain/src/commonMain/kotlin/ui. Because SwiftUI is native Swift, we’ll expose a Kotlin class that returns a SwiftUI AnyView via the Kotlin/Native bridging mechanism.
SwiftUI Renderer
@OptIn(ExperimentalNativeApi::class)
class SwiftUIRenderer {
fun render(element: UIElement): ObjCObject {
return when (element) {
is UIElement.Column -> createColumn(element)
is UIElement.Row -> createRow(element)
is UIElement.Text -> createText(element)
is UIElement.Button -> createButton(element)
}
}
private fun createColumn(element: UIElement.Column): ObjCObject {
val swiftUI = NSClassFromString("SwiftUI.Column")!!
val items = element.content.map { render(it) }
return swiftUI.invokeMethod("init", "content", items)
}
// Implement createRow, createText, createButton similarly
}
SwiftUI’s declarative API is exposed via SwiftUI.Column, SwiftUI.Row, etc., which you create in a small Swift wrapper. The Kotlin renderer builds the view hierarchy and returns a root AnyView that your iOS app embeds in a UIHostingController.
Step 5: Bridging from Swift to Kotlin
In the iOS project’s AppDelegate.swift, set up the shared framework and instantiate the renderer:
import SharedUI
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let rootElement = UIBuilder.buildRoot()
let renderer = SwiftUIRenderer()
let swiftView = renderer.render(rootElement) as? AnyView
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = UIHostingController(rootView: swiftView!)
window?.makeKeyAndVisible()
return true
}
}
Step 6: Defining a Sample UI
With the renderer in place, define a reusable UI component using the shared DSL. Place this in commonMain/src/commonMain/kotlin/ui/Component.kt:
fun buildRoot(): UIElement {
return column(modifier = Modifier.padding(16.dp)) {
text("Welcome to the Shared UI Demo", modifier = Modifier.padding(bottom = 8.dp))
row {
button("Press Me", onClick = { println("Button tapped") })
button("Share", onClick = { println("Share tapped") })
}
}
}
This component can now be rendered identically on both platforms. Any change to the layout or styling propagates instantly to Android and iOS without editing platform‑specific code.
Step 7: Testing the Shared UI
Unit tests for the DSL should run in the commonTest source set. Use the assert library to validate the tree structure:
import kotlin.test.*
class UITest {
@Test
fun testRootStructure() {
val root = buildRoot()
assertTrue(root is UIElement.Column)
val column = root as UIElement.Column
assertEquals(2, column.content.size)
assertTrue(column.content[0] is UIElement.Text)
assertTrue(column.content[1] is UIElement.Row)
}
}
Platform‑specific rendering can be validated with instrumentation tests: Compose’s AndroidComposeTestRule and SwiftUI’s XCTest. These tests confirm that the rendered UI matches expectations.
Step 8: Performance Considerations
- Lazy lists – Add
LazyColumnandLazyRowprimitives to your DSL to support large data sets. - Diffing – Compose automatically performs diffing. For SwiftUI, wrap the view in
AnyViewand rely on SwiftUI’s state management. - Memory overhead – Keep the DSL lightweight; avoid storing large data objects in modifiers.
Step 9: Extending the DSL for Complex Layouts
As your app grows, you may need more advanced widgets such as Card, List, or custom AnimatedContainer. Add them to UIElement and implement corresponding render functions. Maintain a single source of truth by keeping all widget definitions in commonMain and delegating rendering logic to platform modules.
Step 10: Deploying the Shared Framework
For iOS, publish the Kotlin/Native framework to your CocoaPods repo or include it directly in your Xcode project. For Android, ensure the module is added as a Gradle dependency. The final package contains one binary per platform but a single source of UI logic.
Conclusion
By leveraging Kotlin Multiplatform to create a shared declarative UI DSL that compiles to Jetpack Compose and SwiftUI, you break the traditional divide between Android and iOS development. This approach guarantees consistent design, reduces maintenance overhead, and streamlines testing. Start building your cross‑platform UI today with our sample project!
