Compose Multiplatform: First Impression and Hands-on Experience

Anatolijs Petjko
10 min readSep 29, 2023

--

In recent years, the Android development has witnessed a dramatic shift. Gone are the days of massive XML layouts, the de-facto standard these days is Jetpack Compose, Android’s modern, fully declarative UI toolkit. With its powerful and intuitive Kotlin-based syntax, Compose simplified UI development and also opened doors to a future of more concise, reactive, and dynamic mobile applications.

Jetpack Compose got wide adoption and recognition from developers. At the same time, the demand for cross-platform solutions was growing as well. Thus, JetBrains, the powerhouse behind Kotlin, together with Google went on a mission to expand the Compose experience to more platforms, and Compose Multiplatform was born.

Image from JetBrains

Kotlin Multiplatform + Compose Multiplatform

For a while already we have had Kotlin Multiplatform, a great tool for sharing business logic between platforms, but it has one big drawback — developers still need to implement UI with native tools like Jetpack Compose and SwiftUI.

Here Compose Multiplatform enters the stage to fill the gap KMP had this whole time, completing the seamless Kotlin experience. By combining Compose and KMP you can achieve your codebase to consist of Kotlin for 80–95% depending on the project’s complexity. For me, as an Android developer, that sounds pretty exciting.

Image from JetBrains

How to start?

First of all, you need to prepare yourself and your working machine for cross-platform development. Shortly, you will need Android Studio with installed KMP plugin, Xcode, and CocoaPods dependency manager

Then you need to create a project, obviously. The good news is that you don’t even need to spend time for a proper KMP + Compose project setup, JetBrains has a convenient template with step-by-step explanations of what’s happening in this template, here you will also find a guide for environment setup.

After these preparations are done, you gotta think about your app’s architecture. Since we are aiming for cross-platform, we can’t use familiar tools like ViewModel, Hilt, etc. as they are wired to the Android lifecycle, we have to think differently. As the framework is still new, we don’t have plenty of libraries yet, but existing ones are enough to pick something you like. And I have good news for you again, the Kotlin community collected the best libraries that support KMP in this repository.

Project structure

If you are not yet familiar with the KMP project’s structure, here is a short explanation, so you would better understand everything that comes next. Since we are developing a cross-platform mobile app, crucial for us would be 3 modules:

  • shared — should be clear from the name, in this module we will keep all shared Compose UI and business logic. Inside you can see additional separation to androidMain, iosMain and commonMain. Basically, most of the app will stay under commonMain module, other two submodules are needed for platform-specific UI features, which I will show later in this article;
  • androidApp — Kotlin module, the entry point for Android application, contains platform-specific classes, like Application and Activity, as well as additional dependencies if required;
  • iosApp — Xcode project with platform-specific configurations and classes. During build time shared module gets bundled into the iOS project as a CocoaPod dependency.

Notes Demo App

GitHub repository

Architecture

Enough of the intro, let’s see what can we create by using some of the most popular libraries. The tech stack I chose for my demo app looks like this:

  • MVI Kotlin — provides everything you need to implement state management with MVI pattern;
  • Decompose — lifecycle-aware componentization and navigation;
  • Essenty — wraps platform lifecycle, so you can work with that as you are used to it on Android;
  • Koin — runtime dependency injection;
  • Realm NoSQL database;
  • Kotlin Coroutines — concurrency and multithreading;
  • Compose — UI.

The app has limited functionality — create, view, edit, and delete notes, so its architecture looks this way:

Root Component — you can call it an entry point to our UI tree, it knows how to create components for our screens, and handles navigation between them. In the code, it represents some class that keeps the navigation stack, creates components from respective factories, and manages navigation in response to received actions. You can check the NotesRoot interface implementation here.

interface NotesRoot {

val childStack: Value<ChildStack<*, Child>>

sealed class Child {
data class Home(val component: HomeComponent) : Child()
data class CreateNote(val component: CreateNoteComponent) : Child()
data class EditNote(val component: EditNoteComponent) : Child()
}
}

