Jetpack Compose is revolutionizing Android UI development with its declarative approach, and its multiplatform capabilities extend its reach beyond Android. Kotlin Multiplatform (KMP) allows you to write code that targets multiple platforms like Android, iOS, web, desktop, and more, sharing significant parts of your application logic and UI. This post explores real-world examples of Compose Multiplatform projects, showing how to build applications that run seamlessly on different platforms from a single codebase.
Understanding Compose Multiplatform
Compose Multiplatform (CMP) is Kotlin’s UI framework that lets you build cross-platform applications with a single codebase, leveraging the declarative UI paradigm. It is based on Jetpack Compose and adapted to target multiple platforms.
Why Use Compose Multiplatform?
- Code Reusability: Share UI and business logic across multiple platforms, reducing development time and costs.
- Consistency: Ensure a consistent user experience across platforms.
- Maintainability: Easier to maintain and update the application due to the single source of truth.
Setting up a Compose Multiplatform Project
Before diving into real-world examples, it’s essential to understand how to set up a CMP project.
Step 1: Configure the Project
Use the Kotlin Multiplatform wizard in IntelliJ IDEA or Android Studio to create a new project. Select the target platforms (e.g., Android, iOS, Desktop, Web).
Step 2: Project Structure
The project typically includes:
commonMain
: Shared code for all platforms, including UI components and business logic.androidMain
: Android-specific code.iosMain
: iOS-specific code.desktopMain
: Desktop-specific code.jsMain
: Web-specific code.
Step 3: Add Dependencies
Ensure you have the necessary Compose Multiplatform dependencies in your build.gradle.kts
(or build.gradle
) file for each module.
// commonMain
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.uiToolingPreview)
// ... other common dependencies
}
// androidMain
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.core:core-ktx:1.12.0")
implementation(compose.ui)
implementation(compose.uiToolingPreview)
implementation(compose.uiTooling)
implementation(compose.material)
implementation(compose.runtime)
}
// iosMain
dependencies {
implementation(compose.ui)
implementation(compose.foundation)
implementation(compose.material)
// ... other iOS dependencies
}
Real-World Examples of Compose Multiplatform Applications
Let’s explore some practical examples of applications built with Compose Multiplatform.
Example 1: A Simple To-Do List App
A To-Do List application is a great starting point for understanding CMP. The UI components (list, item, input fields) and business logic (adding, deleting, and managing tasks) can be shared across platforms.
commonMain
// commonMain/kotlin/App.kt
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
data class Task(val id: Int, val title: String, var isCompleted: Boolean = false)
@Composable
fun App() {
var tasks by remember { mutableStateOf(mutableListOf()) }
var newTaskTitle by remember { mutableStateOf("") }
var taskIdCounter by remember { mutableStateOf(0) }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("To-Do List", style = MaterialTheme.typography.h5)
// Input field for adding tasks
Row(verticalAlignment = Alignment.CenterVertically) {
TextField(
value = newTaskTitle,
onValueChange = { newTaskTitle = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Add a task") }
)
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
if (newTaskTitle.isNotBlank()) {
val newTask = Task(taskIdCounter++, newTaskTitle)
tasks.add(newTask)
newTaskTitle = "" // Reset input
}
}) {
Text("Add")
}
}
// Task list
Column {
tasks.forEach { task ->
TaskItem(task = task,
onTaskChanged = { updatedTask ->
tasks = tasks.map { if (it.id == task.id) updatedTask else it }.toMutableList()
},
onTaskDeleted = { deletedTask ->
tasks = tasks.filter { it.id != deletedTask.id }.toMutableList()
})
Divider()
}
}
}
}
@Composable
fun TaskItem(task: Task, onTaskChanged: (Task) -> Unit, onTaskDeleted: (Task) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = task.isCompleted,
onCheckedChange = { isChecked ->
onTaskChanged(task.copy(isCompleted = isChecked))
}
)
Text(
text = task.title,
modifier = Modifier.weight(1f)
)
Button(onClick = { onTaskDeleted(task) }) {
Text("Delete")
}
}
}
To run this common code, you’ll have specific entry points for each platform (e.g., an Activity in Android, an App
structure in iOS using SwiftUI, a main function for Desktop, etc.). This ensures a native look-and-feel on each platform, even though the core logic and UI definitions are shared.
//androidMain
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() // Use the common App composable here
}
}
}
//desktopMain
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import App
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "Multiplatform To-Do List") {
App()
}
}
Each platform will run the same basic to-do list logic but adapt the necessary settings or UI enhancements particular to each system. The amount of code shared may vary as each system offers different functions or system calls unique to their implementation.
Example 2: A Simple Weather Application
Building a weather application can demonstrate data fetching and display in CMP. This app fetches weather data from an API and displays it on multiple platforms.
commonMain
import androidx.compose.runtime.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Serializable
data class WeatherData(
val main: Main,
val name: String,
val weather: List
)
@Serializable
data class Main(
val temp: Double,
val humidity: Int
)
@Serializable
data class WeatherDescription(
val description: String,
val icon: String // Will require handling image loading differently
)
@Composable
fun WeatherApp() {
val apiKey = "YOUR_API_KEY" // Replace with your API key
val city = "London"
val client = remember {
HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
}
}
var weatherData by remember { mutableStateOf(null) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(city) {
coroutineScope.launch {
try {
val url = "https://api.openweathermap.org/data/2.5/weather?q=$city&appid=$apiKey&units=metric"
val response: WeatherData = client.get(url).body()
weatherData = response
} catch (e: Exception) {
println("Error fetching weather data: ${e.localizedMessage}")
}
}
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (weatherData != null) {
Text("Weather in ${weatherData!!.name}", style = MaterialTheme.typography.h6)
Spacer(modifier = Modifier.height(8.dp))
Text("Temperature: ${weatherData!!.main.temp}°C")
Text("Humidity: ${weatherData!!.main.humidity}%")
Text("Description: ${weatherData!!.weather.firstOrNull()?.description ?: "N/A"}")
// Display weather icon (implementation differs per platform)
} else {
Text("Loading weather data...")
}
}
DisposableEffect(Unit) {
onDispose {
client.close()
}
}
}
- This setup utilizes the ktor HTTP client with the proper JSON serializatoin packages installed
- This API setup will also work cross-platform but will likely be adjusted slightly to reflect the platform best suited for a task, eg HTTP Networking task.
Example 3: Basic Game Application (Shared Logic)
A game (e.g., Tic-Tac-Toe) can share core game logic, such as game state, move validation, and win conditions, across platforms, while UI and input handling are platform-specific.
commonMain
// commonMain/kotlin/TicTacToe.kt
enum class BoardCell(val display: String) {
EMPTY(" "),
X("X"),
O("O")
}
class GameState {
var board = Array(3) { Array(3) { BoardCell.EMPTY } }
var currentPlayer = BoardCell.X
var winner: BoardCell? = null
var isDraw = false
fun makeMove(row: Int, col: Int): Boolean {
if (board[row][col] != BoardCell.EMPTY || winner != null || isDraw) {
return false
}
board[row][col] = currentPlayer
checkForWin()
checkForDraw()
if (winner == null && !isDraw) {
currentPlayer = if (currentPlayer == BoardCell.X) BoardCell.O else BoardCell.X
}
return true
}
private fun checkForWin() {
// Check rows, columns, and diagonals for a win
for (i in 0..2) {
if (board[i][0] != BoardCell.EMPTY && board[i][0] == board[i][1] && board[i][0] == board[i][2]) {
winner = board[i][0]
return
}
if (board[0][i] != BoardCell.EMPTY && board[0][i] == board[1][i] && board[0][i] == board[2][i]) {
winner = board[0][i]
return
}
}
if (board[0][0] != BoardCell.EMPTY && board[0][0] == board[1][1] && board[0][0] == board[2][2]) {
winner = board[0][0]
return
}
if (board[0][2] != BoardCell.EMPTY && board[0][2] == board[1][1] && board[0][2] == board[2][0]) {
winner = board[0][2]
return
}
}
private fun checkForDraw() {
if (board.all { row -> row.all { cell -> cell != BoardCell.EMPTY } }) {
isDraw = true
}
}
fun resetGame() {
board = Array(3) { Array(3) { BoardCell.EMPTY } }
currentPlayer = BoardCell.X
winner = null
isDraw = false
}
}
- Again you use `commonMain` for this which keeps game management shared to each system without unique overrides
Jetpack Compose, using Multiplatform, allows development for a cross-platform implementation for code resuse on different Operating systems without sacrficing too many benefits
Conclusion
Compose Multiplatform offers a powerful approach to building applications that span multiple platforms from a single codebase. The examples discussed – To-Do List, Weather Application, and basic Game – highlight the benefits of code sharing, consistency, and maintainability. By leveraging Compose Multiplatform, developers can streamline their workflows, reduce development costs, and ensure a consistent user experience across various platforms.