Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

First Edition · Android 12, iOS 15, Desktop · Kotlin 1.6.10 · Android Studio Bumblebee

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, have worked with Google to create Compose Multiplatform (CM). This uses some of the same code from Jetpack Compose but extends it to be used for multiple platforms. This chapter will focus on the desktop, but CM will work on the web as well.

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 if you want. It uses the Skia graphics library that allows hardware acceleration (like JC). Apps built with CM can run on macOS, Windows and Linux.

Differences in desktop

Unlike mobile, the desktop has features like menus, multiple windows and system notifications. Menus can have shortcuts and windows will have different sizes and positions on the screen. The desktop doesn’t usually use app bars like mobile apps. You’ll usually use menus to handle actions.

Creating a desktop app

To create a desktop app, you’re going to do several things:

  1. Create a desktop module.
  2. Create a shared UI module.
  3. Move most of the Android code to the shared UI module.
  4. Create some wrappers so that Android and desktop can have unique functionality.

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 build.gradle.kts. Under allprojects and at the end of repositories add:

maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")

This adds the repository for the Compose Multiplatform library. Open the shared build.gradle.kts file and add the following after kotlin -> android():

jvm("desktop"){
    compilations.all {
        kotlinOptions.jvmTarget = "11"
    }
}

The code above creates a new JVM target with the name desktop and sets the JDK version to 11.

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 manually. Right-click the top-level folder in the project window and choose New ▸ Directory:

Fig. 5.1 - Creating a New Directory
Fig. 5.1 - Creating a 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. Add the following:

import org.jetbrains.compose.compose
import org.jetbrains.compose.desktop.application.dsl.TargetFormat

// 1
plugins {
    kotlin(multiplatform)
    id(composePlugin) version Versions.desktop_compose_plugin
}

// 2
group = "com.raywenderlich.findtime"
version = "1.0.0"

// 3
kotlin {
  // TODO: Add Kotlin 
}

// TODO: Add Compose Desktop
  1. Add the Multiplatform and Desktop Compose plugins.
  2. Set the group and version.
  3. Setup the Kotlin desktop settings.

Replace // TODO: Add Kotlin with the following code:

// 1
jvm {
    withJava()
    compilations.all {
        kotlinOptions.jvmTarget = "11"
    }
}
// 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)

            implementation(Deps.napier)
            // Coroutines
            implementation(Deps.Coroutines.common)

            // 6
            implementation(project(":shared"))
//            implementation(project(":shared-ui"))
        }
    }
}
  1. Set up a JVM target that uses Java 11 (11 or above is required).
  2. Set up a group of sources and resources for the JVM.
  3. Set the source directory path.
  4. Use the pre-defined variable to bring in the current OS library for Compose.
  5. Bring in the compose libraries. (Pre-defined variables).
  6. 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 needed for the desktop module.

Replace // TODO: Add Compose Desktop with:

// 1
compose.desktop {
    // 2
    application {
        // 3
        mainClass = "MainKt"
        // 4
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "FindTime"
            macOS {
            	bundleID = "com.raywenderlich.findtime"
        		}
        }
    }
}
  1. Configuration for Compose desktop.
  2. Define an application.
  3. Set the main class. You’ll create a Main.kt file in a bit.
  4. Set up packaging information for when you’re ready to ship.

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. Use src/jvmMain/kotlin. This will create three folders: src, jvmMain, and kotlin under that. Next, right-click on the kotlin folder and chose New ▸ Kotlin Class/File. Type Main and choose file.

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
              // TODO: Add MainView
            }
        }
    }
}
  1. Entry point to the application. Just like in Kotlin or Java programs, the starting function is main.
  2. Create a new application.
  3. Remember the current default window state. Change this if you want the window positioned in a different position or size.
  4. Create a new window with the window state. If the user closes the window, exit the application.
  5. Set up a Surface.

Other than the commented TODOs, 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. You put a lot of work into those files. 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:

import org.jetbrains.compose.compose

plugins {
    kotlin(multiplatform)
    id(androidLib)
    id(composePlugin) version Versions.desktop_compose_plugin
}

