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:
- Kotlin Multiplatform: The foundation that allows sharing code across platforms
- Jetpack Compose: Google's declarative UI toolkit originally built for Android
- 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:
- Install the Kotlin Multiplatform Mobile (KMM) plugin in Android Studio
- Go to File → New → New Project
- Select "Compose Multiplatform Application" template
- Configure your project details (name, package name, etc.)
- Select the platforms you want to target (Android, iOS, Desktop, Web)
- 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
:
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)
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)
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:
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:
@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
):
// In shared/commonMain/kotlin/.../Platform.kt
expect class PlatformInfo {
fun getPlatformName(): String
}
2. Implement for Each Platform
For Android (androidMain
):
// In shared/androidMain/kotlin/.../Platform.kt
actual class PlatformInfo {
actual fun getPlatformName(): String = "Android"
}
For iOS (iosMain
):
// In shared/iosMain/kotlin/.../Platform.kt
actual class PlatformInfo {
actual fun getPlatformName(): String = "iOS"
}
For Desktop (desktopMain
):
// In shared/desktopMain/kotlin/.../Platform.kt
actual class PlatformInfo {
actual fun getPlatformName(): String = "Desktop"
}
3. Use in Your App
@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:
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:
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:
@Composable
fun App() {
NotesApp()
}
Best Practices for Compose Multiplatform
- Separate UI from Logic: Keep your business logic separate from UI code to maximize code sharing
- Use Expect/Actual for Platform Specifics: When you need platform-specific functionality, use the expect/actual mechanism
- Consider Platform UX: While sharing UI is great, sometimes platform-specific design patterns provide better user experience
- Keep Composables Focused: Create small, reusable composable functions rather than large monolithic ones
- Preview on All Platforms: Regularly test your app on all target platforms during development
- Use Theme Abstraction: Create theme components that adapt to platform expectations
Common Challenges and Solutions
Challenge: Handling Different Screen Sizes
@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:
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
- Official Compose Multiplatform Documentation
- Kotlin Multiplatform Mobile Samples
- Compose Multiplatform Slack Channel
- JetBrains Compose Multiplatform Tutorial
Exercises
- Create a simple to-do list app that works on both Android and Desktop
- Extend the notes app with the ability to categorize notes
- Implement a platform-specific share functionality (share on Android, copy to clipboard on desktop)
- Create a simple weather app that uses platform-specific location APIs
- 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! :)