Build Desktop Apps With Kotlin And Jetpack Compose For Desktop


Get started with Compose for Desktop by JetBrains
How to design cross platform desktop GUI applications using Kotlin and the Compose for Desktop library by JetBrains.

Introduction

Compose for Desktop is a UI framework made by Jetbrains to simplify and accelerate desktop application development using Kotlin with a reactive and functional API. It targets the JVM and thus can be used to build cross platform GUI apps. It's currently in alpha.

It's based on the Jetpack Compose declarative UI Toolkit made by Google for Android. Using Kotlin to design user interfaces promotes less bugs, better tooling support and more concise and robust code. This is achieved using a declarative UI model based on composable functions, which take in parameters to describe the UI logic without returning anything and must be free of side-effects. More details about composables are presented in the Compose mental model documentation.

Although this tutorial's main subject is Compose for Desktop, but the concepts and most code snippets are also applicable to Android's Jetpack Compose.

Getting started

JDK 11 or later is required. IntelliJ IDEA Community Edition or Ultimate Edition 20.2 or later are the preferred IDEs. Follow this tutorial if you need to install JDK 11 and IntelliJ IDEA.

  1. Download the Compose for Desktop project starter from the following link

  2. Extract and move the folder to your workspace

  3. Open IntelliJ IDEA and create a new project from existing sources

  4. Choose Gradle, and click Finish

  5. Open src/main/kotlin/main.kt, override the content with the following:

     import androidx.compose.desktop.Window
     import androidx.compose.material.Text
    
     fun main() = Window {
         Text("Hello, World!")
     }
  6. Open the terminal from the project's root, and launch: gradlew run. You can also launch the run gradle task directly from IntelliJ IDEA.


Download the compose for desktop project starter from the official GitHub
Download the compose for desktop project starter from the official GitHub
Import the project in IntelliJ IDEA
Import the project in IntelliJ IDEA
Choose Gradle, and click Finish
Choose Gradle, and click Finish
Launch the 'run' Gradle task
Launch the 'run' Gradle task
Compose for Desktop Hello World
Compose for Desktop Hello World

Learning by practice: implementing a simple calculator

To dig deeper into Compose for Desktop concepts and learn it by practice, we will implement in this section a calculator UI. The resulting UI will look like this:

Compose desktop app example - Calculator

Entry point

const val DEFAULT_WIDTH = 500
const val DEFAULT_HEIGHT = 500

fun main() = Window(
    title = "Compose Calculator - simply-how.com",
    size = IntSize(DEFAULT_WIDTH, DEFAULT_HEIGHT),
    icon = Assets.WindowIcon
) {
    MaterialTheme(colors = lightThemeColors) {
        val mainOutput = remember { mutableStateOf(TextFieldValue("0")) }
        Column(Modifier.fillMaxHeight()) {
            DisplayPanel(
                Modifier.weight(1f),
                mainOutput
            )
            Keyboard(
                Modifier.weight(4f),
                mainOutput
            )
        }
    }
}
  • The app's window has a customized title, size (dimensions) and icon. More window customization are described here.
  • An application-wide theme is applied that follow Material Design principles. Learn more about theming here.
  • remember is used to create an internal state in the composable. Learn more about state management here.
  • Column is used to place items vertically on the screen. Learn about the available layouts here.
  • Modifier is used to customize the composable's appearance and behavior. For example, Modifier.weight is used to size the Column's children by dividing the available space proportionally. More about Modifiers here.

DisplayPanel

DisplayPanel is the top component of the calculator.