android {
   // TODO: Add Android Info
}

kotlin {
     // TODO: Add Desktop Info
}

This adds the multiplatform, Android and Compose plugins. Now replace // TODO: Add Android Info with:

compileSdk =  Versions.compile_sdk
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
    minSdk = Versions.min_sdk
    targetSdk = Versions.target_sdk
}
buildTypes {
    getByName("release") {
        isMinifyEnabled = false
    }
}

This is the minimum info needed for Android. For the desktop, replace // TODO: Add Desktop Info with:

// 1
android()
// 2
jvm("desktop") {
    compilations.all {
        kotlinOptions.jvmTarget = "11"
    }
}

sourceSets {
    // 3
    val commonMain by getting {
        dependencies {
            implementation(project(":shared"))

            api(compose.foundation)
            api(compose.runtime)
            api(compose.foundation)
            api(compose.material)
            api(compose.materialIconsExtended)
            api(compose.ui)
            api(compose.uiTooling)
        }
    }
    val commonTest by getting
    val androidMain by getting {
        dependencies {
            implementation("androidx.appcompat:appcompat:1.3.1")
        }
    }
    val desktopMain by getting
}
  1. Set an Android target.
  2. Set a desktop target.
  3. Define the common main sources. This includes the shared library and Desktop Compose.

One of the nice features of CM is that it can be used with both Android, desktop and web. 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/raywenderlich/compose/ui.

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/raywenderlich/compose.

For the desktop, do the same but use: desktopMain/kotlin/com/raywenderlich/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. 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/raywenderlich/findtime/android, select the theme and ui folders. Drag the two folders to the shared-ui/src/commonMain/kotlin/com/raywenderlich/compose folder. You’ll get some warnings but continue. You’ll fix these problems now.

AddTimeZoneDialog

Open AddTimeZoneDialog.kt from the commonMain/kotlin/com/raywenderlich/compose/ui folder inside the shared-ui module. You’ll see several errors for the following imports:

import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import com.raywenderlich.findtime.android.R

These three 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 is a Composable function that uses KMM’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 Column with the following code:

