Skip to main content

Kotlin Compose Multiplatform

Introduction

Kotlin Compose Multiplatform is a modern UI framework that allows you to build native applications for multiple platforms including Android, iOS, desktop, and web using a single codebase written in Kotlin. It's an extension of Jetpack Compose (Android's UI toolkit) to other platforms, enabling developers to share UI code across different platforms while maintaining native performance and appearance.

In this guide, we'll explore how Compose Multiplatform works, how to set up a project, and how to build simple cross-platform applications that run on multiple devices from a shared codebase.

What is Compose Multiplatform?

Compose Multiplatform combines several powerful technologies:

  1. Kotlin Multiplatform: The foundation that allows sharing code across platforms
  2. Jetpack Compose: Google's declarative UI toolkit originally built for Android
  3. Skia: A graphics engine that powers rendering on multiple platforms

Together, these technologies allow you to write UI code once and deploy it to Android, iOS, desktop (Windows, macOS, Linux), and web platforms.

Key Benefits

  • Shared UI Logic: Write UI code once instead of implementing the same screens for each platform
  • Declarative Paradigm: Describe what your UI should look like based on state, not how to update it
  • Kotlin-First: Leverage Kotlin's modern features like coroutines, flows, and DSLs
  • Native Performance: Applications compile to native code for optimal performance
  • Platform-Specific APIs: Access platform-specific functionality when needed

Setting Up a Compose Multiplatform Project

Let's start by setting up a basic Compose Multiplatform project:

Prerequisites

  • JDK 17 or newer
  • Android Studio Arctic Fox or newer
  • Xcode (for iOS development)

Creating a New Project

The easiest way to create a Compose Multiplatform project is using the Compose Multiplatform template:

  1. Install the Kotlin Multiplatform Mobile (KMM) plugin in Android Studio
  2. Go to File → New → New Project
  3. Select "Compose Multiplatform Application" template
  4. Configure your project details (name, package name, etc.)
  5. Select the platforms you want to target (Android, iOS, Desktop, Web)
  6. Click Finish

This will generate a project structure with platform-specific modules and shared code.

Project Structure

A typical Compose Multiplatform project includes:

my-project/
├── androidApp/ # Android-specific code
├── iosApp/ # iOS-specific code (Swift/Objective-C)
├── desktopApp/ # Desktop-specific code
├── webApp/ # Web-specific code
└── shared/ # Shared Kotlin code including Compose UI
├── commonMain/ # Common code for all platforms
├── androidMain/ # Android-specific shared code
├── iosMain/ # iOS-specific shared code
├── desktopMain/ # Desktop-specific shared code
└── webMain/ # Web-specific shared code

Your First Compose Multiplatform App

Let's build a simple "Hello World" app that works across platforms:

1. Shared UI Code

In your shared/commonMain/kotlin/ directory, create a file called App.kt:

kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

@Composable
fun App() {
MaterialTheme {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Hello, Compose Multiplatform!")
}
}
}

2. Platform-Specific Entry Points

Each platform needs a small amount of platform-specific code to bootstrap the app:

Android (androidApp/src/main/java/.../MainActivity.kt)

kotlin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}

Desktop (desktopApp/src/jvmMain/kotlin/.../Main.kt)

kotlin
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application

fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "My Compose App") {
App()
}
}

iOS (through Kotlin/Native)

For iOS, Compose Multiplatform will generate the necessary Swift code to integrate with your shared Compose UI.

Building More Complex UIs

Let's create a more interesting example - a simple counter app that works across platforms:

kotlin
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun CounterApp() {
var counter by remember { mutableStateOf(0) }

MaterialTheme {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Count: $counter",
style = MaterialTheme.typography.h4
)

Spacer(modifier = Modifier.height(16.dp))

Row {
Button(
onClick = { counter-- },
modifier = Modifier.padding(8.dp)
) {
Text("Decrease")
}

Button(
onClick = { counter++ },
modifier = Modifier.padding(8.dp)
) {
Text("Increase")
}
}
}
}
}

Now update your App.kt file to use this counter app:

kotlin
@Composable
fun App() {
CounterApp()
}

When you run the app on different platforms, it will display a counter with increment and decrement buttons that maintain state and update the UI.

Adding Platform-Specific Features

Sometimes you need to access platform-specific APIs. Here's how to do that:

1. Define an Expected Interface

In your shared code (commonMain):

kotlin
// In shared/commonMain/kotlin/.../Platform.kt
expect class PlatformInfo {
fun getPlatformName(): String
}

2. Implement for Each Platform

For Android (androidMain):

kotlin
// In shared/androidMain/kotlin/.../Platform.kt
actual class PlatformInfo {
actual fun getPlatformName(): String = "Android"
}

For iOS (iosMain):

kotlin
// In shared/iosMain/kotlin/.../Platform.kt
actual class PlatformInfo {
actual fun getPlatformName(): String = "iOS"
}

For Desktop (desktopMain):

kotlin
// In shared/desktopMain/kotlin/.../Platform.kt
actual class PlatformInfo {
actual fun getPlatformName(): String = "Desktop"
}

3. Use in Your App

kotlin
@Composable
fun PlatformSpecificApp() {
val platformInfo = remember { PlatformInfo() }
var counter by remember { mutableStateOf(0) }

MaterialTheme {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Platform: ${platformInfo.getPlatformName()}",
style = MaterialTheme.typography.h6
)

Spacer(modifier = Modifier.height(8.dp))

Text(
text = "Count: $counter",
style = MaterialTheme.typography.h4
)

Spacer(modifier = Modifier.height(16.dp))

Button(onClick = { counter++ }) {
Text("Increment")
}
}
}
}

