mirror of
https://github.com/jlengrand/compose-multiplatform.git
synced 2026-05-04 15:51:02 +00:00
Tutorials for new Composable Windows API (#721)
This commit is contained in:
494
tutorials/Window_API_new/README.md
Normal file
494
tutorials/Window_API_new/README.md
Normal file
@@ -0,0 +1,494 @@
|
||||
# Top level windows management (new Composable API, experimental)
|
||||
|
||||
## What is covered
|
||||
|
||||
In this tutorial we will show you how to work with windows using Compose for Desktop.
|
||||
|
||||
We represent window state in the shape suitable for Compose-style state manipulations and automatically mapping it into the operating system window state.
|
||||
|
||||
So top level windows can be both conditionally created in other composable functions and their window manager state could be manipulated using state produced by `rememberWindowState()` function.
|
||||
|
||||
## Open and close windows
|
||||
|
||||
The main function for creating windows is `Window`. This function should be used in Composable scope. The easiest way to create a Composable scope is to use `application` function:
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
Window {
|
||||
// Content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Window` is Composable function. It means you can change its properties in a declarative way:
|
||||
```kotlin
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
var fileName by remember { mutableStateOf("Untitled") }
|
||||
|
||||
Window(title = "$fileName - Editor") {
|
||||
Button(onClick = { fileName = "note.txt" }) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||

|
||||
|
||||
You can also close/open windows using a simple `if` statement.
|
||||
|
||||
When Window leaves the composition (isPerformingTask becomes `false`) - the native window automatically closes.
|
||||
```kotlin
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
var isPerformingTask by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(2000) // Do some heavy lifting
|
||||
isPerformingTask = false
|
||||
}
|
||||
if (isPerformingTask) {
|
||||
Window {
|
||||
Text("Performing some tasks. Please wait!")
|
||||
}
|
||||
} else {
|
||||
Window {
|
||||
Text("Hello, World!")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||

|
||||
|
||||
If window requires some custom logic on close (for example, to show a dialog), you can override close action using `onCloseRequest`.
|
||||
|
||||
See that instead of an imperative approach to closing the window (`window.close()`) we use a declarative - close the window in response of changing the state: `isOpen = false`.
|
||||
```kotlin
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
var isOpen by remember { mutableStateOf(true) }
|
||||
var isAskingToClose by remember { mutableStateOf(false) }
|
||||
if (isOpen) {
|
||||
Window(
|
||||
onCloseRequest = { isAskingToClose = true }
|
||||
) {
|
||||
if (isAskingToClose) {
|
||||
Dialog(
|
||||
title = "Close the document without saving?",
|
||||
onCloseRequest = { isAskingToClose = false }
|
||||
) {
|
||||
Button(
|
||||
onClick = { isOpen = false }
|
||||
) {
|
||||
Text("Yes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||

|
||||
|
||||
If you don't need to close the window on close button and just need to hide it (for example to tray), you can change `windowState.isVisible` state:
|
||||
```kotlin
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.Tray
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import kotlinx.coroutines.delay
|
||||
import java.awt.Color
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
val state = rememberWindowState()
|
||||
|
||||
Window(
|
||||
state,
|
||||
title = "Counter",
|
||||
onCloseRequest = { state.isVisible = false }
|
||||
) {
|
||||
var counter by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
counter++
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
Text(counter.toString())
|
||||
}
|
||||
|
||||
if (!state.isVisible && state.isOpen) {
|
||||
Tray(
|
||||
remember { getTrayIcon() },
|
||||
hint = "Counter",
|
||||
onAction = { state.isVisible = true },
|
||||
menu = {
|
||||
Item("Exit", onClick = { state.isOpen = false })
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTrayIcon(): BufferedImage {
|
||||
val size = 256
|
||||
val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
|
||||
val graphics = image.createGraphics()
|
||||
graphics.color = Color.orange
|
||||
graphics.fillOval(0, 0, size, size)
|
||||
graphics.dispose()
|
||||
return image
|
||||
}
|
||||
```
|
||||

|
||||
|
||||
If application has multiple windows then it is better to hoist its state into a separate class and open/close window in response of some list state changes (see [notepad example](https://github.com/JetBrains/compose-jb/tree/master/examples/notepad) for more complex use cases):
|
||||
```kotlin
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.MenuBar
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
val applicationState = remember { MyApplicationState() }
|
||||
|
||||
for (window in applicationState.windows) {
|
||||
MyWindow(window)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun MyWindow(
|
||||
state: MyWindowState
|
||||
) = Window(title = state.title, onCloseRequest = state::close) {
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item("New window", onClick = state.openNewWindow)
|
||||
Item("Exit", onClick = state.exit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MyApplicationState {
|
||||
val windows = mutableStateListOf<MyWindowState>()
|
||||
|
||||
init {
|
||||
windows += MyWindowState("Initial window")
|
||||
}
|
||||
|
||||
fun openNewWindow() {
|
||||
windows += MyWindowState("Window ${windows.size}")
|
||||
}
|
||||
|
||||
fun exit() {
|
||||
windows.clear()
|
||||
}
|
||||
|
||||
private fun MyWindowState(
|
||||
title: String
|
||||
) = MyWindowState(
|
||||
title,
|
||||
openNewWindow = ::openNewWindow,
|
||||
exit = ::exit,
|
||||
windows::remove
|
||||
)
|
||||
}
|
||||
|
||||
private class MyWindowState(
|
||||
val title: String,
|
||||
val openNewWindow: () -> Unit,
|
||||
val exit: () -> Unit,
|
||||
private val close: (MyWindowState) -> Unit
|
||||
) {
|
||||
fun close() = close(this)
|
||||
}
|
||||
```
|
||||

|
||||
|
||||
## Changing state (maximized, minimized, fullscreen, size, position) of the window.
|
||||
|
||||
Some state of the native window is hoisted into a separate API class `WindowState`. You can change its properties in callbacks or observe it in Composable's:
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
val state = rememberWindowState(isMaximized = true)
|
||||
|
||||
Window(state) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(state.isFullscreen, { state.isFullscreen = !state.isFullscreen })
|
||||
Text("isFullscreen")
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(state.isMaximized, { state.isMaximized = !state.isMaximized })
|
||||
Text("isMaximized")
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(state.isMinimized, { state.isMinimized = !state.isMinimized })
|
||||
Text("isMinimized")
|
||||
}
|
||||
|
||||
Text(
|
||||
"Position ${state.position}",
|
||||
Modifier.clickable {
|
||||
state.position = state.position.copy(x = state.position.x + 10.dp)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
"Size ${state.size}",
|
||||
Modifier.clickable {
|
||||
state.size = state.size.copy(width = state.size.width + 10.dp)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||

|
||||
|
||||
## Handle window-level shortcuts
|
||||
```kotlin
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusTarget
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
var isOpen by remember { mutableStateOf(true) }
|
||||
|
||||
if (isOpen) {
|
||||
Window {
|
||||
val focusRequester = remember(::FocusRequester)
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusTarget()
|
||||
.onPreviewKeyEvent {
|
||||
when (it.key) {
|
||||
Key.Escape -> {
|
||||
isOpen = false
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
) {
|
||||
TextField("Text", {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
(currently it is a bit verbose; in the future we will investigate - how can we provide a simple API for handling window key events).
|
||||
|
||||
## Dialogs
|
||||
There are two types of window – modal and regular. Below are the functions for creating each type of window:
|
||||
|
||||
1. Window – regular window type.
|
||||
2. Dialog – modal window type. Such a window locks its parent window until the user completes working with it and closes the modal window.
|
||||
|
||||
You can see an example of both types of window below.
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
Window {
|
||||
var isDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Button(onClick = { isDialogOpen = true }) {
|
||||
Text(text = "Open dialog")
|
||||
}
|
||||
|
||||
if (isDialogOpen) {
|
||||
Dialog(
|
||||
initialAlignment = Alignment.Center,
|
||||
onCloseRequest = { isDialogOpen = false }
|
||||
) {
|
||||
// Dialog's content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Swing interoperability
|
||||
Because Compose for Desktop uses Swing under the hood, it is possible to create a window using Swing directly:
|
||||
```kotlin
|
||||
import androidx.compose.desktop.ComposeWindow
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import java.awt.Dimension
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = SwingUtilities.invokeLater {
|
||||
ComposeWindow().apply {
|
||||
size = Dimension(300, 300)
|
||||
defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE
|
||||
setContent {
|
||||
// Content
|
||||
}
|
||||
isVisible = true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also access ComposeWindow in the Composable `Window` scope:
|
||||
```kotlin
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import java.awt.Cursor
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
Window {
|
||||
LaunchedEffect(Unit) {
|
||||
window.cursor = Cursor(Cursor.CROSSHAIR_CURSOR)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you need a dialog that is implemented in Swing, you can wrap it into Composable function:
|
||||
```kotlin
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.AwtWindow
|
||||
import androidx.compose.ui.window.application
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() = application {
|
||||
var isOpen by remember { mutableStateOf(true) }
|
||||
|
||||
if (isOpen) {
|
||||
FileDialog(
|
||||
onCloseRequest = {
|
||||
isOpen = false
|
||||
println("Result $it")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun FileDialog(
|
||||
parent: Frame? = null,
|
||||
onCloseRequest: (result: String?) -> Unit
|
||||
) = AwtWindow(
|
||||
create = {
|
||||
object : FileDialog(parent, "Choose a file", LOAD) {
|
||||
override fun setVisible(value: Boolean) {
|
||||
super.setVisible(value)
|
||||
if (value) {
|
||||
onCloseRequest(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dispose = FileDialog::dispose
|
||||
)
|
||||
```
|
||||
BIN
tutorials/Window_API_new/ask_to_close.gif
Normal file
BIN
tutorials/Window_API_new/ask_to_close.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 538 KiB |
BIN
tutorials/Window_API_new/hide_instead_of_close.gif
Normal file
BIN
tutorials/Window_API_new/hide_instead_of_close.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 904 KiB |
BIN
tutorials/Window_API_new/multiple_windows.gif
Normal file
BIN
tutorials/Window_API_new/multiple_windows.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
BIN
tutorials/Window_API_new/state.gif
Normal file
BIN
tutorials/Window_API_new/state.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
BIN
tutorials/Window_API_new/window_properties.gif
Normal file
BIN
tutorials/Window_API_new/window_properties.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
BIN
tutorials/Window_API_new/window_splash.gif
Normal file
BIN
tutorials/Window_API_new/window_splash.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 568 KiB |
Reference in New Issue
Block a user