fun AddTimeZoneDialog(
    onAdd: OnAddType,
    onDismiss: onDismissType
) {
    val timezoneHelper: TimeZoneHelper = TimeZoneHelperImpl()

    AddTimeDialogWrapper(onDismiss) {

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. Go to the end of this function and add an ending }.

One issue in using Desktop Compose is resource handling. That’s beyond the scope of this chapter. For now, just change the string resources to hard-coded strings. Change:

stringResource(id = R.string.cancel)

to:

"Cancel"

Change:

stringResource(id = R.string.add)

to:

"Add"

From the shared-ui/src/androidMain/kotlin/com/raywenderlich/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
import com.raywenderlich.compose.ui.onDismissType

@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.raywenderlich.compose.ui package, if it isn’t already. Right-click on desktopMain/kotlin/com/raywenderlich/compose/ui and create the same class AddTimeDialogWrapper.kt. Add:

import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogState
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberDialogState

@Composable
actual fun AddTimeDialogWrapper(onDismiss: onDismissType, content: @Composable () -> Unit) {
    Dialog(onCloseRequest = { onDismiss() },
        state = rememberDialogState(
            position = WindowPosition(Alignment.Center),
        ),
            title = "Add Timezones",
            content = {
                content()
            })
}

This class is similar, and you may be asking why you need to create this wrapper at all. They both refer to import androidx.compose.ui.window.Dialog. But if you command-click on each of these imports, you’ll see they go to two different files. The CM plugin does some substitution with packages that makes the two libraries work together, but some of the code has to be different. Dialogs are one such case. Here this Dialog 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. 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 Column 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.

Change:

stringResource(id = R.string.done)

to:

"Done"

From the src/androidMain/kotlin/com/raywenderlich/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:

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/raywenderlich/compose/ui and create the same MeetingDialogWrapper.kt class. Add:

import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.rememberDialogState

@Composable
actual fun MeetingDialogWrapper(onDismiss: onDismissType, content: @Composable () -> Unit) {
    Dialog(
        onCloseRequest = { onDismiss() },
        title = "Meetings",
        state = rememberDialogState(),
        content = {
            content()
        })
}

This adds a close handler, a title of “Meetings”, the dialog state and the content.

To run your new desktop app, you’ll need to create a new configuration. From the configuration dropdown, choose Edit Configurations:

Fig. 5.2 - Edit Configurations
Fig. 5.2 - Edit Configurations

Next, click the plus symbol and choose Gradle.

Fig. 5.3 - Add New Configuration
Fig. 5.3 - Add New Configuration

Then, do the following:

  1. Set the Name to Desktop.
  2. For the Gradle project, choose the desktop project.
  3. In the Tasks field, enter run.

Click OK.

Fig. 5.4 - Desktop Configuration
Fig. 5.4 - Desktop Configuration

Run the desktop app:

Fig. 5.5 - Run Desktop Configuration
Fig. 5.5 - Run Desktop Configuration

Wait, what is this?

Fig. 5.6 - Blank Desktop app
Fig. 5.6 - Blank Desktop app

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, add:

AppTheme {
    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 resync Gradle.

Now, add the missing imports to Main.kt and run the app again.

Fig. 5.7 - Desktop Time Zones screen
Fig. 5.7 - Desktop Time Zones screen

Much better. Try using the app and see if you’re missing anything.

Fig. 5.8 - Desktop Time Range screen
Fig. 5.8 - Desktop Time Range screen

Note: The window background can vary depending on whether you are using Dark Theme on your computer or not.

Fig. 5.9 - Desktop Meeting Times screen
Fig. 5.9 - Desktop Meeting Times screen

Window sizes

If you bring up the Add Timezones dialog, you’ll see the buttons get cut off:

Fig. 5.10 - Desktop window buttons cropped
Fig. 5.10 - Desktop window buttons cropped

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 add to 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:

Fig. 5.11 - Desktop window buttons not cropped
Fig. 5.11 - Desktop window buttons not cropped

Windows

Your app can have a single window or multiple windows. If you just have 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)

@OptIn(ExperimentalComposeUiApi::class)

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:

  1. A one-time initialized flag.
  2. The number of windows open (starting at one).
  3. 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 ->
    Window(
        onCloseRequest = {
            // 2
            windowList.removeAt(i)
        },
        state = windowList[i].windowState,
        // 3
        title = windowList[i].windowName
    )
  1. For each WindoInfo class in your list, create a new window.
  2. When the window is closed, remove it from the list.
  3. 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.

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:

Fig. 5.12 - Desktop macOS menu
Fig. 5.12 - Desktop macOS menu

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))
    }
}
  1. Create a MenuBar to hold all of your menus.
  2. Create a new menu named File.
  3. Create a menu item named New.
  4. Increment the window count and add a new WindowInfo class to the list. This will cause the function to execute again.
  5. Close the current window by removing it from the list.
  6. Add a separator.
  7. Add the exit menu. This clears the window list, which will cause the app to close.

Add any needed imports. 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:

Fig. 5.13 - Desktop multiple windows
Fig. 5.13 - Desktop multiple windows

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.

Distribution

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 Azul 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.

To create a dmg installer for the Mac, you need to run the package Gradle task. You can run it from Android Studio:

Fig. 5.14 - Gradle desktop package task
Fig. 5.14 - Gradle desktop package task

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:

Fig. 5.15 - Desktop app running on macOS from packaged dmg
Fig. 5.15 - Desktop app running on macOS from packaged dmg

Windows

On Windows, the process is almost the same. 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.

Fig. 5.16 - Desktop app running on Windows from packaged distribution
Fig. 5.16 - Desktop app running on Windows from packaged distribution

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. In the shared-ui project, add a new AndroidManifest.xml file in the src/androidMain folder. Then, add:

<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.raywenderlich.findtime.shared_ui" />

Run the Android app to make sure it still works.

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 for displaying 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?

Desktop Compose:

Azul JVMs: https://www.azul.com/downloads/

Congratulations! You’ve written 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 KMM has to offer.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.