Build Desktop Apps With Kotlin And Compose Multiplatform For Desktop
Introduction
Compose Multiplatform 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 also supports other platforms such as web.
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 Multiplatform 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. The 'JAVA_HOME' environment variable should be set with the JDK path location. 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.
Download the Compose for Desktop project starter from the following link
Extract and move the folder to your workspace
Open IntelliJ IDEA and open the project from your workspace
Choose Gradle Kotlin as a build system
Open
src/main/kotlin/main.kt
, override the content with the following:import androidx.compose.material.Text import androidx.compose.ui.window.Window import androidx.compose.ui.window.application fun main() = application { Window(onCloseRequest = ::exitApplication) { Text("Hello, World!") } }
Open the terminal from the project's root directory, and launch:
gradlew run
. You can also launch therun
gradle task directly from IntelliJ IDEA.
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:
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.
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:
- Accessibility
- Context Menu
- Image and in-app icons manipulations
- Keyboard events handling
- Mouse events
- Navigation
- Tray and menu notification
- Swing integration
- Desktop components (such as Tooltips and Scrollbars)
- Compose for Desktop samples
Jetpack Compose
- Jetpack Compose basics tutorial
- Jetpack Compose basics codelab
- Layouts in Jetpack Compose codelab
- Jetpack Compose samples
- Jetpack Compose Playground: Community-driven collection of Jetpack Compose example code and tutorials
Soufiane Sakhi is an AWS Certified Solutions Architect – Associate and a professional full stack developer.