5.
Developing UI: Compose Multiplatform
Written by Kevin D Moore
If you come from a mobile background, it’s exciting to know that you can build desktop apps with the knowledge you gained from learning Jetpack Compose (JC). JetBrains, the maker of the technology behind Android Studio and IntelliJ, has worked with Google to create Compose Multiplatform (CM). This uses some of the same code from Jetpack Compose and extends it to be used for multiple platforms. This chapter will focus on building UI for the desktop using CM, but it will work on the web (experimental) as well. Currently, CM is in alpha for iOS which is pretty exciting too!
Getting to Know Compose Multiplatform
CM uses the Java Virtual Machine (JVM) under the hood so that you can still use the older Swing technology for creating UI if you want. It uses the Skia graphics library that allows hardware acceleration (like JC). Finally and most importantly, apps built with CM can run on macOS, Windows and Linux.
Differences in Desktop
Before moving ahead, we need to understand the differences between mobile and desktop app and their requirements. This will help us understand and finally build the desktop app. Unlike mobile, the desktop has features like menus, multiple windows and system notifications. Menus can also have shortcuts and windows will have different sizes and positions on the screen. Lastly, the desktop doesn’t usually use app bars like mobile apps. You’ll usually use menus to handle actions instead.
Creating a Desktop App
To create a desktop app, you’re going to do several things:
- Update the existing Gradle files.
- Create a desktop module.
- Create a shared UI module.
- Move most of the Android code to the shared UI module.
- Create some wrappers so that Android and desktop can have unique functionality.
As usual, the desktop module will contain the desktop platform-specific code. The shared UI module will contain the UI code that you will use cross-platform. In this chapter, those platforms are - Android, Mac, and Windows.
Updating Gradle Files
To start, you’ll need to update a few of your current Gradle files. Open the starter project in Android Studio and open the main settings.gradle.kts. Under mavenCentral and at the end of pluginManagement/repositories add:
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
This adds the repository for the Compose Multiplatform library. Similarly, under dependencyResolutionManagement/repositories add:
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
Press the Sync Now button at the top of this file.
Now, go to shared ▸ build.gradle.kts file. You already have the target setup for the desktop:
jvm("desktop")
The code above creates a new JVM target with the name desktop. Next, you will create desktop module for writing your desktop platform specific code.
Desktop Module
There isn’t an easy way to create a desktop module, except by hand. At the time of writing, JetBrains is working to improve this but it isn’t that hard to do it manually. Right-click the top-level folder in the project window and choose New ▸ Directory:
Name the directory desktop. Next, right-click on the desktop folder and choose New ▸ File. Name the file build.gradle.kts. This build file is similar to the shared module’s build file. Now, add the following:
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
// 1
plugins {
kotlin("multiplatform")
alias(libs.plugins.composePlugin)
}
// 2
kotlin {
// TODO: Add Kotlin
}
// 3
// TODO: Add Compose Desktop
Here’s an explanation of the above code:
- Adds the Multiplatform and Desktop Compose plugins.
- It contains a
TODO
that you will soon complete. It will set the jvm version that will be used, specify the source Kotlin source files for the shared codes that will be used, and any dependencies you will need. - This also contains a
TODO
. It will setup the Kotlin desktop settings that you will see in moment.
Starting with first todo. Replace // TODO: Add Kotlin
with the following code:
// 1
jvm {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
// 2
sourceSets {
val jvmMain by getting {
// 3
kotlin.srcDirs("src/jvmMain/kotlin")
dependencies {
// 4
implementation(compose.desktop.currentOs)
// 5
api(compose.runtime)
api(compose.foundation)
api(compose.material)
api(compose.ui)
api(compose.materialIconsExtended)
// 6
implementation(project(":shared"))
// implementation(project(":shared-ui"))
}
}
}
Here’s what the above code does:
- Set up a JVM target that uses Java 17 (11 or above is required).
- Set up a group of sources and resources for the JVM.
- Set the source directory path where the Kotlin files for UI will be located.
- Use the pre-defined variable to bring in the current OS library for Compose.
- Bring in the Compose libraries, using the variables defined in the Compose plugin.
- Import your shared libraries. Leave shared-ui commented out until you create it.
There’s a lot here, but the desktop Gradle setup is a bit more complex. This sets up the libraries and source file locations needed for the desktop module.
Moving ahead, replace // TODO: Add Compose Desktop
with the following:
// 1
compose.desktop {
// 2
application() {
// 3
mainClass = "MainKt"
// 4
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "FindTime"
macOS {
bundleID = "com.kodeco.findtime"
}
}
}
}
Here’s what you have done:
- Configuration for Compose desktop.
- Define a desktop application with the details below.
- Set the main class. You’ll create a Main.kt file in a bit.
- Set up packaging information for when you’re ready to ship. Here, you have provided three types of information - target formats, package name, and in the case of macOS bundle ID.
Click Sync Now from the top right portion of the window. Open settings.gradle.kts from the root directory and add the new project at the end of the file:
include(":desktop")
Do another sync.
Next, right-click on the desktop folder and choose New ▸ Directory. Add src/jvmMain/kotlin as the new directory name. This will create three folders: src, jvmMain, and kotlin each inside the previous one. Next, right-click on the kotlin folder and choose New ▸ Kotlin Class/File. Type Main and press return. This will create Main.kt file inside src/jvmMain/kotlin.
Now, replace the contents of the file with the following code:
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
// 1
fun main() {
// 2
application {
// 3
val windowState = rememberWindowState()
// 4
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "TimeZone"
) {
// 5
Surface(modifier = Modifier.fillMaxSize()) {
// TODO: Add Theme and MainView
}
}
}
}
Here’s what the above code does:
- Entry point to the application. Just like in Kotlin or Java programs, the starting function is
main
. - Create a new application.
- Remember the current default window state. Change this if you want the window positioned in a different position or size.
- Create a new window with the window state. If the user closes the window, exit the application.
- Set up a
Surface
that takes the full screen.Surface
is a composable that implements material surface.
Other than the commented TODO
, this is the extent of the desktop code. The next task is to create a shared-ui module where you will move the Compose files.
Shared UI
You created the Android Compose files earlier. And you put a lot of work into those files too. You could duplicate those files for the desktop, but why not share them? That’s the idea behind the shared-ui module. You’ll move the Android files over and make a few modifications to allow them to be used for both Android and the desktop.
From the project window, right-click on the top-level folder and choose New ▸ Directory. Name the directory shared-ui. Next, right-click on the shared-ui folder and choose New ▸ File. Name the file build.gradle.kts.
Add the following:
plugins {
kotlin("multiplatform")
id("com.android.library")
alias(libs.plugins.composePlugin)
}
kotlin {
// TODO: Add Desktop Info
}
android {
// TODO: Add Android Info
}
The above code is similar to what you did for the build Gradle file for desktop. The only change is that instead of desktop configuration you are providing minimum android configuaration. You will see it a shortly. For Kotlin, replace // TODO: Add Desktop Info
with:
// 1
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
}
// 2
jvm("desktop") {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
sourceSets {
val commonMain by getting {
// 3
dependencies {
implementation(project(":shared"))
api(compose.foundation)
api(compose.runtime)
api(compose.foundation)
api(compose.material)
api(compose.material3)
api(compose.materialIconsExtended)
api(compose.ui)
api(compose.uiTooling)
}
}
// 4
val androidMain by getting {
dependencies {
implementation(project(":shared"))
}
}
// 5
val desktopMain by getting {
dependencies {
implementation(project(":shared"))
}
}
}
Here’s what the following code does:
- Set an Android target.
- Set a desktop target.
- Define the common main sources. This includes the shared library and Compose for Desktop.
- Set Android’s dependencies.
- Set desktop’s dependencies.
Note that this defines the dependencies for each target. The shared project is used by all targets but the common target gets the Compose dependencies.
Now replace // TODO: Add Android Info
with:
namespace = "com.kodeco.findtime.android"
compileSdk = 34
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
This sets the namespace, compile Android version, the minimum supported version of Android and the Java version to compile with.
One of the nice features of CM is that it can be used with both Android and desktop. For the shared-ui folder, you’ll need three different source directories. One for Android, one for a common source and one for desktop. Right-click on shared-ui and choose New ▸ Directory.
Type src/androidMain/kotlin/com/kodeco/compose/ui and press return to create a new director with the following path.
This will create several folders. Next, do the same for commonMain. Select the src directory you just created and create a new directory named commonMain/kotlin/com/kodeco/compose.
Last but not the least. Do the same for the desktop. Create a new directory using: desktopMain/kotlin/com/kodeco/compose/ui.
This will create three main directories. The first for Android, the second for all common code and the third for the desktop.
Open settings.gradle.kts from the root directory and add the new project:
include(":shared-ui")
Click Sync Now to sync all the changes.
Move UI Files
Now comes the fun part. Instead of recreating all of the Compose UI for the desktop, you’ll steal it from Android. From androidApp/src/main/java/com/kodeco/findtime/android/, select the ui folder and drag it to shared-ui/src/commonMain/kotlin/com/kodeco/compose folder. You’ll get a conflicts dialog but go ahead and press continue. You’ll fix these problems next. Then move the MyApplicationTheme.kt file to shared-ui/src/commonMain/kotlin/com/kodeco/compose/ui as well (press continue on the conflicts dialog).
Update Android app
While moving all the UI code was great for the desktop, it broke the Android app. But, you can fix that. First, you need to update the build.gradle.kts file in the androidMain module. Add the shared-ui library after the shared library:
implementation(project(":shared-ui"))
Run a Gradle sync and build the Android app. You’ll see a lot of errors that you’ll fix next.
AddTimeZoneDialog
Open AddTimeZoneDialog.kt from the commonMain/kotlin/com/kodeco/compose/ui folder inside the shared-ui module. You’ll see several errors for the following imports:
import androidx.compose.ui.res.stringResource
import com.kodeco.findtime.android.R
These two imports don’t exist for the shared-ui module. Remove them. After the imports, add:
@Composable
expect fun AddTimeDialogWrapper(onDismiss: onDismissType, content: @Composable () -> Unit)
This will show an error since it hasn’t been implemented yet.
This is a Composable function that uses KMP’s expect
keyword. This means that each target this module uses needs to implement this function. Now, change the signature for the AddTimeZoneDialog
function and the code up to the first Surface
with the following code:
fun AddTimeZoneDialog(
onAdd: OnAddType,
onDismiss: onDismissType
) {
val timezoneHelper: TimeZoneHelper = TimeZoneHelperImpl()
AddTimeDialogWrapper(onDismiss) {
Make sure to add a closing }
at the end of the function. This just uses the AddTimeDialogWrapper
function to wrap the existing code. The AddTimeDialogWrapper
function will handle platform-specific code. Android will handle the dialog one way, and the desktop another way.
You can now remove the unused Dialog import:
import androidx.compose.ui.window.Dialog
One issue in using Compose for Desktop is resource handling. That’s beyond the scope of this chapter. For now, you will just change the string resources to hard-coded strings. Make the following changes:
stringResource(id = R.string.cancel)
To:
"Cancel"
Similarly change:
stringResource(id = R.string.add)
To:
"Add"
From the shared-ui/src/androidMain/kotlin/com/kodeco/compose/ui folder, right-click and create a new Kotlin file named AddTimeDialogWrapper.kt. This will be the Android version that implements the expect
defined in commonMain. Add:
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Dialog
@Composable
actual fun AddTimeDialogWrapper(onDismiss: onDismissType, content: @Composable () -> Unit) {
Dialog(
onDismissRequest = onDismiss) {
content()
}
}
This creates a function that takes a dismiss callback and the content for the dialog. The reason you need this is that Dialog
is specific to JC and not CM. Make sure that when the file is added, it’s part of the com.kodeco.compose.ui
package, if it isn’t already.
Going ahead. Right-click on desktopMain/kotlin/com/kodeco/compose/ui and create the same class AddTimeDialogWrapper.kt.
Now, add the following code:
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState
@Composable
actual fun AddTimeDialogWrapper(onDismiss: onDismissType, content: @Composable () -> Unit) {
DialogWindow(onCloseRequest = { onDismiss() },
state = rememberDialogState(
position = WindowPosition(Alignment.Center),
),
title = "Add Timezones",
content = {
content()
})
}
This class just uses the desktop DialogWindow
instead of a Dialog
. Here this DialogWindow
takes a dismiss callback, a state, title and content. Luckily this is not that much code :]. The bulk of the Compose code is in AddTimeZoneDialog.
MeetingDialog
Much like AddTimeZoneDialog
, you need to change MeetingDialog
. Open MeetingDialog.kt and remove the imports that show up in red and the dialog import. Add another wrapper:
@Composable
expect fun MeetingDialogWrapper(onDismiss: onDismissType, content: @Composable () -> Unit)
This is just like the other dialog wrapper. Now change the MeetingDialog
method up to Surface
with the following:
fun MeetingDialog(
hours: List<Int>,
onDismiss: onDismissType
) {
MeetingDialogWrapper(onDismiss) {
This adds a wrapper around the dialog. Make sure to add a closing }
like before.
Then change:
stringResource(id = R.string.done)
To:
"Done"
From the src/androidMain/kotlin/com/kodeco/compose/ui folder, right-click and create a new Kotlin file called MeetingDialogWrapper.kt. This will be the Android version that implements the expect
defined in commonMain.
Add the following code:
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Dialog
@Composable
actual fun MeetingDialogWrapper(onDismiss: onDismissType, content: @Composable () -> Unit) {
Dialog(
onDismissRequest = onDismiss) {
content()
}
}
This creates a function that takes a dismiss callback and the content for the dialog. Right-click on desktopMain/kotlin/com/kodeco/compose/ui and create the same MeetingDialogWrapper.kt class. Add the following code:
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.rememberDialogState
@Composable
actual fun MeetingDialogWrapper(onDismiss: onDismissType, content: @Composable () -> Unit) {
DialogWindow(
onCloseRequest = { onDismiss() },
title = "Meetings",
state = rememberDialogState(),
content = {
content()
})
}
This adds a close handler, a title of “Meetings”, the dialog state and the content. Next, open AnimatedSwipeDismiss.kt. You’ll see a bunch of errors. Click on the first error and hit option-return:
Choose the first entry to add the OptIn annotation. This will add @OptIn(ExperimentalMaterial3Api::class)
above the class. This will allow you to use experimental Material3 API classes and all the errors will disappear.
If you try to run the Android app now, you will see a few more errors. The first one is in LocalTimeCard. Just delete the two bottom imports.
Next, in MainView, delete the last import. Finally in TimeZoneScreen, hit option-return to Opt in to use the Material3 experimental API by adding the following OptIn annotation - @OptIn(ExperimentalMaterial3Api::class)
Build and run the Android app. Phew! the app should finally start functioning as usual again. :]
Now that everything has been moved to the a shared ui module, you can try to run the app on the desktop. To run your new desktop app, you’ll need to create a new configuration for running desktop app. From the configuration dropdown, choose Edit Configurations:
Next, click the plus symbol and choose Gradle.
Then, do the following:
- Set the Name to Desktop.
- For the Run text field, enter the desktop:run command.
This will run the desktop:run Gradle task when Desktop configuration is selected which will start the desktop app.
Finally, click OK.
Run the desktop app:
Wait, what is this?
The good news is the app ran. The bad news is there isn’t any content. Do you know why? Right — you never added any content to Main.kt. Go back to Main.kt in the desktop module. Inside of Surface, replace // TODO: Add Theme and MainView
:
MyApplicationTheme {
MainView()
}
This shows errors. Any ideas? Take a look at the desktop build.gradle.kts file. Looks like you need to uncomment out the shared-ui project. You’ll have to stop running the desktop to do any other Gradle tasks. Hit the red stop button, uncomment the shared-ui project and then resync Gradle.
Now, add the missing imports to Main.kt and run the app again.
Much better. Try using the app by adding some time zones and see if you’re missing anything.
Note: The window background can vary depending on whether you are using Dark Theme on your computer or not.
Window Sizes
If you bring up the Add Timezones dialog, you’ll see the buttons get cut off:
How can you fix that? Dialogs have a DialogState
class that allows you to set the position and size. To fix this dialog, open AddTimeDialogWrapper inside desktopMain and replace the rememberDialogState
method so that it looks like this:
state = rememberDialogState(
position = WindowPosition(Alignment.Center),
size = DpSize(width = 400.dp, height = Dp.Unspecified),
),
This sets a fixed width of 400dp and an unspecified height. This will allow the height to expand to a good size.
For MeetingDialogWrapper, replace the rememberDialogState
method with:
rememberDialogState(size = DpSize(width = 400.dp, height = Dp.Unspecified)),
Build and run the desktop app. You’ll see that the buttons are no longer cropped:
Adding Multiple Windows Support
Your app can have a single window or multiple windows. If you have just one window, you can use singleWindowApplication
instead of application
. For multiple windows, you need to call the Window
function for each window.
Open Main.kt in the desktop module. Before fun main()
, add:
data class WindowInfo(val windowName: String, val windowState: WindowState)
Add any imports needed. The WindowInfo
class just holds the window name and the window state.
Remove val windowState = rememberWindowState()
, then add:
var initialized by remember { mutableStateOf(false) }
var windowCount by remember { mutableStateOf(1) }
val windowList = remember { SnapshotStateList<WindowInfo>() }
// Add initial window
if (!initialized) {
windowList.add(WindowInfo("Timezone-${windowCount}", rememberWindowState()))
initialized = true
}
Add any needed imports like import androidx.compose.runtime.*
. The code above creates three variables:
- A one-time
initialized
flag. - The number of windows open (starting at one).
- The list of windows.
Then, it adds the first window entry (only once). This will be the first window to show up.
Replace the Window
function with:
// 1
windowList.forEachIndexed { i, _ ->
Window(
onCloseRequest = {
// 2
windowList.removeAt(i)
},
state = windowList[i].windowState,
// 3
title = windowList[i].windowName
) {
Here’s the explanation of the above code:
- For each
WindoInfo
class in your list, create a new window. - When the window is closed, remove it from the list.
- Set the title to the name from the
WindowInfo
class.
Then, add an ending }
at the end of application
. With the above code, you can now have multiple windows of your desktop application. You’ll see this in action in the next section. Run the app again to make sure it’s working fine.
Menus
If you look at the menu bar on macOS, you’ll notice that your app doesn’t have any menus as a regular app would:
You’ll now add a few menu items — like a File and Edit menu, as well as an exit menu option underneath the File menu to let the user exit the app.
Before the Surface function, add the code for a MenuBar
as follows:
// 1
MenuBar {
// 2
Menu("File", mnemonic = 'F') {
val nextWindowState = rememberWindowState()
// 3
Item(
"New", onClick = {
// 4
windowCount++
windowList.add(
WindowInfo(
"Timezone-${windowCount}",
nextWindowState
)
)
}, shortcut = KeyShortcut(
Key.N, ctrl = true
)
)
Item("Open", onClick = { }, shortcut = KeyShortcut(Key.O, ctrl = true))
// 5
Item("Close", onClick = {
windowList.removeAt(i)
}, shortcut = KeyShortcut(Key.W, ctrl = true))
Item("Save", onClick = { }, shortcut = KeyShortcut(Key.S, ctrl = true))
// 6
Separator()
// 7
Item(
"Exit",
onClick = { windowList.clear() },
)
}
Menu("Edit", mnemonic = 'E') {
Item(
"Cut", onClick = { }, shortcut = KeyShortcut(
Key.X, ctrl = true
)
)
Item(
"Copy", onClick = { }, shortcut = KeyShortcut(
Key.C, ctrl = true
)
)
Item("Paste", onClick = { }, shortcut = KeyShortcut(Key.V, ctrl = true))
}
}
You’ll use the Compose MenuBar import as well as the Compose Key import. Here’s what the above code does:
- Create a MenuBar to hold all of your menus.
- Create a new menu named File.
- Create a menu item named New and also assign a keyboard shortcut by providing value to the
shortcut
argument. - Increment the window count and add a new
WindowInfo
class to the list. This will cause the function to execute again. - Close the current window by removing it from the list.
- Add a separator.
- Add the exit menu. This clears the window list, which will cause the app to close.
Most of these menus don’t do anything. The File menu item will increment the window count, the close menu will remove the window from the window list and the exit menu will clear the list (causing the app to quit). Run the app. Here’s what you’ll see:
Try creating new windows and closing them. See what happens when you close the last window.
When writing your app, you may want to create your own menu file that handles menus. It could create a different menu system based on application state.
Distributing the App
When you’re finally satisfied with your app, you’ll want to distribute it to your users. The first step is to package it up into a distributable file. There isn’t cross-compilation support available at the moment, so the formats can only be built using your current machine.
Note: At the time of writing, macOS distribution builds required Java 15 or greater. On M1 MacBook pros, you’ll find that the Adoptium Arm-based JVM distribution works well and is easy to install. There are many third-party packages out there. Do a Google search to find one that’s easy to install for your machine. In case you face issues while packaging the app, make sure to install a compatible JDK.
To create a dmg installer for the Mac, you need to run the package Gradle task. You can run it from Android Studio:
Or, run the following from the command line (in the project directory):
./gradlew :desktop:package
This will create the package in the ./desktop/build/compose/binaries/main/dmg folder. Open it and you’ll see your app. You can double-click the app to run it or drag it into your Applications folder.
Here’s what your final app will look like:
Running on Windows Platform
On Windows, the process is almost the same for running the app. To create a distributable package make sure you build the desktop package from Android Studio and then from the command line type:
.\gradlew.bat :desktop:package
Or, run the package task from the compose desktop task folder. Once this finishes, you’ll find the FindTime-1.0.0.msi file in the desktop/build/compose/binaries/main/msi folder.
Congratulations! You were able to leverage your knowledge of Jetpack Compose to create your app on a whole new platform. If you have access to Windows, this will work there too.
Key Points
-
Compose Multiplatform is a framework that uses most of the Jetpack Compose framework to display UIs.
-
Compose Multiplatform works on Android, macOS, Windows and Linux desktops and the web.
-
Desktop apps can have multiple windows.
-
Desktop apps can use a menu system.
-
Android can use Compose Multiplatform as well.
Where to Go From Here?
Compose for Desktop:
- Official site: https://www.jetbrains.com/lp/compose-multiplatform/
- Github Repository: https://github.com/JetBrains/compose-multiplatform/#readme
- Adoptium JVMs: https://adoptium.net/
- Material Surface: https://m2.material.io/design/environment/surfaces.html#material-environment
Congratulations! You’ve created a Compose Multiplatform app that uses a shared library for the business logic. Looks like you’re on your way to mastering all the different platforms that KMP has to offer.