Handling State and Navigation

State management and navigation are crucial for any real-world application. Here's a simple example using ViewModel pattern:

kotlin
class CounterViewModel {
private val _count = mutableStateOf(0)
val count: State<Int> = _count

fun increment() {
_count.value++
}

fun decrement() {
_count.value--
}
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = remember { CounterViewModel() }) {
val count by viewModel.count

Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Count: $count",
style = MaterialTheme.typography.h4
)

Spacer(modifier = Modifier.height(16.dp))

Row {
Button(
onClick = { viewModel.decrement() },
modifier = Modifier.padding(8.dp)
) {
Text("Decrease")
}

Button(
onClick = { viewModel.increment() },
modifier = Modifier.padding(8.dp)
) {
Text("Increase")
}
}
}
}

Real-World Application Example

Let's put everything together to create a simple notes app that works across platforms:

kotlin
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

data class Note(val id: Int, var title: String, var content: String)

class NotesViewModel {
private val _notes = mutableStateListOf<Note>()
val notes: List<Note> get() = _notes

init {
// Add some sample notes
_notes.add(Note(1, "Shopping List", "Milk, Bread, Eggs"))
_notes.add(Note(2, "Meeting Notes", "Discuss project timeline"))
_notes.add(Note(3, "Ideas", "New app features for Q3"))
}

fun addNote(title: String, content: String) {
val nextId = if (_notes.isEmpty()) 1 else _notes.maxOf { it.id } + 1
_notes.add(Note(nextId, title, content))
}

fun deleteNote(id: Int) {
_notes.removeIf { it.id == id }
}
}

@Composable
fun NotesApp() {
val viewModel = remember { NotesViewModel() }
var showDialog by remember { mutableStateOf(false) }
var selectedNote by remember { mutableStateOf<Note?>(null) }

MaterialTheme {
Scaffold(
topBar = {
TopAppBar(title = { Text("Notes App") })
},
floatingActionButton = {
FloatingActionButton(
onClick = {
selectedNote = null
showDialog = true
}
) {
Icon(Icons.Default.Add, contentDescription = "Add Note")
}
}
) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp)
) {
items(viewModel.notes) { note ->
NoteItem(
note = note,
onClick = {
selectedNote = note
showDialog = true
},
onDelete = { viewModel.deleteNote(note.id) }
)
}
}

if (showDialog) {
NoteDialog(
note = selectedNote,
onDismiss = { showDialog = false },
onSave = { title, content ->
if (selectedNote == null) {
viewModel.addNote(title, content)
} else {
selectedNote?.title = title
selectedNote?.content = content
}
showDialog = false
}
)
}
}
}
}

@Composable
fun NoteItem(note: Note, onClick: () -> Unit, onDelete: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clickable(onClick = onClick),
elevation = 4.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = note.title,
style = MaterialTheme.typography.h6
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = note.content,
style = MaterialTheme.typography.body2
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error)
) {
Text("Delete")
}
}
}
}

@Composable
fun NoteDialog(note: Note?, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
var title by remember { mutableStateOf(note?.title ?: "") }
var content by remember { mutableStateOf(note?.content ?: "") }

AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (note == null) "Add Note" else "Edit Note") },
text = {
Column {
TextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = content,
onValueChange = { content = it },
label = { Text("Content") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(onClick = { onSave(title, content) }) {
Text("Save")
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

Use this Notes App in your main App composition:

kotlin
@Composable
fun App() {
NotesApp()
}

Best Practices for Compose Multiplatform

  1. Separate UI from Logic: Keep your business logic separate from UI code to maximize code sharing
  2. Use Expect/Actual for Platform Specifics: When you need platform-specific functionality, use the expect/actual mechanism
  3. Consider Platform UX: While sharing UI is great, sometimes platform-specific design patterns provide better user experience
  4. Keep Composables Focused: Create small, reusable composable functions rather than large monolithic ones
  5. Preview on All Platforms: Regularly test your app on all target platforms during development
  6. Use Theme Abstraction: Create theme components that adapt to platform expectations

Common Challenges and Solutions

Challenge: Handling Different Screen Sizes

kotlin
@Composable
fun ResponsiveLayout(content: @Composable () -> Unit) {
val windowInfo = LocalWindowInfo.current
val screenWidth = windowInfo.width

if (screenWidth > 600.dp) {
// Tablet/desktop layout
Row(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(0.3f)) {
// Sidebar
}
Box(modifier = Modifier.weight(0.7f)) {
// Main content
content()
}
}
} else {
// Phone layout
content()
}
}

Challenge: Platform-Specific Navigation

Create a common interface for navigation:

kotlin
interface Navigator {
fun navigateTo(route: String)
fun goBack()
}

// Then implement it differently for each platform

Summary

Kotlin Compose Multiplatform is a powerful tool for building cross-platform applications with shared UI code. In this guide, we've covered:

  • The basics of Compose Multiplatform
  • Setting up a project structure
  • Creating your first cross-platform UI
  • Managing state and navigation
  • Building a real-world notes application
  • Best practices and common challenges

With Compose Multiplatform, you can significantly reduce development time and maintenance costs by sharing UI code across Android, iOS, desktop, and web platforms while still providing users with a native-feeling experience.

Additional Resources

Exercises

  1. Create a simple to-do list app that works on both Android and Desktop
  2. Extend the notes app with the ability to categorize notes
  3. Implement a platform-specific share functionality (share on Android, copy to clipboard on desktop)
  4. Create a simple weather app that uses platform-specific location APIs
  5. Build a cross-platform image gallery with local image loading

Happy coding with Compose Multiplatform!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)