@Composable
fun RootContent(
component: NotesRoot,
modifier: Modifier = Modifier
) {
Children(
stack = component.childStack,
animation = stackAnimation(fade() + scale()),
modifier = modifier
) {
when (val child = it.instance) {
is Home -> HomeScreen(child.component)
is CreateNote -> CreateNoteScreen(child.component)
is EditNote -> EditNoteScreen(child.component)
}
}
}

Screens — composables that consume state from components, and interpret interface.
Components — interlayer between UI and Store. Sends intents to store, as well as sends navigation actions to the root component.

class CreateNoteComponentImpl(
componentContext: ComponentContext,
storeFactory: StoreFactory,
private val action: ValueCallback<Action>
) : CreateNoteComponent, KoinComponent, ComponentContext by componentContext {

private val notesDao by inject<NotesDao>()

private val store = instanceKeeper.getStore {
CreateNoteStoreProvider(
storeFactory = storeFactory,
notesDao = notesDao
).provide()
}

override val state: StateFlow<CreateNoteStore.State> = store.stateFlow
override val labels: Flow<CreateNoteStore.Label> = store.labels

override fun onTitleChanged(title: String) {
store.accept(TitleChanged(title))
}

override fun onBodyChanged(body: String) {
store.accept(BodyChanged(body))
}

override fun onCreateNote() {
store.accept(CreateNote)
}

override fun onNoteCreated() {
action(Action.PopScreen)
}

override fun onGoBack() {
action(Action.PopScreen)
}
}

Store — our alternative to Android ViewModel or iOS ViewController. It communicates with your repositories, database, networking services, and everything you may need from your business layer. It manages a screen state in response to received Intents and emits Labels. In this architecture Label is basically a single-shot event that is used to show error/toast or to navigate. Looking at the interface you can already notice similarities with ViewModel implementing the MVI pattern. You can check the implementation here.

interface CreateNoteStore : Store<Intent, State, Label> {

sealed class Intent {
data class TitleChanged(val title: String): Intent()
data class BodyChanged(val body: String): Intent()
data class UpdateNote(val id: String) : Intent()
data class DeleteNote(val id: String) : Intent()
object CreateNote : Intent()
}

sealed class Label {
object NoteCreated : Label()
object NoteDeleted : Label()
}

data class State(
val title: String = "",
val body: String = "",
val canSave: Boolean = false
)
}

User Interface

Safe Area

The first thing that you will notice after launching your app on both platforms is that Android and iOS have their safe areas for content, and Compose Multiplatform does not provide a unified option to handle safe area paddings, so we will do it natively.

Android

In our MainActivity, under androidApp module, we can use all APIs that native Jetpack Compose and AndroidX provide. So simply adding enableEdgeToEdge() together with .systemBarsPadding() modifier will do the trick.

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

setContent {
AppTheme {
... {
RootContent(
component = ...,
modifier = Modifier.systemBarsPadding()
)
}
}
}
}

iOS

On iOS it’s a bit more complex (or I just haven’t found a shorter solution, since I am originally an Android developer). In the iOS App class, where the main body is declared, we can retrieve safe area paddings from GeometryReader, passing them to our shared Composable, where it will be applied as a Compose padding modifier.

// iosApp.swift under iosApp module
var body: some Scene {
WindowGroup {
GeometryReader { geo in
ComposeViewController(
...,
topSafeArea: Float(geo.safeAreaInsets.top),
bottomSafeArea: Float(geo.safeAreaInsets.bottom)
)
.edgesIgnoringSafeArea(.all)
}
}
}

// main.ios.kt under shared/iosMain module, which can be accessed from native iOS project
ComposeUIViewController {
AppTheme {
... {
RootContent(
component = ...,
modifier = Modifier.padding(
top = topSafeArea,
bottom = bottomSafeArea
)
)
}
}
}

Platform-specific UI

To try out how to work with platform-specific UI I decided to add note deletion confirmation as a native alert dialog. Implementation turned out to be pretty straightforward, thanks to expect/actual mechanism from KMP.

First of all, in my EditNoteScreen composable under shared/common module, I added simple logic to trigger that deletion confirmation.

var showDeleteConfirmation by remember {
mutableStateOf(false)
}

