Four platforms – one code. What is Compose Multiplatform?

Developers have long dreamed of being able to write cross-platform code – one that would run and work the same way on any operating system of any architecture. Today, the principle of “Write once, run anywhere”, once thundered in connection with the advent of the Java language, is difficult to surprise anyone. And yet there is a niche in which there is not much cross-platform technology: this is UI development.
It would not be an exaggeration to say that today there are only two UI frameworks that allow you to run the same UI on different platforms and are widely represented on the market: React Native and Flutter. It would seem, what more could you want? Two technologies at once provide the ability to fumble UI features between platforms and do an excellent job of it. But this article is not about them, but about their younger brother, a convenient and powerful tool for mobile and desktop development – Compose Multiplatform.
Its basis was Jetpack Compose, the first declarative UI framework for Android. However, thanks to Jetbrains and Kotlin Multiplatform, at the moment it can be run almost anywhere and on anything: Android, iOS, Windows, Linux, MacOS and in the browser (some DIY enthusiasts also reuse the code on WearOS).
There will be no traditional bickering about which framework is better, but let me share one personal impression: after trying React and then dipping into Jetpack Compose, I was left with a bunch of questions about the first and delighted with the simplicity and intuitiveness of the second. When you develop interfaces using Compose, one thought is spinning in your head: not every senior can understand the reactivity of React, but any student can understand the principle of Compose.
It is clear that these two frameworks are distinguished by their “target audience”: web development frontends come to React Native, and Android developers who do not want to leave their comfort zone and, without learning new languages and technologies, “reach out” to other platforms. But here’s a look from the outside: at the time of my acquaintance with both technologies, I was equally far from both declarative web development and android. Maybe this article will be an incentive to get acquainted with this new technology for you and get the same buzz from it that I get every day. As for Flutter, I will refrain from commenting, because I have not tried it myself yet.
Today we will try to understand whether it is easy to transfer code written only for android on pure Jetpack Compose to other platforms. (Spoiler: not easy, but very easy.) We will write a simple but working messenger prototype that can be run as a desktop application, a mobile application on Android and iOS, and also in a browser. The application code can be found Here.
To start a new project on Compose Multiplatform, use template on Github.
The initial setup provided by the template already contains a very simple Hello-world interface. The project is already divided into modules: androidApp, iosApp, desktopApp and shared (we will add more jsApp to them, but more on that later). Modules, as you might guess, are the target platforms of the application. Inside the shared module, each of the platforms has its own sourceset (sourceset, a set of source code files). All sourcesets have a common part – commonMain, this is a code that is 100% reused.
Why such a layered structure? It is possible to build a project on the same sources, but then the advantages of the multi-module structure of the Gradle project will be lost, which I will not expand on here, since they are enough obvious.
Let’s look at the modules of each of the targets, right in alphabetical order. In the module androidApp
contains an indispensable for this target AndroidManifest.xml
And MainActivity.kt
. By and large, this Activity
– just an entry point for the whole interface on Android:
package com.myapplication
import MainView
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Application()
}
}
}
Application
– normal Composable
a function that is contained in a module shared
in sourceset commonMain
and is called in the same way in all targets.
IN desktopApp
exactly the same function is called, but wrapped in Window
. This simple method creates a Swing window and puts our interface in it.
fun main() = application {
Window(
title = "ComposeConnect",
onCloseRequest = ::exitApplication
) {
Application()
}
}
By the way, in general, the entire interface in the Compose Multiplatform desktop target under the hood has bindings to Swing or AWT, so you can use many methods from these libraries, for example, a file manager or saving graphic resources to disk using awt.Image
.
Let’s move on to one of the most interesting parts – the iOS target. If so far all entry points to the application have been written in Kotlin / JVM, then in the module iosApp
there is not a single line on Kotlin, but there is a Swift project that refers to the module shared
or rather, its sourceset iosMain
:
import UIKit
import SwiftUI
import shared
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
Main_iosKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea(.keyboard) // Compose has own keyboard handler
}
}
If above Kotlin Multiplatform showed itself in conjunction with Java, then its other cool feature is obvious here – Swift-interop. Function MainViewController
from a file main.ios.kt
we call directly in Swift:
fun MainViewController() = ComposeUIViewController { Application() }
Finally, a separate discussion requires the transfer of our application to the browser. It is not supported in the official template from Jetbrains, with the proviso that the Compose Multiplatform js target is in the experimental development stage. But we will still add it to make sure that our application works in all environments.
To do this, in Intellij Idea, create a new module through File -> Project structure -> New module and select Compose Multiplatform. Further Single platform and Web. The newly created module needs to cut off all the “excess”, leaving only build.gadle.kts
script. To include a module in the main project, add the appropriate include(project(":jsApp"))
V settings.gradle.kts
root project.
IN build.gradle.kts
module shared
you need to add the appropriate compilation plugin and sourceset:
kotlin {
js(IR) {
browser()
binaries.executable()
}
...
sourceSets {
...
val jsMain by getting {}
}
}
After updating the gradle, let’s take a look at the module itself jsApp
. How important it was for the Android target AndroidManifest.xml
so it will be important for the web index.html
, which is located in our module’s resources folder. It’s worth mentioning right away that in the browser, the application will be drawn not using the DOM tree, but on a single <canvas>
(yes, just like in Flutter). Therefore our index.html
will look like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ComposeConnect</title>
<script src="https://habr.com/ru/companies/timeweb/articles/734818/skiko.js"></script>
</head>
<body>
<div id="root">
<canvas id="ComposeTarget"></canvas>
</div>
<script src="https://habr.com/ru/companies/timeweb/articles/734818/jsApp.js"></script>
</body>
</html>
The most important parts are
1) skiko.js
(skiko, aka Skia for Kotlin – a graphic library that runs the entire Compose Multiplatform, implements low-level bindings to Skia itself for Kotlin on different platforms),
2) <canvas id="ComposeTarget"></canvas>
3) jsApp.js
(our application transpiled to JavaScript).
Now in Main.kt
Let’s write only 5 lines:
fun main() {
onWasmReady {
Window { Application() }
}
}
This is enough to port the interface to the browser.
The only thing left is to write the application itself! I opted for the messenger and took as a basis JetChat – an exemplary application from Google on pure Jetpack Compose. The most important components of an application are Scaffold
which functions as a navigation bar, ConversationContent
in which the chat room itself is implemented, and ProfileScreen
which is the profile view screen of the selected user.
First, let’s create a class MainViewModel
which plays the same role as ViewModel
in android. Unfortunately, Compose Multiplatform does not yet provide its own class ViewModel
out of the box, for its cross-platform implementation, the Decompose library is usually used. I plan to do this in the future, but for now let’s try to get by with a simple class with the necessary StateFlow
. IN Application
just initialize the class:
@Composable
fun Application() {
val viewModel = remember { MainViewModel() }
ThemeWrapper(viewModel)
}
The class itself MainViewModel
let’s write down a number of objects monitored by the interface StateFlow
:
@Stable
class MainViewModel {
private val _conversationUiState: MutableStateFlow<ConversationUiState> = MutableStateFlow(exampleUiState.getValue("composers"))
val conversationUiState: StateFlow<ConversationUiState> = _conversationUiState
private val _selectedUserProfile: MutableStateFlow<ProfileScreenState?> = MutableStateFlow(null)
val selectedUserProfile: StateFlow<ProfileScreenState?> = _selectedUserProfile
private val _themeMode: MutableStateFlow<ThemeMode> = MutableStateFlow(ThemeMode.LIGHT)
val themeMode: StateFlow<ThemeMode> = _themeMode
private val _drawerShouldBeOpened: MutableStateFlow<Boolean> = MutableStateFlow(false)
val drawerShouldBeOpened: StateFlow<Boolean> = _drawerShouldBeOpened
fun setCurrentConversation(title: String) {
_conversationUiState.value = exampleUiState.getValue(title)
}
fun setCurrentAccount(userId: String) {
_selectedUserProfile.value = exampleAccountsState.getValue(userId)
}
fun resetOpenDrawerAction() {
_drawerShouldBeOpened.value = false
}
fun switchTheme(theme: ThemeMode) {
_themeMode.value = theme
}
fun sendMessage(message: Message) {
_conversationUiState.value.addMessage(message)
}
}
In this class ConversationUiState
– a small utility class that conveniently packs all the data we need to display a separate chat room:
class ConversationUiState(
val channelName: String,
val channelMembers: Int,
initialMessages: List<Message>,
) {
private val _messages: MutableList<Message> = initialMessages.toMinitialMessages.toMutableList()
val messages: List<Message> = _messages
fun addMessage(msg: Message) {
_messages.add(0, msg) // Add to the beginning of the list
}
}
To place the application’s top bar and new message input field on top of the message list itself, use Box
:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationContent(
viewModel: MainViewModel,
scrollState: LazyListState,
scope: CoroutineScope,
onNavIconPressed: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val messagesState by viewModel.conversationUiState.collectAsState()
Box(modifier = Modifier.fillMaxSize()) {
Messages(messagesState, scrollState)
Column(
Modifier
.align(Alignment.BottomCenter)
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
UserInput(
onMessageSent = { content ->
val timeNow = getTimeNow()
val message = Message("me", content, timeNow)
viewModel.sendMessage(message)
},
resetScroll = {
scope.launch {
scrollState.scrollToItem(0)
}
},
// Use navigationBarsWithImePadding(), to move the input panel above both the
// navigation bar, and on-screen keyboard (IME)
modifier = Modifier.userInputModifier(),
)
}
ChannelNameBar(
channelName = messagesState.channelName,
channelMembers = messagesState.channelMembers,
onNavIconPressed = onNavIconPressed,
scrollBehavior = scrollBehavior,
// Use statusBarsPadding() to move the app bar content below the status bar
modifier = Modifier.statusBarsPaddingMpp(),
)
}
}
In order to preserve the convenient Android styles that are applied using the Accompanist library, we will use the convenient expect / actual modifier, which allows you to customize functions, classes and variables depending on the platform. To do this, create a platform package in commonMain
and write the function userInputModifier
. In reality, it will only affect the display on Android.
// shared/src/commonMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier
// shared/src/androidMain/kotlin/platform/modifier.kt
actual fun Modifier.userInputModifier(): Modifier = this.navigationBarsWithImePadding()
// shared/src/desktopMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this
// shared/src/iosMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this
// shared/src/jsMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this
Other features that depend on the logic of the platform itself, and not on specific dependencies, are the cursor. It is assumed that on Android and iOS it is not needed, but it is hard to imagine without it on the web and desktop versions.
And again – in commonMain
announce expect fun
, and to other implementation sources for specific targets. It’s very simple, for example, for desktopMain
so let’s write:
actual fun Modifier.pointerCursor() = pointerHoverIcon(PointerIcon.Hand, true)
For convenient enum
class PointerIcon
the desktop actually hides an AWT event that changes the appearance of the cursor. In a web environment where the interface is not “native”, this method will not work, and you will have to play with … CSS styles. Is it a crutch? Undoubtedly. Let’s hope that over time the Compose Multiplatform team will roll out a beautiful solution for the web, but for now we write as it is:
actual fun Modifier.pointerCursor() = composed {
val hovered = remember { mutableStateOf(false) }
if (hovered.value) {
document.body?.style?.cursor = "pointer"
} else {
document.body?.style?.cursor = "default"
}
this.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val pass = PointerEventPass.Main
val event = awaitPointerEvent(pass)
val isOutsideRelease = event.type == PointerEventType.Release &&
event.changes[0].isOutOfBounds(size, Size.Zero)
hovered.value = event.type != PointerEventType.Exit && !isOutsideRelease
}
}
}
}
Wonderful! We have a completely working messenger prototype that works on Android, iOS, in a browser and a standalone application!
What else interesting can be done with our messenger? As an option, add a theme switch in the interface. In the initial implementation of JetChat, the theme depends on the main theme of the system. That’s cool, but how do you make it switchable at the user’s request?
To do this, we indicate in build.gradle.kts
in sourceset commonMain
dependencies implementation(compose.material3)
And implementation(compose.materialIconsExtended)
create variables AppLightColorScheme
And AppDarkColorScheme
type ColorScheme
with colors for light and dark theme respectively and then pass one of the variables to the class MaterialTheme
:
@Composable
@Suppress("FunctionName")
fun ThemeWrapper(
viewModel: MainViewModel
) {
val theme by viewModel.themeMode.collectAsState()
ApplicationTheme(theme) {
Column {
Conversation(
viewModel = viewModel
)
}
}
}
@Composable
fun ApplicationTheme(
theme: ThemeMode = isSystemInDarkTheme().toTheme(),
content: @Composable () -> Unit,
) {
val myColorScheme = when (theme) {
ThemeMode.DARK -> AppDarkColorScheme
ThemeMode.LIGHT -> AppLightColorScheme
}
MaterialTheme(
colorScheme = myColorScheme,
typography = JetchatTypography
) {
val rippleIndication = rememberRipple()
CompositionLocalProvider(
LocalIndication provides rippleIndication,
content = content
)
}
}
All that remains for us is to write in MainViewModel
new StateFlow
and a method to change it:
private val _themeMode: MutableStateFlow<ThemeMode> = MutableStateFlow(ThemeMode.LIGHT)
val themeMode: StateFlow<ThemeMode> = _themeMode
fun switchTheme(theme: ThemeMode) {
_themeMode.value = theme
}
Finishing touch – add to Drawer
our application function Switch
available in the compose out of the box, and change the theme depending on the value of this small but proud widget:
AppScaffold(
scaffoldState = scaffoldState,
viewModel = viewModel,
onChatClicked = { title ->
viewModel.setCurrentConversation(title)
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
onProfileClicked = { userId ->
viewModel.setCurrentAccount(userId)
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
onThemeChange = { value ->
viewModel.switchTheme(value.toTheme())
}
) {...}
@Composable
@Suppress("FunctionName")
fun ThemeSwitch(viewModel: MainViewModel, onThemeChange: (Boolean) -> Unit) {
Box(
Modifier
.defaultMinSize(300.dp, 48.dp)
.fillMaxSize()
) {
Row(
modifier = Modifier
.height(56.dp)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(CircleShape)
) {
val checkedState by viewModel.themeMode.collectAsState()
val iconColor = MaterialTheme.colorScheme.onSecondary
val commonModifier = Modifier.align(Alignment.CenterVertically)
Icon(
imageVector = Icons.Outlined.LightMode,
contentDescription = "Light theme",
modifier = commonModifier,
tint = iconColor
)
Switch(
checked = checkedState.toBoolean(),
onCheckedChange = {
onThemeChange(it)
},
modifier = commonModifier
)
Icon(
imageVector = Icons.Outlined.DarkMode,
contentDescription = "Dark theme",
modifier = commonModifier,
tint = iconColor
)
}
}
}
The result is the ability to switch from light to dark and back on all platforms! 🙌
Perhaps I’ll stop here for now so as not to go far beyond the 10-minute read. In fact, I have already managed to connect a very mock application to a Websocket server using Ktor Multiplatform, but I hope to cover this and other new features of it in future articles.