@Composable
fun DisplayPanel(
    modifier: Modifier,
    mainOutput: MutableState<TextFieldValue>
) {
    Column(
        modifier = modifier
            .fillMaxWidth()
            .background(MaterialTheme.colors.surface)
            .padding(CALCULATOR_PADDING)
            .background(Color.White)
            .border(color = Color.Gray, width = 1.dp)
            .padding(start = 16.dp, end = 16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.End
    ) {
        Text(
            text = mainOutput.value.text,
            style = TextStyle(
                fontSize = 48.sp,
                fontFamily = jostFontFamily
            ),
            overflow = TextOverflow.Ellipsis,
            softWrap = false,
            maxLines = 1,
        )
    }
}
  • We can find here a more complex usage of Modifiers.
  • The calculator's output is displayed using the Text composable with custom styling and behavior.
  • TextFieldValue holds the output text's editing state. It was initialized in the app's entry point, and is modified by the keyboard keys.

Keyboard

This is the second component of the calculator placed below DisplayPanel.

@Composable
fun Keyboard(
    modifier: Modifier,
    mainOutput: MutableState<TextFieldValue>
) {
    Surface(modifier) {
        KeyboardKeys(mainOutput)
    }
}

@Composable
fun KeyboardKeys(mainOutput: MutableState<TextFieldValue>) {
    Row(modifier = Modifier.fillMaxSize()) {
        KeyboardLayout.forEach { keyColumn ->
            Column(modifier = Modifier.weight(1f)) {
                keyColumn.forEach { key ->
                    KeyboardKey(Modifier.weight(1f), key, mainOutput)
                }
            }
        }
    }
}
  • the Surface composable will implicitly use the surface color defined in the app's Material theme.
  • Row is used to to place items horizontally on the screen.

@Composable
fun KeyboardKey(modifier: Modifier, key: Key?, mainOutput: MutableState<TextFieldValue>) {
    if (key == null) {
        return EmptyKeyView(modifier)
    }
    KeyView(modifier = modifier.padding(1.dp), onClick = key.onClick?.let {
        { it(mainOutput) }
    } ?: {
        val textValue = mainOutput.value.text.let {
            if (it == "0") key.value else it + key.value
        }
        mainOutput.value = TextFieldValue(text = textValue)
    }) {
        if (key.icon == null) {
            val textStyle = if (key.type == KeyType.COMMAND) {
                TextStyle(
                    color = MaterialTheme.colors.primary,
                    fontSize = 22.sp
                )
            } else {
                TextStyle(
                    fontFamily = jostFontFamily,
                    fontSize = 29.sp
                )
            }
            Text(
                text = key.value,
                style = textStyle
            )
        } else {
            Icon(
                asset = key.icon,
                tint = MaterialTheme.colors.primary
            )
        }
    }
}

val KEY_BORDER_WIDTH = 1.dp
val KEY_BORDER_COLOR = Color.Gray
val KEY_ACTIVE_BACKGROUND = Color.White

@Composable
fun KeyView(
    modifier: Modifier = Modifier,
    onClick: () -> Unit,
    children: @Composable ColumnScope.() -> Unit
) {
    val active = remember { mutableStateOf(false) }
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
        modifier = modifier.fillMaxWidth()
            .padding(CALCULATOR_PADDING)
            .clickable(onClick = onClick)
            .background(color = if (active.value) KEY_ACTIVE_BACKGROUND else MaterialTheme.colors.background)
            .border(width = KEY_BORDER_WIDTH, color = KEY_BORDER_COLOR)
            .pointerMoveFilter(
                onEnter = {
                    active.value = true
                    false
                },
                onExit = {
                    active.value = false
                    false
                }
            ),
        children = children
    )
}

@Composable
fun EmptyKeyView(modifier: Modifier) = Box(
    modifier = modifier.fillMaxWidth()
        .background(MaterialTheme.colors.background)
        .border(width = KEY_BORDER_WIDTH, color = KEY_BORDER_COLOR)
)
  • Modifier.clickable is used to make the keys receive click interactions from the user.
  • Modifier.pointerMoveFilter is used here to change the keys background color when hovering over them. Learn more about mouse events here.

Misc.

The following code was used to directly define a vector graphic (SVG aka vector drawable on Android) without using any XML file:

val OutlineBackspace = vectorBuilder().addPath(
    pathData = parsePath("M22,3L7,3c-0.69,0 -1.23,0.35 -1.59,0.88L0,12l5.41,8.11c0.36,0.53 0.9,0.89 1.59,0.89h15c1.1,0 2,-0.9 2,-2L24,5c0,-1.1 -0.9,-2 -2,-2zM22,19L7.07,19L2.4,12l4.66,-7L22,5v14zM10.41,17L14,13.41 17.59,17 19,15.59 15.41,12 19,8.41 17.59,7 14,10.59 10.41,7 9,8.41 12.59,12 9,15.59z"),
    fill = SolidColor(Color.Blue)
).build()

private fun vectorBuilder() = VectorAssetBuilder(
    defaultWidth = 24.dp,
    defaultHeight = 24.dp,
    viewportWidth = 24f,
    viewportHeight = 24f
)

private fun parsePath(pathStr: String) = PathParser().parsePathString(pathStr).toNodes()

The app's full source code is available on GitHub.


More resources and documentation

Here are some external resources and tutorials covering Compose aspects not presented in this article:

Compose for Desktop

Jetpack Compose