if (showDeleteConfirmation) {
ShowDialog(
title = "Delete note?",
message = "You won't be able to access this note anymore",
positiveText = "Delete",
negativeText = "Cancel",
onConfirmed = {
component.onDeleteNote()
showDeleteConfirmation = false
},
onDismissed = {
showDeleteConfirmation = false
}
)
}

And in the same shared/common module I declared expect function that does not require any implementations in this module, you can treat it as a simple interface.

@Composable
expect fun ShowDialog(
title: String,
message: String,
positiveText: String,
negativeText: String,
onConfirmed: () -> Unit,
onDismissed: () -> Unit
)

Now we have to provide respective native implementations in both shared/android and shared/ios. To do so, we should create a function with the same name, but now with actual modifier. For Android we can use the default AlertDialog from Jetpack Compose, nothing special here. But on iOS side Compose Multiplatform can surprise us with the possibility of using UIKit directly from Kotlin code, and showing native UIAlertController without touching any Swift code.

// shared/android implementation
@Composable
actual fun ShowDialog(
title: String,
message: String,
positiveText: String,
negativeText: String,
onConfirmed: () -> Unit,
onDismissed: () -> Unit
) {
AlertDialog(
title = { Text(title, style = MaterialTheme.typography.titleMedium) },
text = { Text(message, style = MaterialTheme.typography.bodyMedium) },
shape = MaterialTheme.shapes.medium,
containerColor = MaterialTheme.colorScheme.background,
onDismissRequest = onDismissed,
dismissButton = {
TextButton(onClick = onDismissed) {
Text(
text = negativeText,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
},
confirmButton = {
TextButton(onClick = onConfirmed) {
Text(positiveText, style = MaterialTheme.typography.titleMedium, color = Color.Red)
}
}
)
}

// shared/ios implementation
@Composable
actual fun ShowDialog(
title: String,
message: String,
positiveText: String,
negativeText: String,
onConfirmed: () -> Unit,
onDismissed: () -> Unit
) {
val alert = UIAlertController.alertControllerWithTitle(
title = title,
message = message,
preferredStyle = UIAlertControllerStyleActionSheet
)
alert.addAction(
actionWithTitle(
title = negativeText,
style = UIAlertActionStyleDefault,
handler = { onDismissed() }
)
)
alert.addAction(
actionWithTitle(
title = positiveText,
style = UIAlertActionStyleDestructive,
handler = { onConfirmed() }
)
)
LocalUIViewController.current.showViewController(alert, null)
}

The result of our magic:

How does this architecture work?

Let’s explore how everything works on note creation flow. While user is typing, Intents are sent to the store telling it that the Title or Body was changed, and store should update its state. After a new state is emitted, the UI gets updated. When user presses on the “Save note” button, CreateNote intent is sent telling store that it should take current Title and Body from the state, create an entity, and save it to the database. After fresh note is saved, store emits a Label, so we can either notify user that note was created or simply close the screen by sending the PopScreen action to the root component.

Thoughts and Conclusion

Putting the Android developer’s excitement aside, Compose Multiplatform is a promising technology that needs time. iOS support is just in alpha, which means that stability and performance on this platform are not guaranteed. In addition, in the early stages of development, many things in the framework’s API can change with new updates, which could make it difficult to support the application. For the same reason, documentation and open resources on the topic are also scarce, and the information in them can quickly become outdated.

The framework has support from the community. Many of the existing limitations and shortcomings receive solutions from third parties (e.g. libraries for lifecycle, resources, and translation management, which currently have no built-in solution). But again, due to the active development of the framework, not all open-source libraries are updated in time to support new versions of Compose, which may not allow you to upgrade yourself to experience all the improvements.

Many small issues with how composables perform on iOS. JetBrains are working on that, and the recent Compose 1.5.0 release brings us improvements for scrolling physics, text fields, and supporting native 120Hz refresh rate, but there is still a lot of work ahead.

At this point, you should approach this technology with curiosity. It is definitely too early to use it in big projects, or in production in general, but it might be worth trying it for your next small pet project. It seems JetBrains and Google have big plans for KMP and Compose Multiplatform, so my advice is to keep an eye on this and see if, in the end, we will receive a worthy competitor in a multi-platform game.

--

--