Part 2 of moving tutorials from GitHub to the [Kotlin Multiplatform Development](https://www.jetbrains.com/help/kotlin-multiplatform-dev) portalpull/5174/merge
@ -1,61 +0,0 @@
|
||||
# Accessibility support |
||||
|
||||
## Platform Support |
||||
|
||||
| Platform | Status | |
||||
|----------|-----------------------------------| |
||||
| MacOS | Supported | |
||||
| Windows | Supported with Java Access Bridge | |
||||
| Linux | Not supported | |
||||
|
||||
## Custom widget with semantic rules |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.* |
||||
import androidx.compose.foundation.layout.* |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.* |
||||
import androidx.compose.ui.* |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.semantics.* |
||||
import androidx.compose.ui.unit.* |
||||
import androidx.compose.ui.window.* |
||||
|
||||
fun main() = singleWindowApplication( |
||||
title = "Custom Button", state = WindowState(size = DpSize(300.dp, 200.dp)) |
||||
) { |
||||
var count by remember { mutableStateOf(0) } |
||||
|
||||
Box(modifier = Modifier.padding(50.dp)) { |
||||
Box(modifier = Modifier |
||||
.background(Color.LightGray) |
||||
.fillMaxSize() |
||||
.clickable { count += 1 } |
||||
.semantics(mergeDescendants = true /* Use text from the contents (1) */) { |
||||
// This is a button (2) |
||||
role = Role.Button |
||||
// Add some help text to button (3) |
||||
contentDescription = "Click to increment value" |
||||
} |
||||
) { |
||||
val text = when (count) { |
||||
0 -> "Click Me!" |
||||
1 -> "Clicked" |
||||
else -> "Clicked $count times" |
||||
} |
||||
Text(text, modifier = Modifier.align(Alignment.Center), fontSize = 24.sp) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
![Custom Widget](./images/custom-widget.png) |
||||
|
||||
# Windows |
||||
Accessibility on Windows is provided by Java Access Bridge and is disabled by default. To enable it, run the following command in Command Prompt. |
||||
|
||||
```cmd |
||||
%JAVA_HOME%\bin\jabswitch.exe /enable |
||||
``` |
||||
|
||||
There are some issues with HiDPI display support on windows, see [Desktop Accessibility on Windows](Windows.md) for details. |
@ -1,18 +0,0 @@
|
||||
# Desktop Accessibility on Windows |
||||
|
||||
## Enabling Java Accessibility on Windows |
||||
Java Access Bridge is disabled by default. To enable it, run |
||||
|
||||
```cmd |
||||
%JAVA_HOME%\bin\jabswitch.exe /enable |
||||
``` |
||||
|
||||
## HiDPI issues |
||||
### JDK support |
||||
HiDPI support in Access Bridge was landed in [JDK-8279227](https://bugs.openjdk.java.net/browse/JDK-8279227). As for Feb/01/2022 this feature is not included in any released JDK, OpenJDK 17.0.4 release with this feature is planned for May 2022. |
||||
|
||||
### NVDA workaround |
||||
NVDA 2021.3.1 does not handle widget position properly. Until they fix it we can override DPI awareness in NVDA compatibility settings as shown: |
||||
|
||||
![NVDA compatibility settings](./images/nvda-compat.png) |
||||
|
Before Width: | Height: | Size: 332 KiB |
Before Width: | Height: | Size: 103 KiB |
@ -1,385 +0,0 @@
|
||||
# Context Menu in Compose for Desktop |
||||
|
||||
## What is covered |
||||
|
||||
In this tutorial we will cover all aspects of work with Context Menu |
||||
using the Compose UI framework. |
||||
|
||||
## Default context menu |
||||
There is out-of-the box context menu support for TextField and Selectable text. |
||||
|
||||
To enable standard context menu for a TextField you just need to put it inside DesktopMaterialTheme: |
||||
|
||||
```kotlin |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.material.TextField |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication(title = "Context menu") { |
||||
val text = remember { mutableStateOf("Hello!") } |
||||
TextField( |
||||
value = text.value, |
||||
onValueChange = { text.value = it }, |
||||
label = { Text(text = "Input") } |
||||
) |
||||
} |
||||
``` |
||||
<img width="396" alt="image" src="https://user-images.githubusercontent.com/5963351/190021028-c207164d-df04-4294-ad8f-da3106c16fb6.png"> |
||||
|
||||
|
||||
Standard context menu for TextField contains the following items based on text selection: Copy, Cut, Paste, Select All. |
||||
|
||||
Enabling standard context menu for a Text component is similar - you just need to make it selectable: |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.text.selection.SelectionContainer |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication(title = "Context menu") { |
||||
SelectionContainer { |
||||
Text("Hello World!") |
||||
} |
||||
} |
||||
``` |
||||
Context menu for text contains just Copy action: |
||||
|
||||
<img width="391" alt="image" src="https://user-images.githubusercontent.com/5963351/190020951-0cc539a2-f698-4e2b-bc20-9d4aa1b11c6f.png"> |
||||
|
||||
## User-defined context menu |
||||
To enable additional context menu items for TextField and Text components, ContextMenuDataProvider and ContextMenuItem elements are used: |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.ContextMenuDataProvider |
||||
import androidx.compose.foundation.ContextMenuItem |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.text.selection.SelectionContainer |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.material.TextField |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication(title = "Context menu") { |
||||
val text = remember { mutableStateOf("Hello!") } |
||||
Column { |
||||
ContextMenuDataProvider( |
||||
items = { |
||||
listOf( |
||||
ContextMenuItem("User-defined Action") {/*do something here*/ }, |
||||
ContextMenuItem("Another user-defined action") {/*do something else*/ } |
||||
) |
||||
} |
||||
) { |
||||
TextField( |
||||
value = text.value, |
||||
onValueChange = { text.value = it }, |
||||
label = { Text(text = "Input") } |
||||
) |
||||
|
||||
Spacer(Modifier.height(16.dp)) |
||||
|
||||
SelectionContainer { |
||||
Text("Hello World!") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
In this example Text/TextField context menus will be extended with two additional items: |
||||
|
||||
<img width="430" alt="image" src="https://user-images.githubusercontent.com/5963351/190020831-9b87b191-a351-4f70-a726-d5a53577ad53.png"> |
||||
|
||||
## Context menu for an arbitrary area |
||||
There is a possibility to create a context menu for an arbitrary application window area. This is implemented using ContextMenuArea API that is |
||||
similar to ContextMenuDataProvider. |
||||
```kotlin |
||||
import androidx.compose.foundation.ContextMenuArea |
||||
import androidx.compose.foundation.ContextMenuItem |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication(title = "Context menu") { |
||||
ContextMenuArea(items = { |
||||
listOf( |
||||
ContextMenuItem("User-defined Action") {/*do something here*/}, |
||||
ContextMenuItem("Another user-defined action") {/*do something else*/} |
||||
) |
||||
}) { |
||||
Box(modifier = Modifier.background(Color.Blue).height(100.dp).width(100.dp)) |
||||
} |
||||
} |
||||
``` |
||||
Right click on the Blue Square will show a context menu with two items: |
||||
|
||||
<img width="423" alt="image" src="https://user-images.githubusercontent.com/5963351/190020592-15e851f8-e356-413c-b5c3-225393712292.png"> |
||||
|
||||
## Styling context menu |
||||
Style of context menu doesn't comply MaterialTheme. To change its colors, you should override `LocalContextMenuRepresentation`: |
||||
```kotlin |
||||
import androidx.compose.foundation.DarkDefaultContextMenuRepresentation |
||||
import androidx.compose.foundation.LightDefaultContextMenuRepresentation |
||||
import androidx.compose.foundation.LocalContextMenuRepresentation |
||||
import androidx.compose.foundation.isSystemInDarkTheme |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.TextField |
||||
import androidx.compose.material.darkColors |
||||
import androidx.compose.material.lightColors |
||||
import androidx.compose.runtime.CompositionLocalProvider |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication { |
||||
isSystemInDarkTheme() |
||||
MaterialTheme( |
||||
colors = if (isSystemInDarkTheme()) darkColors() else lightColors() |
||||
) { |
||||
val contextMenuRepresentation = if (isSystemInDarkTheme()) { |
||||
DarkDefaultContextMenuRepresentation |
||||
} else { |
||||
LightDefaultContextMenuRepresentation |
||||
} |
||||
CompositionLocalProvider(LocalContextMenuRepresentation provides contextMenuRepresentation) { |
||||
Surface(Modifier.fillMaxSize()) { |
||||
Box { |
||||
var value by remember { mutableStateOf("") } |
||||
TextField(value, { value = it }) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
<img width="392" alt="image" src="https://user-images.githubusercontent.com/5963351/190514663-d345a0ba-0b4c-4920-b6cd-743a753d7d83.png"> |
||||
|
||||
## Custom text context menu |
||||
You can override text menu for all texts and text fields in your application, overriding `TextContextMenu`: |
||||
```kotlin |
||||
import androidx.compose.foundation.ContextMenuDataProvider |
||||
import androidx.compose.foundation.ContextMenuItem |
||||
import androidx.compose.foundation.ContextMenuState |
||||
import androidx.compose.foundation.ExperimentalFoundationApi |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.text.LocalTextContextMenu |
||||
import androidx.compose.foundation.text.TextContextMenu |
||||
import androidx.compose.foundation.text.selection.SelectionContainer |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.material.TextField |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.CompositionLocalProvider |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.awt.ComposePanel |
||||
import androidx.compose.ui.platform.LocalUriHandler |
||||
import androidx.compose.ui.text.AnnotatedString |
||||
import java.awt.Dimension |
||||
import java.net.URLEncoder |
||||
import java.nio.charset.Charset |
||||
import javax.swing.JFrame |
||||
import javax.swing.SwingUtilities |
||||
|
||||
fun main() = SwingUtilities.invokeLater { |
||||
val panel = ComposePanel() |
||||
panel.setContent { |
||||
CustomTextMenuProvider { |
||||
Column { |
||||
SelectionContainer { |
||||
Text("Hello, Compose!") |
||||
} |
||||
|
||||
var text by remember { mutableStateOf("") } |
||||
|
||||
TextField(text, { text = it }) |
||||
} |
||||
} |
||||
} |
||||
|
||||
val window = JFrame() |
||||
window.contentPane.add(panel) |
||||
window.size = Dimension(800, 600) |
||||
window.isVisible = true |
||||
} |
||||
|
||||
@OptIn(ExperimentalFoundationApi::class) |
||||
@Composable |
||||
fun CustomTextMenuProvider(content: @Composable () -> Unit) { |
||||
val textMenu = LocalTextContextMenu.current |
||||
val uriHandler = LocalUriHandler.current |
||||
CompositionLocalProvider( |
||||
LocalTextContextMenu provides object : TextContextMenu { |
||||
@Composable |
||||
override fun Area( |
||||
textManager: TextContextMenu.TextManager, |
||||
state: ContextMenuState, |
||||
content: @Composable () -> Unit |
||||
) { |
||||
// Here we reuse the original TextContextMenu, but add an additional item to item on the bottom. |
||||
ContextMenuDataProvider({ |
||||
val shortText = textManager.selectedText.crop() |
||||
if (shortText.isNotEmpty()) { |
||||
val encoded = URLEncoder.encode(shortText, Charset.defaultCharset()) |
||||
listOf(ContextMenuItem("Search $shortText") { |
||||
uriHandler.openUri("https://google.com/search?q=$encoded") |
||||
}) |
||||
} else { |
||||
emptyList() |
||||
} |
||||
}) { |
||||
textMenu.Area(textManager, state, content = content) |
||||
} |
||||
} |
||||
}, |
||||
content = content |
||||
) |
||||
} |
||||
|
||||
private fun AnnotatedString.crop() = if (length <= 5) toString() else "${take(5)}..." |
||||
``` |
||||
<img width="453" alt="image" src="https://user-images.githubusercontent.com/5963351/190509388-92cff018-2880-4cfe-95c4-4c023ecac09d.png"> |
||||
|
||||
## Swing interoperability |
||||
If you are embedding Compose into an existing application, you may want the text context menu to look the same as in other parts of the application. To do this, there is `JPopupTextMenu`: |
||||
```kotlin |
||||
import androidx.compose.foundation.ExperimentalFoundationApi |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.text.JPopupTextMenu |
||||
import androidx.compose.foundation.text.LocalTextContextMenu |
||||
import androidx.compose.foundation.text.selection.SelectionContainer |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.material.TextField |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.CompositionLocalProvider |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.awt.ComposePanel |
||||
import androidx.compose.ui.platform.LocalLocalization |
||||
import java.awt.Color |
||||
import java.awt.Component |
||||
import java.awt.Dimension |
||||
import java.awt.Graphics |
||||
import java.awt.event.KeyEvent |
||||
import java.awt.event.KeyEvent.CTRL_DOWN_MASK |
||||
import java.awt.event.KeyEvent.META_DOWN_MASK |
||||
import javax.swing.Icon |
||||
import javax.swing.JFrame |
||||
import javax.swing.JMenuItem |
||||
import javax.swing.JPopupMenu |
||||
import javax.swing.KeyStroke.getKeyStroke |
||||
import javax.swing.SwingUtilities |
||||
import org.jetbrains.skiko.hostOs |
||||
|
||||
fun main() = SwingUtilities.invokeLater { |
||||
val panel = ComposePanel() |
||||
panel.setContent { |
||||
JPopupTextMenuProvider(panel) { |
||||
Column { |
||||
SelectionContainer { |
||||
Text("Hello, Compose!") |
||||
} |
||||
|
||||
var text by remember { mutableStateOf("") } |
||||
|
||||
TextField(text, { text = it }) |
||||
} |
||||
} |
||||
} |
||||
|
||||
val window = JFrame() |
||||
window.contentPane.add(panel) |
||||
window.size = Dimension(800, 600) |
||||
window.isVisible = true |
||||
} |
||||
|
||||
@OptIn(ExperimentalFoundationApi::class) |
||||
@Composable |
||||
fun JPopupTextMenuProvider(owner: Component, content: @Composable () -> Unit) { |
||||
val localization = LocalLocalization.current |
||||
CompositionLocalProvider( |
||||
LocalTextContextMenu provides JPopupTextMenu(owner) { textManager, items -> |
||||
JPopupMenu().apply { |
||||
textManager.cut?.also { |
||||
add( |
||||
swingItem(localization.cut, Color.RED, KeyEvent.VK_X, it) |
||||
) |
||||
} |
||||
textManager.copy?.also { |
||||
add( |
||||
swingItem(localization.copy, Color.GREEN, KeyEvent.VK_C, it) |
||||
) |
||||
} |
||||
textManager.paste?.also { |
||||
add( |
||||
swingItem(localization.paste, Color.BLUE, KeyEvent.VK_V, it) |
||||
) |
||||
} |
||||
textManager.selectAll?.also { |
||||
add(JPopupMenu.Separator()) |
||||
add( |
||||
swingItem(localization.selectAll, Color.BLACK, KeyEvent.VK_A, it) |
||||
) |
||||
} |
||||
|
||||
// Here we add other items that can be defined additionaly in the other places of the application via ContextMenuDataProvider |
||||
for (item in items) { |
||||
add( |
||||
JMenuItem(item.label).apply { |
||||
addActionListener { item.onClick() } |
||||
} |
||||
) |
||||
} |
||||
} |
||||
}, |
||||
content = content |
||||
) |
||||
} |
||||
|
||||
private fun swingItem( |
||||
label: String, |
||||
color: Color, |
||||
key: Int, |
||||
onClick: () -> Unit |
||||
) = JMenuItem(label).apply { |
||||
icon = circleIcon(color) |
||||
accelerator = getKeyStroke(key, if (hostOs.isMacOS) META_DOWN_MASK else CTRL_DOWN_MASK) |
||||
addActionListener { onClick() } |
||||
} |
||||
|
||||
private fun circleIcon(color: Color) = object : Icon { |
||||
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { |
||||
g.create().apply { |
||||
this.color = color |
||||
translate(16, 2) |
||||
fillOval(0, 0, 16, 16) |
||||
} |
||||
} |
||||
|
||||
override fun getIconWidth() = 16 |
||||
|
||||
override fun getIconHeight() = 16 |
||||
} |
||||
``` |
||||
<img width="448" alt="image" src="https://user-images.githubusercontent.com/5963351/191312702-f455ab2c-4c47-4e11-b615-fc67af1af3f9.png"> |
||||
|
@ -1,247 +0,0 @@
|
||||
# Desktop components |
||||
|
||||
## What is covered |
||||
|
||||
In this tutorial, we will show you how to use desktop-specific components of Compose for Desktop such as scrollbars and tooltips. |
||||
|
||||
## Scrollbars |
||||
|
||||
You can apply scrollbars to scrollable components. The scrollbar and scrollable components share a common state to synchronize with each other. For example, `VerticalScrollbar` can be attached to `Modifier.verticalScroll`, and `LazyColumn` and `HorizontalScrollbar` can be attached to `Modifier.horizontalScroll` and `LazyRow`. |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.HorizontalScrollbar |
||||
import androidx.compose.foundation.VerticalScrollbar |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.horizontalScroll |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxHeight |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.foundation.rememberScrollState |
||||
import androidx.compose.foundation.rememberScrollbarAdapter |
||||
import androidx.compose.foundation.verticalScroll |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.WindowState |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication( |
||||
title = "Scrollbars", |
||||
state = WindowState(width = 250.dp, height = 400.dp) |
||||
) { |
||||
Box( |
||||
modifier = Modifier.fillMaxSize() |
||||
.background(color = Color(180, 180, 180)) |
||||
.padding(10.dp) |
||||
) { |
||||
val stateVertical = rememberScrollState(0) |
||||
val stateHorizontal = rememberScrollState(0) |
||||
|
||||
Box( |
||||
modifier = Modifier |
||||
.fillMaxSize() |
||||
.verticalScroll(stateVertical) |
||||
.padding(end = 12.dp, bottom = 12.dp) |
||||
.horizontalScroll(stateHorizontal) |
||||
) { |
||||
Column { |
||||
for (item in 0..30) { |
||||
TextBox("Item #$item") |
||||
if (item < 30) { |
||||
Spacer(modifier = Modifier.height(5.dp)) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
VerticalScrollbar( |
||||
modifier = Modifier.align(Alignment.CenterEnd) |
||||
.fillMaxHeight(), |
||||
adapter = rememberScrollbarAdapter(stateVertical) |
||||
) |
||||
HorizontalScrollbar( |
||||
modifier = Modifier.align(Alignment.BottomStart) |
||||
.fillMaxWidth() |
||||
.padding(end = 12.dp), |
||||
adapter = rememberScrollbarAdapter(stateHorizontal) |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun TextBox(text: String = "Item") { |
||||
Box( |
||||
modifier = Modifier.height(32.dp) |
||||
.width(400.dp) |
||||
.background(color = Color(200, 0, 0, 20)) |
||||
.padding(start = 10.dp), |
||||
contentAlignment = Alignment.CenterStart |
||||
) { |
||||
Text(text = text) |
||||
} |
||||
} |
||||
``` |
||||
|
||||
Scrollbars can be moved by dragging the bars and using the mouse wheel or the touchpad. Horizontal scrolling with the mouse wheel can be performed by side-clicking the wheel or by holding down `Shift`. |
||||
|
||||
<img alt="Scrollbars" src="scrollbars.gif" height="412" /> |
||||
|
||||
## Lazy scrollable components with Scrollbar |
||||
|
||||
You can use scrollbars with lazy scrollable components, for example, `LazyColumn`. |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.VerticalScrollbar |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxHeight |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.lazy.LazyColumn |
||||
import androidx.compose.foundation.lazy.rememberLazyListState |
||||
import androidx.compose.foundation.rememberScrollbarAdapter |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.application |
||||
import androidx.compose.ui.window.rememberWindowState |
||||
|
||||
fun main() = application { |
||||
Window( |
||||
onCloseRequest = ::exitApplication, |
||||
title = "Scrollbars", |
||||
state = rememberWindowState(width = 250.dp, height = 400.dp) |
||||
) { |
||||
LazyScrollable() |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun LazyScrollable() { |
||||
Box( |
||||
modifier = Modifier.fillMaxSize() |
||||
.background(color = Color(180, 180, 180)) |
||||
.padding(10.dp) |
||||
) { |
||||
|
||||
val state = rememberLazyListState() |
||||
|
||||
LazyColumn(Modifier.fillMaxSize().padding(end = 12.dp), state) { |
||||
items(1000) { x -> |
||||
TextBox("Item #$x") |
||||
Spacer(modifier = Modifier.height(5.dp)) |
||||
} |
||||
} |
||||
VerticalScrollbar( |
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), |
||||
adapter = rememberScrollbarAdapter( |
||||
scrollState = state |
||||
) |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun TextBox(text: String = "Item") { |
||||
Box( |
||||
modifier = Modifier.height(32.dp) |
||||
.fillMaxWidth() |
||||
.background(color = Color(0, 0, 0, 20)) |
||||
.padding(start = 10.dp), |
||||
contentAlignment = Alignment.CenterStart |
||||
) { |
||||
Text(text = text) |
||||
} |
||||
} |
||||
``` |
||||
|
||||
<img alt="Lazy component" src="lazy_scrollbar.gif" height="412" /> |
||||
|
||||
## Tooltips |
||||
|
||||
You can add tooltip to any components using `TooltipArea`. `TooltipArea` is similar to a `Box`, but with the ability to show a tooltip. |
||||
|
||||
The main arguments of the `TooltipArea` function: |
||||
- tooltip - composable content representing tooltip |
||||
- tooltipPlacement - describes how to place tooltip. You can specify an anchor (the mouse cursor or the component), an offset and an alignment |
||||
- delay - time delay in milliseconds after which the tooltip will be shown (default is 500 ms) |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.ExperimentalFoundationApi |
||||
import androidx.compose.foundation.TooltipArea |
||||
import androidx.compose.foundation.TooltipPlacement |
||||
import androidx.compose.foundation.layout.Arrangement |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material.Button |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.draw.shadow |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.DpOffset |
||||
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, ExperimentalFoundationApi::class) |
||||
fun main() = application { |
||||
Window( |
||||
onCloseRequest = ::exitApplication, |
||||
title = "Tooltip Example", |
||||
state = rememberWindowState(width = 300.dp, height = 300.dp) |
||||
) { |
||||
val buttons = listOf("Button A", "Button B", "Button C", "Button D", "Button E", "Button F") |
||||
Column(Modifier.fillMaxSize(), Arrangement.spacedBy(5.dp)) { |
||||
buttons.forEachIndexed { index, name -> |
||||
// wrap button in BoxWithTooltip |
||||
TooltipArea( |
||||
tooltip = { |
||||
// composable tooltip content |
||||
Surface( |
||||
modifier = Modifier.shadow(4.dp), |
||||
color = Color(255, 255, 210), |
||||
shape = RoundedCornerShape(4.dp) |
||||
) { |
||||
Text( |
||||
text = "Tooltip for ${name}", |
||||
modifier = Modifier.padding(10.dp) |
||||
) |
||||
} |
||||
}, |
||||
modifier = Modifier.padding(start = 40.dp), |
||||
delayMillis = 600, // in milliseconds |
||||
tooltipPlacement = TooltipPlacement.CursorPoint( |
||||
alignment = Alignment.BottomEnd, |
||||
offset = if (index % 2 == 0) DpOffset(-16.dp, 0.dp) else DpOffset.Zero // tooltip offset |
||||
) |
||||
) { |
||||
Button(onClick = {}) { Text(text = name) } |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
<img alt="Tooltip" src="tooltips.gif" height="314" /> |
Before Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 2.4 MiB |
@ -1,168 +0,0 @@
|
||||
# Keyboard events handling |
||||
|
||||
## Prerequisites |
||||
|
||||
This tutorial expects that you have already set up the Compose project as described in the [Getting Started tutorial](../Getting_Started) |
||||
|
||||
## What is covered |
||||
|
||||
In this tutorial, we will look at two different ways of handling keyboard events in Compose for Desktop as well as the utilities that we have to do this. |
||||
|
||||
## Event handlers |
||||
|
||||
There are two ways to handle key events in Compose for Desktop: |
||||
|
||||
- By setting up an event handler based on the element that is in focus |
||||
- By setting up an event handler in the scope of the window |
||||
|
||||
## Focus related events |
||||
|
||||
It works the same as Compose for Android, for details see [API Reference](https://developer.android.com/reference/kotlin/androidx/compose/ui/input/key/package-summary#keyinputfilter) |
||||
|
||||
The most common use case is to define keyboard handlers for active controls like `TextField`. You can use both `onKeyEvent` and `onPreviewKeyEvent` but the last one is usually preferable to define shortcuts while it guarantees you that key events will not be consumed by children components. Here is an example: |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.layout.Arrangement |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.material.TextField |
||||
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.input.key.Key |
||||
import androidx.compose.ui.input.key.isCtrlPressed |
||||
import androidx.compose.ui.input.key.key |
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent |
||||
import androidx.compose.ui.input.key.type |
||||
import androidx.compose.ui.input.key.KeyEventType |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
fun main() = singleWindowApplication { |
||||
MaterialTheme { |
||||
var consumedText by remember { mutableStateOf(0) } |
||||
var text by remember { mutableStateOf("") } |
||||
Column(Modifier.fillMaxSize(), Arrangement.spacedBy(5.dp)) { |
||||
Text("Consumed text: $consumedText") |
||||
TextField( |
||||
value = text, |
||||
onValueChange = { text = it }, |
||||
modifier = Modifier.onPreviewKeyEvent { |
||||
when { |
||||
(it.isCtrlPressed && it.key == Key.Minus && it.type == KeyEventType.KeyUp) -> { |
||||
consumedText -= text.length |
||||
text = "" |
||||
true |
||||
} |
||||
(it.isCtrlPressed && it.key == Key.Equals && it.type == KeyEventType.KeyUp) -> { |
||||
consumedText += text.length |
||||
text = "" |
||||
true |
||||
} |
||||
else -> false |
||||
} |
||||
} |
||||
) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
Note the annotation `@OptIn(ExperimentalComposeUiApi::class)`. Some keys related APIs are still an experimental feature of Compose, and later API changes are possible. So it requires the use of a special annotation to emphasize the experimental nature of the code. |
||||
|
||||
<img alt="keyInputFilter" src="keyInputFilter.gif" height="272" /> |
||||
|
||||
## Window-scoped events |
||||
|
||||
`Window`, `singleWindowApplication` and `Dialog` functions have a `onPreviewKeyEvent` and a `onKeyEvent` properties. It is possible to use them to define keyboard event handlers that are always active in the current window. You possibly want to use `onPreviewKeyEvent` here to intercept events. Here is an example: |
||||
|
||||
``` kotlin |
||||
import androidx.compose.foundation.layout.Arrangement |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.material.Button |
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Text |
||||
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.Modifier |
||||
import androidx.compose.ui.input.key.Key |
||||
import androidx.compose.ui.input.key.KeyEventType |
||||
import androidx.compose.ui.input.key.isCtrlPressed |
||||
import androidx.compose.ui.input.key.isShiftPressed |
||||
import androidx.compose.ui.input.key.key |
||||
import androidx.compose.ui.input.key.type |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.Dialog |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
private var cleared by mutableStateOf(false) |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
fun main() = singleWindowApplication( |
||||
onKeyEvent = { |
||||
if ( |
||||
it.isCtrlPressed && |
||||
it.isShiftPressed && |
||||
it.key == Key.C && |
||||
it.type == KeyEventType.KeyDown |
||||
) { |
||||
cleared = true |
||||
true |
||||
} else { |
||||
false |
||||
} |
||||
} |
||||
) { |
||||
MaterialTheme { |
||||
if (cleared) { |
||||
Text("The App was cleared!") |
||||
} else { |
||||
App() |
||||
} |
||||
} |
||||
} |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
@Composable |
||||
fun App() { |
||||
var isDialogOpen by remember { mutableStateOf(false) } |
||||
|
||||
if (isDialogOpen) { |
||||
Dialog( |
||||
onCloseRequest = { isDialogOpen = false }, |
||||
onPreviewKeyEvent = { |
||||
if (it.key == Key.Escape && it.type == KeyEventType.KeyDown) { |
||||
isDialogOpen = false |
||||
true |
||||
} else { |
||||
false |
||||
} |
||||
}) { |
||||
Text("I'm dialog!") |
||||
} |
||||
} |
||||
|
||||
Column(Modifier.fillMaxSize(), Arrangement.spacedBy(5.dp)) { |
||||
Button( |
||||
modifier = Modifier.padding(4.dp), |
||||
onClick = { isDialogOpen = true } |
||||
) { |
||||
Text("Open dialog") |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
<img alt="window_keyboard" src="window_keyboard.gif" height="280" /> |
Before Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 493 KiB |
@ -1,492 +0,0 @@
|
||||
# Mouse events |
||||
|
||||
## What is covered |
||||
|
||||
In this tutorial we will see how to install mouse event listeners on components |
||||
in Compose for Desktop. |
||||
|
||||
## Mouse event listeners |
||||
|
||||
### Click listeners |
||||
|
||||
Click listeners are available in both Compose on Android and Compose for Desktop, so code like this will work on both platforms: |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.ExperimentalFoundationApi |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.combinedClickable |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.fillMaxHeight |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
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.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.sp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication { |
||||
var count by remember { mutableStateOf(0) } |
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth()) { |
||||
var text by remember { mutableStateOf("Click magenta box!") } |
||||
Column { |
||||
@OptIn(ExperimentalFoundationApi::class) |
||||
Box( |
||||
modifier = Modifier |
||||
.background(Color.Magenta) |
||||
.fillMaxWidth(0.7f) |
||||
.fillMaxHeight(0.2f) |
||||
.combinedClickable( |
||||
onClick = { |
||||
text = "Click! ${count++}" |
||||
}, |
||||
onDoubleClick = { |
||||
text = "Double click! ${count++}" |
||||
}, |
||||
onLongClick = { |
||||
text = "Long click! ${count++}" |
||||
} |
||||
) |
||||
) |
||||
Text(text = text, fontSize = 40.sp) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
<img alt="Application running" src="mouse_click.gif" height="500" /> |
||||
|
||||
`combinedClickable` supports only the Primary button (Left Mouse Button) and touch events. If there is a need to handle other buttons differently, please have a look at `Modifier.onClick` below (note: `Modifier.onClick` is currently avilable only for Desktop-JVM platform). |
||||
|
||||
### Mouse move listeners |
||||
|
||||
Let's create a window and install a pointer move listener on it that changes the background color according to the mouse pointer position: |
||||
```kotlin |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.wrapContentSize |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.input.pointer.PointerEventType |
||||
import androidx.compose.ui.input.pointer.onPointerEvent |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
fun main() = singleWindowApplication { |
||||
var color by remember { mutableStateOf(Color(0, 0, 0)) } |
||||
Box( |
||||
modifier = Modifier |
||||
.wrapContentSize(Alignment.Center) |
||||
.fillMaxSize() |
||||
.background(color = color) |
||||
.onPointerEvent(PointerEventType.Move) { |
||||
val position = it.changes.first().position |
||||
color = Color(position.x.toInt() % 256, position.y.toInt() % 256, 0) |
||||
} |
||||
) |
||||
} |
||||
``` |
||||
*Note that onPointerEvent is experimental and can be changed in the future. For more stable API look at [Modifier.pointerInput](#listenining-raw-events-in-commonmain-via-modifierpointerinput)*. |
||||
|
||||
<img alt="Application running" src="mouse_move.gif" height="519" /> |
||||
|
||||
### Mouse enter listeners |
||||
|
||||
Compose for Desktop also supports pointer enter and exit handlers, like this: |
||||
```kotlin |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Arrangement |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
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.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.input.pointer.PointerEventType |
||||
import androidx.compose.ui.input.pointer.onPointerEvent |
||||
import androidx.compose.ui.text.font.FontStyle |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.unit.sp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
fun main() = singleWindowApplication { |
||||
Column( |
||||
Modifier.background(Color.White), |
||||
verticalArrangement = Arrangement.spacedBy(10.dp) |
||||
) { |
||||
repeat(10) { index -> |
||||
var active by remember { mutableStateOf(false) } |
||||
Text( |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.background(color = if (active) Color.Green else Color.White) |
||||
.onPointerEvent(PointerEventType.Enter) { active = true } |
||||
.onPointerEvent(PointerEventType.Exit) { active = false }, |
||||
fontSize = 30.sp, |
||||
fontStyle = if (active) FontStyle.Italic else FontStyle.Normal, |
||||
text = "Item $index" |
||||
) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
*Note that onPointerEvent is experimental and can be changed in the future. For more stable API look at [Modifier.pointerInput](#listenining-raw-events-in-commonmain-via-modifierpointerinput)*. |
||||
|
||||
<img alt="Application running" src="mouse_enter.gif" height="500" /> |
||||
|
||||
### Mouse scroll listeners |
||||
```kotlin |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
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.Alignment |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.input.pointer.PointerEventType |
||||
import androidx.compose.ui.input.pointer.onPointerEvent |
||||
import androidx.compose.ui.unit.sp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
fun main() = singleWindowApplication { |
||||
var number by remember { mutableStateOf(0f) } |
||||
Box( |
||||
Modifier |
||||
.fillMaxSize() |
||||
.onPointerEvent(PointerEventType.Scroll) { |
||||
number += it.changes.first().scrollDelta.y |
||||
}, |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Text("Scroll to change the number: $number", fontSize = 30.sp) |
||||
} |
||||
} |
||||
``` |
||||
*Note that onPointerEvent is experimental and can be changed in the future. For more stable API look at [Modifier.pointerInput](#listenining-raw-events-in-commonmain-via-modifierpointerinput)*. |
||||
|
||||
### Swing interoperability |
||||
|
||||
Compose for Desktop uses Swing underneath and allows to access raw AWT events: |
||||
```kotlin |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
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.Alignment |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.awt.awtEventOrNull |
||||
import androidx.compose.ui.input.pointer.PointerEventType |
||||
import androidx.compose.ui.input.pointer.onPointerEvent |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class) |
||||
fun main() = singleWindowApplication { |
||||
var text by remember { mutableStateOf("") } |
||||
|
||||
Box( |
||||
Modifier |
||||
.fillMaxSize() |
||||
.onPointerEvent(PointerEventType.Press) { |
||||
text = it.awtEventOrNull?.locationOnScreen?.toString().orEmpty() |
||||
}, |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Text(text) |
||||
} |
||||
} |
||||
``` |
||||
*Note that onPointerEvent is experimental and can be changed in the future. For more stable API look at [Modifier.pointerInput](#listenining-raw-events-in-commonmain-via-modifierpointerinput)*. |
||||
|
||||
### Listenining raw events in commonMain via Modifier.pointerInput |
||||
In the snippets above we use `Modifier.onPointerEvent`, which is a helper function to subscribe to some type of pointer events. It is a shorter variant of `Modifier.pointerInput`. For now it is experimental, and desktop-only (you can't use it in commonMain code). If you need to subscribe to events in commonMain or you need stable API, you can use `Modifier.pointerInput`: |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.mutableStateListOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.input.pointer.PointerEventType |
||||
import androidx.compose.ui.input.pointer.pointerInput |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication { |
||||
val list = remember { mutableStateListOf<String>() } |
||||
|
||||
Column( |
||||
Modifier |
||||
.fillMaxSize() |
||||
.pointerInput(Unit) { |
||||
awaitPointerEventScope { |
||||
while (true) { |
||||
val event = awaitPointerEvent() |
||||
val position = event.changes.first().position |
||||
// on every relayout Compose will send synthetic Move event, |
||||
// so we skip it to avoid event spam |
||||
if (event.type != PointerEventType.Move) { |
||||
list.add(0, "${event.type} $position") |
||||
} |
||||
} |
||||
} |
||||
}, |
||||
) { |
||||
for (item in list.take(20)) { |
||||
Text(item) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
### New experimental onClick handlers (only for Desktop-JVM platform) |
||||
`Modifier.onClick` provides independent callbacks for clicks, double clicks, long clicks. It handles clicks originated only from pointer events and accessibility `click` event is not handled out of a box. |
||||
|
||||
Each `onClick` can be configured to target specific pointer events (using `matcher: PointerMatcher` and `keyboardModifiers: PointerKeyboardModifiers.() -> Boolean`). `matcher` can be specified to choose what mouse button should trigger a click. `keyboardModifiers` allows for filtering pointer events which have specified keyboardModifiers pressed. |
||||
|
||||
Multiple `onClick` modifiers can be chained to handle different clicks with different conditions (matcher and keyboard modifiers). |
||||
Unlike `clickable`, `onClick` doesn't have a default `Modifier.indication`, `Modifier.semantics`, and it doesn't trigger a click event when `Enter` pressed. These modifiers need to be added separately if necessary. |
||||
The most generic (with the least number of conditions) `onClick` handlers should be declared before other to ensure correct events propagation. |
||||
|
||||
```kotlin |
||||
import androidx.compose.animation.AnimatedContent |
||||
import androidx.compose.animation.ExperimentalAnimationApi |
||||
import androidx.compose.foundation.ExperimentalFoundationApi |
||||
import androidx.compose.foundation.LocalIndication |
||||
import androidx.compose.foundation.PointerMatcher |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.indication |
||||
import androidx.compose.foundation.interaction.MutableInteractionSource |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.onClick |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.* |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.input.pointer.PointerButton |
||||
import androidx.compose.ui.input.pointer.isAltPressed |
||||
import androidx.compose.ui.input.pointer.isShiftPressed |
||||
import androidx.compose.ui.text.style.TextAlign |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) |
||||
fun main() = singleWindowApplication { |
||||
Column { |
||||
var topBoxText by remember { mutableStateOf("Click me\nusing LMB or LMB + Shift") } |
||||
var topBoxCount by remember { mutableStateOf(0) } |
||||
// No indication on interaction |
||||
Box(modifier = Modifier.size(200.dp, 100.dp).background(Color.Blue) |
||||
// the most generic click handler (without extra conditions) should be the first one |
||||
.onClick { |
||||
// it will receive all LMB clicks except when Shift is pressed |
||||
println("Click with primary button") |
||||
topBoxText = "LMB ${topBoxCount++}" |
||||
}.onClick( |
||||
keyboardModifiers = { isShiftPressed } // accept clicks only when Shift pressed |
||||
) { |
||||
// it will receive all LMB clicks when Shift is pressed |
||||
println("Click with primary button and shift pressed") |
||||
topBoxCount++ |
||||
topBoxText = "LMB + Shift ${topBoxCount++}" |
||||
} |
||||
) { |
||||
AnimatedContent( |
||||
targetState = topBoxText, |
||||
modifier = Modifier.align(Alignment.Center) |
||||
) { |
||||
Text(text = it, textAlign = TextAlign.Center) |
||||
} |
||||
} |
||||
|
||||
var bottomBoxText by remember { mutableStateOf("Click me\nusing LMB or\nRMB + Alt") } |
||||
var bottomBoxCount by remember { mutableStateOf(0) } |
||||
val interactionSource = remember { MutableInteractionSource() } |
||||
// With indication on interaction |
||||
Box(modifier = Modifier.size(200.dp, 100.dp).background(Color.Yellow) |
||||
.onClick( |
||||
enabled = true, |
||||
interactionSource = interactionSource, |
||||
matcher = PointerMatcher.mouse(PointerButton.Secondary), // Right Mouse Button |
||||
keyboardModifiers = { isAltPressed }, // accept clicks only when Alt pressed |
||||
onLongClick = { // optional |
||||
bottomBoxText = "RMB Long Click + Alt ${bottomBoxCount++}" |
||||
println("Long Click with secondary button and Alt pressed") |
||||
}, |
||||
onDoubleClick = { // optional |
||||
bottomBoxText = "RMB Double Click + Alt ${bottomBoxCount++}" |
||||
println("Double Click with secondary button and Alt pressed") |
||||
}, |
||||
onClick = { |
||||
bottomBoxText = "RMB Click + Alt ${bottomBoxCount++}" |
||||
println("Click with secondary button and Alt pressed") |
||||
} |
||||
) |
||||
.onClick(interactionSource = interactionSource) { // use default parameters |
||||
bottomBoxText = "LMB Click ${bottomBoxCount++}" |
||||
println("Click with primary button (mouse left button)") |
||||
} |
||||
.indication(interactionSource, LocalIndication.current) |
||||
) { |
||||
AnimatedContent( |
||||
targetState = bottomBoxText, |
||||
modifier = Modifier.align(Alignment.Center) |
||||
) { |
||||
Text(text = it, textAlign = TextAlign.Center) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
### New experimental onDrag modifier (only for Desktop-JVM platform) |
||||
|
||||
`Modifier.onDrag` allows for configuration of the pointer that should trigger the drag (see `matcher: PointerMatcher`). |
||||
Many `onDrag` modifiers can be chained together. |
||||
|
||||
The example below also shows how to access the state of keyboard modifiers (via `LocalWindowInfo.current.keyboardModifier`) for cases when keyboard modifiers can alter the behaviour of the drag (for example: move an item if we perform a simple drag; or copy/paste an item if dragged with Ctrl pressed) |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.ExperimentalFoundationApi |
||||
import androidx.compose.foundation.PointerMatcher |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.gestures.onDrag |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.offset |
||||
import androidx.compose.foundation.layout.size |
||||
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.Alignment |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.geometry.Offset |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.input.pointer.PointerButton |
||||
import androidx.compose.ui.input.pointer.isCtrlPressed |
||||
import androidx.compose.ui.platform.LocalWindowInfo |
||||
import androidx.compose.ui.unit.IntOffset |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
@OptIn(ExperimentalFoundationApi::class) |
||||
fun main() = singleWindowApplication { |
||||
val windowInfo = LocalWindowInfo.current |
||||
|
||||
Column { |
||||
var topBoxOffset by remember { mutableStateOf(Offset(0f, 0f)) } |
||||
|
||||
Box(modifier = Modifier.offset { |
||||
IntOffset(topBoxOffset.x.toInt(), topBoxOffset.y.toInt()) |
||||
}.size(100.dp) |
||||
.background(Color.Green) |
||||
.onDrag { // all default: enabled = true, matcher = PointerMatcher.Primary (left mouse button) |
||||
topBoxOffset += it |
||||
} |
||||
) { |
||||
Text(text = "Drag with LMB", modifier = Modifier.align(Alignment.Center)) |
||||
} |
||||
|
||||
var bottomBoxOffset by remember { mutableStateOf(Offset(0f, 0f)) } |
||||
|
||||
Box(modifier = Modifier.offset { |
||||
IntOffset(bottomBoxOffset.x.toInt(), bottomBoxOffset.y.toInt()) |
||||
}.size(100.dp) |
||||
.background(Color.LightGray) |
||||
.onDrag( |
||||
enabled = true, |
||||
matcher = PointerMatcher.mouse(PointerButton.Secondary), // right mouse button |
||||
onDragStart = { |
||||
println("Gray Box: drag start") |
||||
}, |
||||
onDragEnd = { |
||||
println("Gray Box: drag end") |
||||
} |
||||
) { |
||||
val keyboardModifiers = windowInfo.keyboardModifiers |
||||
bottomBoxOffset += if (keyboardModifiers.isCtrlPressed) it * 2f else it |
||||
} |
||||
) { |
||||
Text(text = "Drag with RMB,\ntry with CTRL", modifier = Modifier.align(Alignment.Center)) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
There is also a non-modifier way to handle drags using `suspend fun PointerInputScope.detectDragGestures`: |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.ExperimentalFoundationApi |
||||
import androidx.compose.foundation.PointerMatcher |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.gestures.detectDragGestures |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.offset |
||||
import androidx.compose.foundation.layout.size |
||||
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.Alignment |
||||
import androidx.compose.ui.ExperimentalComposeUiApi |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.geometry.Offset |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.input.pointer.pointerInput |
||||
import androidx.compose.ui.unit.IntOffset |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
@OptIn(ExperimentalFoundationApi::class) |
||||
fun main() = singleWindowApplication { |
||||
var topBoxOffset by remember { mutableStateOf(Offset(0f, 0f)) } |
||||
|
||||
Box(modifier = Modifier.offset { |
||||
IntOffset(topBoxOffset.x.toInt(), topBoxOffset.y.toInt()) |
||||
}.size(100.dp) |
||||
.background(Color.Green) |
||||
.pointerInput(Unit) { |
||||
detectDragGestures( |
||||
matcher = PointerMatcher.Primary |
||||
) { |
||||
topBoxOffset += it |
||||
} |
||||
} |
||||
) { |
||||
Text(text = "Drag with LMB", modifier = Modifier.align(Alignment.Center)) |
||||
} |
||||
} |
||||
``` |
||||
|
Before Width: | Height: | Size: 110 KiB |
Before Width: | Height: | Size: 139 KiB |
Before Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 257 KiB |
@ -1,665 +0,0 @@
|
||||
# Native distributions & local execution |
||||
|
||||
## What is covered |
||||
|
||||
In this tutorial, we'll show you how to create native distributions (installers/packages) for all the supported systems. |
||||
We will also demonstrate how to run an application locally with the same settings as for distributions. |
||||
|
||||
## Available tools |
||||
|
||||
There are two tools available for packaging Compose applications: |
||||
|
||||
1. The Compose Multiplatform Gradle plugin which provides tasks for basic packaging, obfuscation and (macOS only) signing. |
||||
2. [Conveyor](https://www.hydraulic.software), which is a separate tool not made by JetBrains. |
||||
|
||||
This tutorial covers how to use the built-in tasks. Conveyor has [its own tutorial](https://conveyor.hydraulic.dev/latest/tutorial/hare/jvm). The choice of which to use boils down to features/ease of use vs price. Conveyor provides support for online updates, cross-building and [various other features](packaging-tools-comparison.md) but requires [a license](https://hydraulic.software/pricing.html) for non-open source projects. The packaging tasks come with the Compose Desktop Gradle plugin, but the resulting packages don't support online updates and will require a multi-platform CI setup to create packages for each OS. |
||||
|
||||
## Gradle plugin |
||||
|
||||
`org.jetbrains.compose` Gradle plugin simplifies the packaging of applications into native distributions using `jpackage` and running an application locally. |
||||
Distributable applications are self-contained, installable binaries which include all the Java runtime components they need, |
||||
without requiring an installed JDK on the target system. |
||||
|
||||
[Jlink](https://openjdk.java.net/jeps/282) will take care of bundling only the necessary Java Modules in |
||||
the distributable package to minimize package size, |
||||
but you must still configure the Gradle plugin to tell it which modules you need |
||||
(see the `Configuring included JDK modules` section). |
||||
|
||||
## Basic usage |
||||
|
||||
The basic unit of configuration in the plugin is an `application`. |
||||
An `application` defines a shared configuration for a set of final binaries. |
||||
In other words, an `application` in DSL allows you to pack a bunch of files, |
||||
together with a JDK distribution, into a set of compressed binary installers |
||||
in various formats (`.dmg`, `.deb`, `.msi`, `.exe`, etc). |
||||
|
||||
``` kotlin |
||||
import org.jetbrains.compose.compose |
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat |
||||
|
||||
plugins { |
||||
kotlin("jvm") |
||||
id("org.jetbrains.compose") |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(compose.desktop.currentOS) |
||||
} |
||||
|
||||
compose.desktop { |
||||
application { |
||||
mainClass = "example.MainKt" |
||||
|
||||
nativeDistributions { |
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
The plugin creates the following tasks: |
||||
* `package<FormatName>` (e.g. `packageDmg` or `packageMsi`) are used for packaging the app into the corresponding format. |
||||
Note, that there is no cross-compilation support available at the moment, |
||||
so the formats can only be built using the specific OS (e.g. to build `.dmg` you have to use macOS). |
||||
Tasks that are not compatible with the current OS are skipped by default. |
||||
* `packageDistributionForCurrentOS` is a [lifecycle](https://docs.gradle.org/current/userguide/more_about_tasks.html#sec:task_categories) task, |
||||
aggregating all package tasks for an application. |
||||
* `packageUberJarForCurrentOS` is used to create a single jar file, containing all dependencies for current OS. |
||||
The task is available starting from the M2 release. |
||||
The task expects `compose.desktop.currentOS` to be used as a `compile`/`implementation`/`runtime` dependency. |
||||
* `run` is used to run an app locally. You need to define a `mainClass` — an fq-name of a class, |
||||
containing the `main` function. |
||||
Note, that `run` starts a non-packaged JVM application with full runtime. |
||||
This is faster and easier to debug, than creating a compact binary image with minified runtime. |
||||
To run a final binary image, use `runDistributable` instead. |
||||
* `createDistributable` is used to create a prepackaged application image a final application image without creating an installer. |
||||
* `runDistributable` is used to run a prepackaged application image. |
||||
|
||||
Note, that the tasks are created only if the `application` block/property is used in a script. |
||||
|
||||
After a build, output binaries can be found in `${project.buildDir}/compose/binaries`. |
||||
|
||||
## Configuring included JDK modules |
||||
|
||||
The Gradle plugin uses [jlink](https://openjdk.java.net/jeps/282) to minimize a distributable size by |
||||
including only necessary JDK modules. |
||||
|
||||
At this time, the Gradle plugin does not automatically determine necessary JDK Modules. |
||||
Failure to provide the necessary modules will not cause compilation issues, |
||||
but will lead to `ClassNotFoundException` at runtime. |
||||
|
||||
If you encounter `ClassNotFoundException` when running a packaged application or |
||||
`runDistributable` task, you can include additional JDK modules using |
||||
`modules` DSL method (see example below). |
||||
|
||||
You can determine, which modules are necessary either by hand or by running |
||||
`suggestModules` task. `suggestModules` uses the [jdeps](https://docs.oracle.com/javase/9/tools/jdeps.htm) |
||||
static analysis tool to determine possible missing modules. Note, that the output of the tool |
||||
might be incomplete or list unnecessary modules. |
||||
|
||||
If a distributable size is not critical, you may simply include all runtime modules as an alternative |
||||
by using `includeAllModules` DSL property. |
||||
|
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
nativeDistributions { |
||||
modules("java.sql") |
||||
// alternatively: includeAllModules = true |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
## Available formats |
||||
|
||||
The following formats available for the supported operating systems: |
||||
* macOS — `.dmg` (`TargetFormat.Dmg`), `.pkg` (`TargetFormat.Pkg`) |
||||
* Windows — `.exe` (`TargetFormat.Exe`), `.msi` (`TargetFormat.Msi`) |
||||
* Linux — `.deb` (`TargetFormat.Deb`), `.rpm` (`TargetFormat.Rpm`) |
||||
|
||||
## Signing & notarization on macOS |
||||
|
||||
By default, Apple does not allow users to execute unsigned applications downloaded from the internet. Users attempting |
||||
to run such applications will be faced with an error like this: |
||||
|
||||
<img alt="" src="attrs-error.png" height="462" /> |
||||
|
||||
See [our tutorial](/tutorials/Signing_and_notarization_on_macOS/README.md) on how to sign and notarize your application. |
||||
|
||||
## Specifying package version |
||||
|
||||
You must specify a package version for native distribution packages. |
||||
|
||||
You can use the following DSL properties (in order of descending priority): |
||||
* `nativeDistributions.<os>.<packageFormat>PackageVersion` specifies a version for a single package format; |
||||
* `nativeDistributions.<os>.packageVersion` specifies a version for a single target OS; |
||||
* `nativeDistributions.packageVersion` specifies a version for all packages; |
||||
|
||||
For macOS you can also specify the build version using the following DSL properties (in order of descending priority): |
||||
* `nativeDistributions.macOS.<packageFormat>PackageBuildVersion` specifies a build version for a single package format; |
||||
* `nativeDistributions.macOS.packageBuildVersion` specifies a build version for all macOS packages; |
||||
|
||||
If the build version is not specified, the package version is used. |
||||
See [CFBundleShortVersionString](https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring) (package version) |
||||
and [CFBundleVersion](https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleversion) (build version) |
||||
for more information about versions on macOS. |
||||
|
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
nativeDistributions { |
||||
// a version for all distributables |
||||
packageVersion = "..." |
||||
|
||||
linux { |
||||
// a version for all Linux distributables |
||||
packageVersion = "..." |
||||
// a version only for the deb package |
||||
debPackageVersion = "..." |
||||
// a version only for the rpm package |
||||
rpmPackageVersion = "..." |
||||
} |
||||
macOS { |
||||
// a version for all macOS distributables |
||||
packageVersion = "..." |
||||
// a version only for the dmg package |
||||
dmgPackageVersion = "..." |
||||
// a version only for the pkg package |
||||
pkgPackageVersion = "..." |
||||
|
||||
// a build version for all macOS distributables |
||||
packageBuildVersion = "..." |
||||
// a build version only for the dmg package |
||||
dmgPackageBuildVersion = "..." |
||||
// a build version only for the pkg package |
||||
pkgPackageBuildVersion = "..." |
||||
} |
||||
windows { |
||||
// a version for all Windows distributables |
||||
packageVersion = "..." |
||||
// a version only for the msi package |
||||
msiPackageVersion = "..." |
||||
// a version only for the exe package |
||||
exePackageVersion = "..." |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
Versions must follow the rules: |
||||
* For `dmg` and `pkg`: |
||||
* The format is `MAJOR[.MINOR][.PATCH]`, where: |
||||
* `MAJOR` is an integer > 0; |
||||
* `MINOR` is an optional non-negative integer; |
||||
* `PATCH` is an optional non-negative integer; |
||||
* For `msi` and `exe`: |
||||
* The format is `MAJOR.MINOR.BUILD`, where: |
||||
* `MAJOR` is a non-negative integer with a maximum value of 255; |
||||
* `MINOR` is a non-negative integer with a maximum value of 255; |
||||
* `BUILD` is a non-negative integer with a maximum value of 65535; |
||||
* For `deb`: |
||||
* The format is `[EPOCH:]UPSTREAM_VERSION[-DEBIAN_REVISION]`, where: |
||||
* `EPOCH` is an optional non-negative integer; |
||||
* `UPSTREAM_VERSION` |
||||
* may contain only alphanumerics and the characters `.`, `+`, `-`, `~`; |
||||
* must start with a digit; |
||||
* `DEBIAN_REVISION` |
||||
* is optional; |
||||
* may contain only alphanumerics and the characters `.`, `+`, `~`; |
||||
* See [Debian documentation](https://www.debian.org/doc/debian-policy/ch-controlfields.html#version) for more details; |
||||
* For `rpm`: |
||||
* A version must not contain the `-` (dash) character. |
||||
|
||||
## Customizing JDK version |
||||
|
||||
The plugin uses `jpackage`, for which you should be using at least [JDK 17](https://openjdk.java.net/projects/jdk/17/). |
||||
Make sure you meet at least one of the following requirements: |
||||
* `JAVA_HOME` environment variable points to the compatible JDK version. |
||||
* `javaHome` is set via DSL: |
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
javaHome = System.getenv("JDK_17") |
||||
} |
||||
} |
||||
``` |
||||
|
||||
## Customizing output dir |
||||
|
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
nativeDistributions { |
||||
outputBaseDir.set(project.buildDir.resolve("customOutputDir")) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
## Customizing launcher |
||||
|
||||
The following properties are available for customizing the application startup: |
||||
* `mainClass` — a fully-qualified name of a class, containing the main method; |
||||
* `args` — arguments for the application's main method; |
||||
* `jvmArgs` — arguments for the application's JVM. |
||||
|
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
mainClass = "MainKt" |
||||
jvmArgs += listOf("-Xmx2G") |
||||
args += listOf("-customArgument") |
||||
} |
||||
} |
||||
``` |
||||
|
||||
## Customizing metadata |
||||
|
||||
The following properties are available in the `nativeDistributions` DSL block: |
||||
* `packageName` — application's name (default value: Gradle project's [name](https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html#getName--)); |
||||
* `packageVersion` — application's version (default value: Gradle project's [version](https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html#getVersion--)); |
||||
* `description` — application's description (default value: none); |
||||
* `copyright` — application's copyright (default value: none); |
||||
* `vendor` — application's vendor (default value: none); |
||||
* `licenseFile` — application's license (default value: none). |
||||
|
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
nativeDistributions { |
||||
packageName = "ExampleApp" |
||||
packageVersion = "0.1-SNAPSHOT" |
||||
description = "Compose Example App" |
||||
copyright = "© 2020 My Name. All rights reserved." |
||||
vendor = "Example vendor" |
||||
licenseFile.set(project.file("LICENSE.txt")) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
## Packaging resources |
||||
|
||||
There are multiple ways to package and load resources with Compose for Desktop. |
||||
|
||||
### JVM resource loading |
||||
|
||||
Since Compose for Desktop uses JVM platform, you can load resources from a jar file using `java.lang.Class` API. Put a file under `src/main/resources`, |
||||
then access it using [Class::getResource](https://docs.oracle.com/en/java/javase/15/docs/api/java.base/java/lang/Class.html#getResource(java.lang.String)) |
||||
or [Class::getResourceAsStream](https://docs.oracle.com/en/java/javase/15/docs/api/java.base/java/lang/Class.html#getResourceAsStream(java.lang.String)). |
||||
|
||||
### Adding files to packaged application |
||||
|
||||
In some cases putting and reading resources from jar files might be inconvenient. |
||||
Or you may want to include a target specific asset (e.g. a file, that is included only |
||||
into a macOS package, but not into a Windows one). |
||||
|
||||
Compose Gradle plugin can be configured to put additional |
||||
resource files under an installation directory. |
||||
|
||||
To do so, specify a root resource directory via DSL: |
||||
``` |
||||
compose.desktop { |
||||
application { |
||||
mainClass = "MainKt" |
||||
nativeDistributions { |
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) |
||||
packageVersion = "1.0.0" |
||||
|
||||
appResourcesRootDir.set(project.layout.projectDirectory.dir("resources")) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
In the example above a root resource directory is set to `<PROJECT_DIR>/resources`. |
||||
|
||||
Compose Gradle plugin will include all files under the following subdirectories: |
||||
1. Files from `<RESOURCES_ROOT_DIR>/common` will be included into all packages. |
||||
2. Files from `<RESOURCES_ROOT_DIR>/<OS_NAME>` will be included only into packages for |
||||
a specific OS. Possible values for `<OS_NAME>` are: `windows`, `macos`, `linux`. |
||||
3. Files from `<RESOURCES_ROOT_DIR>/<OS_NAME>-<ARCH_NAME>` will be included only into packages for |
||||
a specific combination of OS and CPU architecture. Possible values for `<ARCH_NAME>` are: `x64` and `arm64`. |
||||
For example, files from `<RESOURCES_ROOT_DIR>/macos-arm64` will be included only into packages built for Apple Silicon |
||||
Macs. |
||||
|
||||
Included resources can be accessed via `compose.application.resources.dir` system property: |
||||
``` |
||||
import java.io.File |
||||
|
||||
val resourcesDir = File(System.getProperty("compose.application.resources.dir")) |
||||
|
||||
fun main() { |
||||
println(resourcesDir.resolve("resource.txt").readText()) |
||||
} |
||||
``` |
||||
|
||||
## Customizing content |
||||
|
||||
The plugin can configure itself, when either `org.jetbrains.kotlin.jvm` or `org.jetbrains.kotlin.multiplatform` plugins |
||||
are used. |
||||
|
||||
* With `org.jetbrains.kotlin.jvm` the plugin includes content from the `main` [source set](https://docs.gradle.org/current/userguide/java_plugin.html#source_sets). |
||||
* With `org.jetbrains.kotlin.multiplatform` the plugin includes content a single [jvm target](https://kotlinlang.org/docs/reference/mpp-dsl-reference.html#targets). |
||||
The default configuration is disabled if multiple JVM targets are defined. In this case, the plugin should be configured |
||||
manually, or a single target should be specified (see below). |
||||
|
||||
If the default configuration is ambiguous or not sufficient, the plugin can be configured: |
||||
* Using a Gradle [source set](https://docs.gradle.org/current/userguide/java_plugin.html#source_sets) |
||||
``` kotlin |
||||
plugins { |
||||
kotlin("jvm") |
||||
id("org.jetbrains.compose") |
||||
} |
||||
|
||||
val customSourceSet = sourceSets.create("customSourceSet") |
||||
compose.desktop { |
||||
application { |
||||
from(customSourceSet) |
||||
} |
||||
} |
||||
``` |
||||
* Using a Kotlin [JVM target](https://kotlinlang.org/docs/reference/mpp-dsl-reference.html#targets): |
||||
``` kotlin |
||||
plugins { |
||||
kotlin("multiplatform") |
||||
id("org.jetbrains.compose") |
||||
} |
||||
|
||||
kotlin { |
||||
jvm("customJvmTarget") {} |
||||
} |
||||
|
||||
compose.desktop { |
||||
application { |
||||
from(kotlin.targets["customJvmTarget"]) |
||||
} |
||||
} |
||||
``` |
||||
* manually: |
||||
* `disableDefaultConfiguration` can be used to disable the default configuration; |
||||
* `dependsOn` can be used to add task dependencies to all plugin's tasks; |
||||
* `fromFiles` can be used to specify files to include; |
||||
* `mainJar` file property can be specified to point to a jar, containing a main class. |
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
disableDefaultConfiguration() |
||||
fromFiles(project.fileTree("libs/") { include("**/*.jar") }) |
||||
mainJar.set(project.file("main.jar")) |
||||
dependsOn("mainJarTask") |
||||
} |
||||
} |
||||
``` |
||||
|
||||
|
||||
## Platform-specific options |
||||
|
||||
Platform-specific options should be set using the corresponding DSL blocks: |
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
nativeDistributions { |
||||
macOS { |
||||
// macOS specific options |
||||
} |
||||
windows { |
||||
// Windows specific options |
||||
} |
||||
linux { |
||||
// Linux specific options |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
The following platform-specific options are available |
||||
(the usage of non-documented properties **is not recommended**): |
||||
* All platforms: |
||||
* `iconFile.set(File("PATH_TO_ICON"))` — a path to a platform-specific icon for the application. |
||||
(see the section `App icon` for details); |
||||
* `packageVersion = "1.0.0"` — a platform-specific package version |
||||
(see the section `Specifying package version` for details); |
||||
* `installationPath = "PATH_TO_INSTALL_DIR"` — an absolute or relative path to the default installation directory; |
||||
* On Windows `dirChooser = true` may be used to enable customizing the path during installation. |
||||
* Linux: |
||||
* `packageName = "custom-package-name"` overrides the default application name; |
||||
* `debMaintainer = "maintainer@example.com"` — an email of the deb package's maintainer; |
||||
* `menuGroup = "my-example-menu-group"` — a menu group for the application; |
||||
* `appRelease = "1"` — a release value for the rpm package, or a revision value for the deb package; |
||||
* `appCategory = "CATEGORY"` — a group value for the rpm package, or a section value for the deb package; |
||||
* `rpmLicenseType = "TYPE_OF_LICENSE"` — a type of license for the rpm package; |
||||
* `debPackageVersion = "DEB_VERSION"` — a deb-specific package version |
||||
(see the section `Specifying package version` for details); |
||||
* `rpmPackageVersion = "RPM_VERSION"` — a rpm-specific package version |
||||
(see the section `Specifying package version` for details); |
||||
* macOS: |
||||
* `bundleID` — a unique application identifier; |
||||
* May only contain alphanumeric characters (`A-Z`,`a-z`,`0-9`), hyphen (`-`) and period (`.`) characters; |
||||
* Use of a reverse DNS notation (e.g. `com.mycompany.myapp`) is recommended; |
||||
* `packageName` — a name of the application; |
||||
* `dockName` — a name of the application displayed in the menu bar, the "About <App>" menu item, in the dock, etc. |
||||
Equals to `packageName` by default. |
||||
* `minimumSystemVersion` — a minimum macOS version required to run the application. |
||||
See [LSMinimumSystemVersion](https://developer.apple.com/documentation/bundleresources/information_property_list/lsminimumsystemversion) for details; |
||||
* `signing`, `notarization`, `provisioningProfile`, and `runtimeProvisioningProfile` — see |
||||
[the corresponding tutorial](/tutorials/Signing_and_notarization_on_macOS/README.md) |
||||
for details; |
||||
* `appStore = true` — build and sign for the Apple App Store. Requires at least JDK 17; |
||||
* `appCategory` — category of the app for the Apple App Store. |
||||
Default value is `public.app-category.utilities` when building for the App Store, `Unknown` otherwise. |
||||
See [LSApplicationCategoryType](https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype) for a list of valid categories; |
||||
* `entitlementsFile.set(File("PATH_TO_ENTITLEMENTS"))` — a path to file containing entitlements to use when signing. |
||||
When a custom file is provided, make sure to add the entitlements that are required for Java apps. |
||||
See [sandbox.plist](https://github.com/openjdk/jdk/blob/master/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/resources/sandbox.plist) for the default file that is used when building for the App Store. It can be different depending on your JDK version. |
||||
If no file is provided the default entitlements provided by jpackage are used. |
||||
See [the corresponding tutorial](/tutorials/Signing_and_notarization_on_macOS/README.md#configuring-entitlements) |
||||
* `runtimeEntitlementsFile.set(File("PATH_TO_RUNTIME_ENTITLEMENTS"))` — a path to file containing entitlements to use when signing the JVM runtime. |
||||
When a custom file is provided, make sure to add the entitlements that are required for Java apps. |
||||
See [sandbox.plist](https://github.com/openjdk/jdk/blob/master/src/jdk.jpackage/macosx/classes/jdk/jpackage/internal/resources/sandbox.plist) for the default file that is used when building for the App Store. It can be different depending on your JDK version. |
||||
If no file is provided then `entitlementsFile` is used. If that was also not provided, the default entitlements provided by jpackage are used. |
||||
See [the corresponding tutorial](/tutorials/Signing_and_notarization_on_macOS/README.md#configuring-entitlements) |
||||
* `dmgPackageVersion = "DMG_VERSION"` — a dmg-specific package version |
||||
(see the section `Specifying package version` for details); |
||||
* `pkgPackageVersion = "PKG_VERSION"` — a pkg-specific package version |
||||
(see the section `Specifying package version` for details); |
||||
* `packageBuildVersion = "DMG_VERSION"` — a package build version |
||||
(see the section `Specifying package version` for details); |
||||
* `dmgPackageBuildVersion = "DMG_VERSION"` — a dmg-specific package build version |
||||
(see the section `Specifying package version` for details); |
||||
* `pkgPackageBuildVersion = "PKG_VERSION"` — a pkg-specific package build version |
||||
(see the section `Specifying package version` for details); |
||||
* `infoPlist` — see the section `Customizing Info.plist on macOS` for details; |
||||
* Windows: |
||||
* `console = true` adds a console launcher for the application; |
||||
* `dirChooser = true` enables customizing the installation path during installation; |
||||
* `perUserInstall = true` enables installing the application on a per-user basis |
||||
* `menuGroup = "start-menu-group"` adds the application to the specified Start menu group; |
||||
* `upgradeUuid = "UUID"` — a unique ID, which enables users to update an app via installer, |
||||
when an updated version is newer, than an installed version. The value must remain constant for a single application. |
||||
See [the link](https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html) |
||||
for details on generating a UUID. |
||||
* `msiPackageVersion = "MSI_VERSION"` — a msi-specific package version |
||||
(see the section `Specifying package version` for details); |
||||
* `exePackageVersion = "EXE_VERSION"` — a pkg-specific package version |
||||
(see the section `Specifying package version` for details); |
||||
|
||||
## App icon |
||||
|
||||
The app icon needs to be provided in OS-specific formats: |
||||
* `.icns` for macOS; |
||||
* `.ico` for Windows; |
||||
* `.png` for Linux. |
||||
|
||||
``` kotlin |
||||
compose.desktop { |
||||
application { |
||||
nativeDistributions { |
||||
macOS { |
||||
iconFile.set(project.file("icon.icns")) |
||||
} |
||||
windows { |
||||
iconFile.set(project.file("icon.ico")) |
||||
} |
||||
linux { |
||||
iconFile.set(project.file("icon.png")) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
## Customizing Info.plist on macOS |
||||
|
||||
We aim to support important platform-specific customization use-cases via declarative DSL. |
||||
However, the provided DSL is not enough sometimes. If you need to specify `Info.plist` |
||||
values, that are not modeled in the DSL, you can work around by specifying a piece |
||||
of raw XML, that will be appended to the application's `Info.plist`. |
||||
|
||||
### Example: deep linking into macOS apps |
||||
|
||||
1. Specify a custom URL scheme: |
||||
``` kotlin |
||||
// build.gradle.kts |
||||
compose.desktop { |
||||
application { |
||||
mainClass = "MainKt" |
||||
nativeDistributions { |
||||
targetFormats(TargetFormat.Dmg) |
||||
packageName = "Deep Linking Example App" |
||||
macOS { |
||||
bundleID = "org.jetbrains.compose.examples.deeplinking" |
||||
infoPlist { |
||||
extraKeysRawXml = macExtraPlistKeys |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
val macExtraPlistKeys: String |
||||
get() = """ |
||||
<key>CFBundleURLTypes</key> |
||||
<array> |
||||
<dict> |
||||
<key>CFBundleURLName</key> |
||||
<string>Example deep link</string> |
||||
<key>CFBundleURLSchemes</key> |
||||
<array> |
||||
<string>compose</string> |
||||
</array> |
||||
</dict> |
||||
</array> |
||||
""" |
||||
``` |
||||
|
||||
2. Use `java.awt.Desktop` to set up a URI handler: |
||||
``` kotlin |
||||
// src/main/main.kt |
||||
|
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
import java.awt.Desktop |
||||
|
||||
fun main() { |
||||
var text by mutableStateOf("Hello, World!") |
||||
|
||||
try { |
||||
Desktop.getDesktop().setOpenURIHandler { event -> |
||||
text = "Open URI: " + event.uri |
||||
} |
||||
} catch (e: UnsupportedOperationException) { |
||||
println("setOpenURIHandler is unsupported") |
||||
} |
||||
|
||||
singleWindowApplication { |
||||
MaterialTheme { |
||||
Text(text) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
3. Run `./gradlew runDistributable`. |
||||
4. Links like `compose://foo/bar` are now redirected from a browser to your application. |
||||
|
||||
## Minification & obfuscation |
||||
|
||||
Starting from 1.2 the Compose Gradle plugin supports [ProGuard](https://www.guardsquare.com/proguard) out-of-the-box. |
||||
ProGuard is a well known [open source](https://github.com/Guardsquare/proguard) tool for minification and obfuscation, |
||||
that is developed by [Guardsquare](https://www.guardsquare.com/). |
||||
|
||||
The Gradle plugin provides a *release* task for each corresponding *default* packaging task: |
||||
|
||||
Default task (w/o ProGuard) | Release task (w. ProGuard) | Description |
||||
-----------------------------------|------------------------------------------|-------------------------------------------------------------------------- |
||||
`createDistributable` | `createReleaseDistributable` | Creates an application image with bundled JDK & resources |
||||
`runDistributable` | `runReleaseDistributable` | Runs an application image with bundled JDK & resources |
||||
`run` | `runRelease` | Runs a non-packaged application `jar` using Gradle JDK |
||||
`package<FORMAT_NAME>` | `packageRelease<FORMAT_NAME>` | Packages an application image into a `<FORMAT_NAME>` file |
||||
`packageDistributionForCurrentOS` | `packageReleaseDistributionForCurrentOS` | Packages an application image into a format compatible with current OS |
||||
`packageUberJarForCurrentOS` | `packageReleaseUberJarForCurrentOS` | Packages an application image into an uber (fat) JAR |
||||
`notarize<FORMAT_NAME>` | `notarizeRelease<FORMAT_NAME>` | Uploads a `<FORMAT_NAME>` application image for notarization (macOS only) |
||||
`checkNotarizationStatus` | `checkReleaseNotarizationStatus` | Checks if notarization succeeded (macOS only) |
||||
|
||||
The default configuration adds a few ProGuard rules: |
||||
* an application image is minified, i.e. non-used classes are removed; |
||||
* `compose.desktop.application.mainClass` is used as an entry point; |
||||
* a few `keep` rules to avoid breaking Compose runtime. |
||||
|
||||
In many cases getting a minified Compose application will not require any additional configuration. |
||||
However, sometimes ProGuard might be unable to track certain usages in bytecode |
||||
(for example, this might happen if a class is used via reflection). |
||||
If you encounter an issue, which happens only after ProGuard processing, |
||||
you might want to add custom rules. |
||||
To do so, specify a configuration file via DSL: |
||||
``` |
||||
compose.desktop { |
||||
application { |
||||
buildTypes.release.proguard { |
||||
configurationFiles.from(project.file("compose-desktop.pro")) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
See the Guardsquare's [comprehensive manual](https://www.guardsquare.com/manual/configuration/usage) |
||||
on ProGuard's rules & configuration options. |
||||
|
||||
Obfuscation is disabled by default. To enable it, set the following property via Gradle DSL: |
||||
``` |
||||
compose.desktop { |
||||
application { |
||||
buildTypes.release.proguard { |
||||
obfuscate.set(true) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
ProGuard's optimizations are enabled by default. To disable them, set the following property via Gradle DSL: |
||||
``` |
||||
compose.desktop { |
||||
application { |
||||
buildTypes.release.proguard { |
||||
optimize.set(false) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
Joining to the uber JAR is disabled by default - ProGuard produces the corresponding JAR for every input JAR. |
||||
To enable it, set the following property via Gradle DSL: |
||||
``` |
||||
compose.desktop { |
||||
application { |
||||
buildTypes.release.proguard { |
||||
joinOutputJars.set(true) |
||||
} |
||||
} |
||||
} |
||||
``` |
Before Width: | Height: | Size: 164 KiB |
@ -1,504 +0,0 @@
|
||||
# Integration of Compose Multiplatform and Swing |
||||
|
||||
## What is covered |
||||
|
||||
In this tutorial, we'll show you how to make the `Swing/Compose` interop work in your application, what its limitations are, what you can achieve with it, in which cases you may use it and when you shouldn't do that. |
||||
|
||||
The main goals of the interoperability between Compose Multiplatform and Swing are |
||||
- make it easier and smoother to migrate Swing applications to to Compose |
||||
- allow to enhance Compose application with Swing components that don't have 'Compose' analogues |
||||
|
||||
In many cases it is more efficient to implement a missing Component in Compose (and contribute it to community) rather than using a Swing component in a Compose Application. |
||||
|
||||
## Swing interop use cases and limitations |
||||
|
||||
Before combining Compose Multiplatform and Swing, it's important to keep in mind that these two technologies have different approaches to the content rendering. Compose Multiplatform uses one heavyweight Swing component to render all its content and has logical rendering layers, while Swing operates on both heavyweight and lightweight components (`Component/JComponent`). For Swing logic Compose Multiplatform is just one more heavyweight component and it interacts with it the same way as with all other Swing components. |
||||
|
||||
The first use-case is addition of a Compose part into a Swing application. It could be done use `ComposePanel` Swing component to render the "Compose" part of the application. From Swing perspective it will be just another Swing component, that should be treated accordingly. Important point, that all Compose components will be rendered inside the `ComposePanel`, including popups, tooltips, context menus, etc. They will be positioned and resized inside the `ComposePanel`. So probably it would be better to replace them with a Swing based implementation. |
||||
|
||||
Below you can find several cases where the use of `ComposePanel` is justified: |
||||
- you want to embed animated objects or a whole panel of animated objects into your application (selection of emoticons, toolbar with animated reaction to events, etc.) |
||||
- you want to implement an interactive rendering area in your application, which is easier and more convenient to implement using Compose (for example, any type of graphics or infographics) |
||||
- you want to embed a complex rendering area (perhaps even animated) into your application - this is easier and more convenient to do using Compose |
||||
- you want to replace complex parts of the user interface of your Swing-based application - Compose has a convenient component layout system, and Compose offers a wide range of built-in components and options for quickly creating your own components |
||||
|
||||
If your case is somewhat similar to one of the above, then you should try to implement it using `ComposePanel`. |
||||
|
||||
The second use case is situation when you want to use some component, that exists in Swing and there is no analogue in Compose. And creating it from scratch is too expensive. In this case, you can use `SwingPanel`. A `SwingPanel` is a wrapper that controls the size, position and rendering of a Swing component that is placed on top of a Compose Multiplatform component, meaning the component inside a `SwingPanel` will always be on top of the Compose in depth. Anything that is misplaced and rests on the `SwingPanel` will be clipped by the Swing component placed there, so try to think about these situations, and if there is such a risk, then it is better to either redesign the UI accordingly, or stop using the `SwingPanel` and still try to implement the missing component, thereby contributing to the development of technology and making life easier for other developers. |
||||
|
||||
Below you can find several cases where the use of `SwingPanel` is justified: |
||||
- there are no popups, tooltips, context menus, etc. in your application. or they are not used inside your `SwingPanel` |
||||
- in your application, the `SwingPanel` will always be in the same position. This will reduce the risk of glitches and artifacts when changing the position of the Swing component (this condition is not mandatory and you need to test each such case separately) |
||||
|
||||
If your case is somewhat similar to one of the above, then you should try to implement it using `SwingPanel`. |
||||
|
||||
Since Compose Multiplatform and Swing can be combined in both directions, it is quite possible to place a `SwingPanel` into a `ComposePanel`, which in turn could be placed into another `SwingPanel`. In this case, you should be careful to minimize rendering glitches. At the end of this tutorial, you can find an example covering this case. |
||||
|
||||
## Using ComposePanel |
||||
|
||||
`ComposePanel` lets you create a UI using Compose Multiplatform in a Swing-based UI. To achieve this you need to create an instance of `ComposePanel`, add it to your Swing layout, and describe the composition inside `setContent`. |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material.Button |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.MutableState |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.awt.ComposePanel |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import java.awt.BorderLayout |
||||
import java.awt.Dimension |
||||
import javax.swing.JButton |
||||
import javax.swing.JFrame |
||||
import javax.swing.SwingUtilities |
||||
import javax.swing.WindowConstants |
||||
|
||||
val northClicks = mutableStateOf(0) |
||||
val westClicks = mutableStateOf(0) |
||||
val eastClicks = mutableStateOf(0) |
||||
|
||||
fun main() = SwingUtilities.invokeLater { |
||||
val window = JFrame() |
||||
|
||||
// creating ComposePanel |
||||
val composePanel = ComposePanel() |
||||
window.defaultCloseOperation = WindowConstants.EXIT_ON_CLOSE |
||||
window.title = "SwingComposeWindow" |
||||
|
||||
window.contentPane.add(actionButton("NORTH", action = { northClicks.value++ }), BorderLayout.NORTH) |
||||
window.contentPane.add(actionButton("WEST", action = { westClicks.value++ }), BorderLayout.WEST) |
||||
window.contentPane.add(actionButton("EAST", action = { eastClicks.value++ }), BorderLayout.EAST) |
||||
window.contentPane.add( |
||||
actionButton( |
||||
text = "SOUTH/REMOVE COMPOSE", |
||||
action = { |
||||
window.contentPane.remove(composePanel) |
||||
} |
||||
), |
||||
BorderLayout.SOUTH |
||||
) |
||||
|
||||
// addind ComposePanel on JFrame |
||||
window.contentPane.add(composePanel, BorderLayout.CENTER) |
||||
|
||||
// setting the content |
||||
composePanel.setContent { |
||||
ComposeContent() |
||||
} |
||||
|
||||
window.setSize(800, 600) |
||||
window.isVisible = true |
||||
} |
||||
|
||||
fun actionButton(text: String, action: () -> Unit): JButton { |
||||
val button = JButton(text) |
||||
button.toolTipText = "Tooltip for $text button." |
||||
button.preferredSize = Dimension(100, 100) |
||||
button.addActionListener { action() } |
||||
return button |
||||
} |
||||
|
||||
@Composable |
||||
fun ComposeContent() { |
||||
Box( |
||||
modifier = Modifier.fillMaxSize(), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Row { |
||||
Counter("West", westClicks) |
||||
Spacer(modifier = Modifier.width(25.dp)) |
||||
Counter("North", northClicks) |
||||
Spacer(modifier = Modifier.width(25.dp)) |
||||
Counter("East", eastClicks) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun Counter(text: String, counter: MutableState<Int>) { |
||||
Surface( |
||||
modifier = Modifier.size(130.dp, 130.dp), |
||||
color = Color(180, 180, 180), |
||||
shape = RoundedCornerShape(4.dp) |
||||
) { |
||||
Column { |
||||
Box( |
||||
modifier = Modifier.height(30.dp).fillMaxWidth(), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Text(text = "${text}Clicks: ${counter.value}") |
||||
} |
||||
Spacer(modifier = Modifier.height(25.dp)) |
||||
Box( |
||||
modifier = Modifier.fillMaxSize(), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Button(onClick = { counter.value++ }) { |
||||
Text(text = text, color = Color.White) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
<img alt="IntegrationWithSwing" src="screenshot.png" height="781" /> |
||||
|
||||
## Adding a Swing component to Compose Multiplatform composition using SwingPanel |
||||
|
||||
SwingPanel lets you create a UI using Swing in a Compose-based UI. To achieve this you need to create Swing `JComponent` in the `factory` parameter of `SwingPanel`. |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.material.Button |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.awt.SwingPanel |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
import java.awt.Component |
||||
import javax.swing.BoxLayout |
||||
import javax.swing.JButton |
||||
import javax.swing.JPanel |
||||
|
||||
fun main() = singleWindowApplication { |
||||
val counter = remember { mutableStateOf(0) } |
||||
|
||||
val inc: () -> Unit = { counter.value++ } |
||||
val dec: () -> Unit = { counter.value-- } |
||||
|
||||
Box( |
||||
modifier = Modifier.fillMaxWidth().height(60.dp).padding(top = 20.dp), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Text("Counter: ${counter.value}") |
||||
} |
||||
|
||||
Box( |
||||
modifier = Modifier.fillMaxSize(), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Column( |
||||
modifier = Modifier.padding(top = 80.dp, bottom = 20.dp) |
||||
) { |
||||
Button("1. Compose Button: increment", inc) |
||||
Spacer(modifier = Modifier.height(20.dp)) |
||||
|
||||
SwingPanel( |
||||
background = Color.White, |
||||
modifier = Modifier.size(270.dp, 90.dp), |
||||
factory = { |
||||
JPanel().apply { |
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS) |
||||
add(actionButton("1. Swing Button: decrement", dec)) |
||||
add(actionButton("2. Swing Button: decrement", dec)) |
||||
add(actionButton("3. Swing Button: decrement", dec)) |
||||
} |
||||
} |
||||
) |
||||
|
||||
Spacer(modifier = Modifier.height(20.dp)) |
||||
Button("2. Compose Button: increment", inc) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun Button(text: String = "", action: (() -> Unit)? = null) { |
||||
Button( |
||||
modifier = Modifier.size(270.dp, 30.dp), |
||||
onClick = { action?.invoke() } |
||||
) { |
||||
Text(text) |
||||
} |
||||
} |
||||
|
||||
fun actionButton( |
||||
text: String, |
||||
action: () -> Unit |
||||
): JButton { |
||||
val button = JButton(text) |
||||
button.alignmentX = Component.CENTER_ALIGNMENT |
||||
button.addActionListener { action() } |
||||
|
||||
return button |
||||
} |
||||
``` |
||||
|
||||
<img alt="IntegrationWithSwing" src="swing_panel.gif" height="523" /> |
||||
|
||||
## Updating Swing component when Сompose state changes |
||||
|
||||
Example below shows how to update a Swing component in a `SwingPanel` when the composable state changes. To do this, you need to provide an `update: (T) -> Unit` callback that is called when the composable state changes or after the layout is inflated. |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.material.Button |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.awt.SwingPanel |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.application |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.rememberWindowState |
||||
import java.awt.BorderLayout |
||||
import javax.swing.JPanel |
||||
import javax.swing.JLabel |
||||
|
||||
val swingLabel = JLabel() |
||||
|
||||
fun main() = application { |
||||
Window( |
||||
onCloseRequest = ::exitApplication, |
||||
state = rememberWindowState(width = 400.dp, height = 200.dp), |
||||
) { |
||||
val clicks = remember { mutableStateOf(0) } |
||||
Column( |
||||
modifier = Modifier.fillMaxSize().padding(20.dp), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
SwingPanel( |
||||
modifier = Modifier.fillMaxWidth().height(40.dp), |
||||
factory = { |
||||
JPanel().apply { |
||||
add(swingLabel, BorderLayout.CENTER) |
||||
} |
||||
}, |
||||
update = { |
||||
swingLabel.setText("SwingLabel Clicks: ${clicks.value}") |
||||
} |
||||
) |
||||
Spacer(modifier = Modifier.height(40.dp)) |
||||
Row ( |
||||
modifier = Modifier.height(40.dp), |
||||
verticalAlignment = Alignment.CenterVertically |
||||
) { |
||||
Button(onClick = { clicks.value++ }) { |
||||
Text(text = "Increment") |
||||
} |
||||
Spacer(modifier = Modifier.width(20.dp)) |
||||
Button(onClick = { clicks.value-- }) { |
||||
Text(text = "Decrement") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
<img alt="IntegrationWithSwing" src="swing_panel_update.gif" height="254" /> |
||||
|
||||
## Layouting with SwingPanel and ComposePanel |
||||
|
||||
Example below shows how Compose for Desktop and Swing can be combined in both directions i.e. adding a `SwingPanel` to a `ComposePanel` which is in turn added to another `SwingPanel`. |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.* |
||||
import androidx.compose.foundation.layout.* |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material.* |
||||
import androidx.compose.material.icons.Icons |
||||
import androidx.compose.material.icons.filled.* |
||||
import androidx.compose.runtime.* |
||||
import androidx.compose.ui.awt.* |
||||
import androidx.compose.ui.* |
||||
import androidx.compose.ui.draw.* |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.graphics.vector.ImageVector |
||||
import androidx.compose.ui.window.* |
||||
import androidx.compose.ui.unit.* |
||||
import java.awt.BorderLayout |
||||
import java.awt.Canvas |
||||
import java.awt.Dimension |
||||
import java.awt.Insets |
||||
import java.awt.event.* |
||||
import javax.swing.* |
||||
import javax.swing.border.EmptyBorder |
||||
|
||||
val Gray = java.awt.Color(64, 64, 64) |
||||
val DarkGray = java.awt.Color(32, 32, 32) |
||||
val LightGray = java.awt.Color(210, 210, 210) |
||||
|
||||
data class Item( |
||||
val text: String, |
||||
val icon: ImageVector, |
||||
val color: Color, |
||||
val state: MutableState<Boolean> = mutableStateOf(false) |
||||
) |
||||
val panelItemsList = listOf( |
||||
Item(text = "Person", icon = Icons.Filled.Person, color = Color(10, 232, 162)), |
||||
Item(text = "Favorite", icon = Icons.Filled.Favorite, color = Color(150, 232, 150)), |
||||
Item(text = "Search", icon = Icons.Filled.Search, color = Color(232, 10, 162)), |
||||
Item(text = "Settings", icon = Icons.Filled.Settings, color = Color(232, 162, 10)), |
||||
Item(text = "Close", icon = Icons.Filled.Close, color = Color(232, 100, 100)) |
||||
) |
||||
val itemSize = 50.dp |
||||
|
||||
fun java.awt.Color.toCompose(): Color { |
||||
return Color(red, green, blue) |
||||
} |
||||
|
||||
fun main() = application { |
||||
Window( |
||||
onCloseRequest = ::exitApplication, |
||||
state = rememberWindowState(width = 500.dp, height = 500.dp), |
||||
) { |
||||
Column( |
||||
modifier = Modifier.fillMaxSize().background(color = Gray.toCompose()).padding(20.dp), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Text(text = "Compose Area", color = LightGray.toCompose()) |
||||
Spacer(modifier = Modifier.height(40.dp)) |
||||
SwingPanel( |
||||
background = DarkGray.toCompose(), |
||||
modifier = Modifier.fillMaxSize(), |
||||
factory = { |
||||
ComposePanel().apply { |
||||
setContent { |
||||
Box { |
||||
SwingPanel( |
||||
modifier = Modifier.fillMaxSize(), |
||||
factory = { SwingComponent() } |
||||
) |
||||
Box ( |
||||
modifier = Modifier.align(Alignment.TopStart) |
||||
.padding(start = 20.dp, top = 80.dp) |
||||
.background(color = DarkGray.toCompose()) |
||||
) { |
||||
SwingPanel( |
||||
modifier = Modifier.size(itemSize * panelItemsList.size, itemSize), |
||||
factory = { |
||||
ComposePanel().apply { |
||||
setContent { |
||||
ComposeOverlay() |
||||
} |
||||
} |
||||
} |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun SwingComponent() : JPanel { |
||||
return JPanel().apply { |
||||
background = DarkGray |
||||
border = EmptyBorder(20, 20, 20, 20) |
||||
layout = BorderLayout() |
||||
add( |
||||
JLabel("TextArea Swing Component").apply { |
||||
foreground = LightGray |
||||
verticalAlignment = SwingConstants.NORTH |
||||
horizontalAlignment = SwingConstants.CENTER |
||||
preferredSize = Dimension(40, 160) |
||||
}, |
||||
BorderLayout.NORTH |
||||
) |
||||
add( |
||||
JTextArea().apply { |
||||
background = LightGray |
||||
lineWrap = true |
||||
wrapStyleWord = true |
||||
margin = Insets(10, 10, 10, 10) |
||||
text = "The five boxing wizards jump quickly. " + |
||||
"Crazy Fredrick bought many very exquisite opal jewels. " + |
||||
"Pack my box with five dozen liquor jugs.\n" + |
||||
"Cozy sphinx waves quart jug of bad milk. " + |
||||
"The jay, pig, fox, zebra and my wolves quack!" |
||||
}, |
||||
BorderLayout.CENTER |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun ComposeOverlay() { |
||||
Box( |
||||
modifier = Modifier.fillMaxSize(). |
||||
background(color = DarkGray.toCompose()), |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Row( |
||||
modifier = Modifier.background( |
||||
shape = RoundedCornerShape(4.dp), |
||||
color = Color.DarkGray.copy(alpha = 0.5f) |
||||
) |
||||
) { |
||||
for (item in panelItemsList) { |
||||
SelectableItem( |
||||
text = item.text, |
||||
icon = item.icon, |
||||
color = item.color, |
||||
selected = item.state |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun SelectableItem( |
||||
text: String, |
||||
icon: ImageVector, |
||||
color: Color, |
||||
selected: MutableState<Boolean> |
||||
) { |
||||
Box( |
||||
modifier = Modifier.size(itemSize) |
||||
.clickable { selected.value = !selected.value }, |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
Column( |
||||
modifier = Modifier.alpha(if (selected.value) 1.0f else 0.5f), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Icon(modifier = Modifier.size(32.dp), imageVector = icon, contentDescription = null, tint = color) |
||||
Text(text = text, color = Color.White, fontSize = 10.sp) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
<img alt="IntegrationWithSwing" src="swing_compose_layouting.gif" height="600" /> |
Before Width: | Height: | Size: 147 KiB |
Before Width: | Height: | Size: 5.2 MiB |
Before Width: | Height: | Size: 10 MiB |
Before Width: | Height: | Size: 150 KiB |
@ -1,656 +0,0 @@
|
||||
# Top level windows management |
||||
|
||||
## What is covered |
||||
|
||||
In this tutorial, we will show you how to work with windows using Compose for Desktop. |
||||
|
||||
We represent the window state in a shape suitable for Compose-style state manipulations and automatically map it to the operating system window state. |
||||
|
||||
Top-level windows can be conditionally created in other composable functions and their window manager state can also be manipulated using states produced by the `rememberWindowState()` function. |
||||
|
||||
## Open and close windows |
||||
|
||||
The main function for creating windows is `Window`. This function should be used in a Composable scope. The easiest way to create a Composable scope is to use the `application` function: |
||||
|
||||
```kotlin |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.application |
||||
|
||||
fun main() = application { |
||||
Window(onCloseRequest = ::exitApplication) { |
||||
// Content |
||||
} |
||||
} |
||||
``` |
||||
|
||||
`Window` is a Composable function, meaning 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.window.Window |
||||
import androidx.compose.ui.window.application |
||||
|
||||
fun main() = application { |
||||
var fileName by remember { mutableStateOf("Untitled") } |
||||
|
||||
Window(onCloseRequest = ::exitApplication, title = "$fileName - Editor") { |
||||
Button(onClick = { fileName = "note.txt" }) { |
||||
Text("Save") |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
<img alt="Window properties" src="window_properties.gif" height="260" /> |
||||
|
||||
## Open and close windows (conditionally) |
||||
|
||||
You can also close/open windows using a simple `if` statement. |
||||
|
||||
When the `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.window.Window |
||||
import androidx.compose.ui.window.application |
||||
import kotlinx.coroutines.delay |
||||
|
||||
fun main() = application { |
||||
var isPerformingTask by remember { mutableStateOf(true) } |
||||
|
||||
LaunchedEffect(Unit) { |
||||
delay(2000) // Do some heavy lifting |
||||
isPerformingTask = false |
||||
} |
||||
|
||||
if (isPerformingTask) { |
||||
Window(onCloseRequest = ::exitApplication) { |
||||
Text("Performing some tasks. Please wait!") |
||||
} |
||||
} else { |
||||
Window(onCloseRequest = ::exitApplication) { |
||||
Text("Hello, World!") |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
<img alt="Window splash" src="window_splash.gif" height="354" /> |
||||
|
||||
If the window requires some custom logic on close (for example, to show a dialog), you can override the close action using `onCloseRequest`. |
||||
|
||||
Instead of the imperative approach to closing the window (`window.close()`) we use a declarative approach - closing the window in response to a change of 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.window.DialogWindow |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.application |
||||
|
||||
fun main() = application { |
||||
var isOpen by remember { mutableStateOf(true) } |
||||
var isAskingToClose by remember { mutableStateOf(false) } |
||||
|
||||
if (isOpen) { |
||||
Window( |
||||
onCloseRequest = { isAskingToClose = true } |
||||
) { |
||||
if (isAskingToClose) { |
||||
DialogWindow( |
||||
onCloseRequest = { isAskingToClose = false }, |
||||
title = "Close the document without saving?", |
||||
) { |
||||
Button( |
||||
onClick = { isOpen = false } |
||||
) { |
||||
Text("Yes") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
<img alt="Ask to close" src="ask_to_close.gif" height="309" /> |
||||
|
||||
## Hide the window into the tray |
||||
|
||||
If you don't need to close the window and just need to hide it (for example to the tray), you can change the `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.geometry.Size |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.graphics.drawscope.DrawScope |
||||
import androidx.compose.ui.graphics.painter.Painter |
||||
import androidx.compose.ui.window.Tray |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.application |
||||
import kotlinx.coroutines.delay |
||||
|
||||
fun main() = application { |
||||
var isVisible by remember { mutableStateOf(true) } |
||||
|
||||
Window( |
||||
onCloseRequest = { isVisible = false }, |
||||
visible = isVisible, |
||||
title = "Counter", |
||||
) { |
||||
var counter by remember { mutableStateOf(0) } |
||||
LaunchedEffect(Unit) { |
||||
while (true) { |
||||
counter++ |
||||
delay(1000) |
||||
} |
||||
} |
||||
Text(counter.toString()) |
||||
} |
||||
|
||||
if (!isVisible) { |
||||
Tray( |
||||
TrayIcon, |
||||
tooltip = "Counter", |
||||
onAction = { isVisible = true }, |
||||
menu = { |
||||
Item("Exit", onClick = ::exitApplication) |
||||
}, |
||||
) |
||||
} |
||||
} |
||||
|
||||
object TrayIcon : Painter() { |
||||
override val intrinsicSize = Size(256f, 256f) |
||||
|
||||
override fun DrawScope.onDraw() { |
||||
drawOval(Color(0xFFFFA500)) |
||||
} |
||||
} |
||||
``` |
||||
<img alt="Hide instead of closing" src="hide_instead_of_close.gif" height="308" /> |
||||
|
||||
## Open and close multiple windows |
||||
|
||||
If an application has multiple windows, then it is better to put its state into a separate class and open/close window in response to `mutableStateListOf` changes (see [notepad example](https://github.com/JetBrains/compose-multiplatform/tree/master/examples/notepad) for more complex use cases): |
||||
```kotlin |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.key |
||||
import androidx.compose.runtime.mutableStateListOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.window.ApplicationScope |
||||
import androidx.compose.ui.window.MenuBar |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.application |
||||
|
||||
fun main() = application { |
||||
val applicationState = remember { MyApplicationState() } |
||||
|
||||
for (window in applicationState.windows) { |
||||
key(window) { |
||||
MyWindow(window) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun ApplicationScope.MyWindow( |
||||
state: MyWindowState |
||||
) = Window(onCloseRequest = state::close, title = state.title) { |
||||
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) |
||||
} |
||||
``` |
||||
<img alt="Multiple windows" src="multiple_windows.gif" height="280" /> |
||||
|
||||
## Function `singleWindowApplication` |
||||
|
||||
There is a simplified function for creating a single window application: |
||||
```kotlin |
||||
import androidx.compose.ui.window.singleWindowApplication |
||||
|
||||
fun main() = singleWindowApplication { |
||||
// Content |
||||
} |
||||
``` |
||||
Use it if: |
||||
- your application has only one window |
||||
- you don't need custom closing logic |
||||
- you don't need to change the window parameters after it is already created |
||||
|
||||
## Adaptive window size |
||||
|
||||
Sometimes we want to show some content as a whole without knowing in advance what exactly will be shown, meaning that we don’t know the optimal window dimensions for it. By setting one or both dimensions of your window’s WindowSize to Dp.Unspecified, Compose for Desktop will automatically adjust the initial size of your window in that dimension to accommodate its content: |
||||
```kotlin |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.Dp |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.application |
||||
import androidx.compose.ui.window.rememberWindowState |
||||
|
||||
fun main() = application { |
||||
Window( |
||||
onCloseRequest = ::exitApplication, |
||||
state = rememberWindowState(width = Dp.Unspecified, height = Dp.Unspecified), |
||||
title = "Adaptive", |
||||
resizable = false |
||||
) { |
||||
Column(Modifier.background(Color(0xFFEEEEEE))) { |
||||
Row { |
||||
Text("label 1", Modifier.size(100.dp, 100.dp).padding(10.dp).background(Color.White)) |
||||
Text("label 2", Modifier.size(150.dp, 200.dp).padding(5.dp).background(Color.White)) |
||||
Text("label 3", Modifier.size(200.dp, 300.dp).padding(25.dp).background(Color.White)) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
<img alt="Adaptive window size" src="adaptive.png" height="327" /> |
||||
|
||||
## Changing the state (maximized, minimized, fullscreen, size, position) of the window. |
||||
|
||||
Some states of the native window are moved into a separate API class, `WindowState`. You can change its properties in callbacks or observe it in Composable's. |
||||
When some state is changed (window size or position), Composable function will be automatically recomposed. |
||||
|
||||
```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.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.WindowPlacement |
||||
import androidx.compose.ui.window.WindowPosition |
||||
import androidx.compose.ui.window.application |
||||
import androidx.compose.ui.window.rememberWindowState |
||||
|
||||
fun main() = application { |
||||
val state = rememberWindowState(placement = WindowPlacement.Maximized) |
||||
|
||||
Window(onCloseRequest = ::exitApplication, state) { |
||||
Column { |
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
Checkbox( |
||||
state.placement == WindowPlacement.Fullscreen, |
||||
{ |
||||
state.placement = if (it) { |
||||
WindowPlacement.Fullscreen |
||||
} else { |
||||
WindowPlacement.Floating |
||||
} |
||||
} |
||||
) |
||||
Text("isFullscreen") |
||||
} |
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
Checkbox( |
||||
state.placement == WindowPlacement.Maximized, |
||||
{ |
||||
state.placement = if (it) { |
||||
WindowPlacement.Maximized |
||||
} else { |
||||
WindowPlacement.Floating |
||||
} |
||||
} |
||||
) |
||||
Text("isMaximized") |
||||
} |
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
Checkbox(state.isMinimized, { state.isMinimized = !state.isMinimized }) |
||||
Text("isMinimized") |
||||
} |
||||
|
||||
Text( |
||||
"Position ${state.position}", |
||||
Modifier.clickable { |
||||
val position = state.position |
||||
if (position is WindowPosition.Absolute) { |
||||
state.position = 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) |
||||
} |
||||
) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
<img alt="Changing the state" src="state.gif" height="231" /> |
||||
|
||||
## Listening the state of the window |
||||
Reading the state in composition is useful when you need to update UI, but there are cases when you need to react to the state changes and send a value to another non-composable level of your application (write it to the database, for example): |
||||
|
||||
```kotlin |
||||
import androidx.compose.runtime.LaunchedEffect |
||||
import androidx.compose.runtime.snapshotFlow |
||||
import androidx.compose.ui.unit.DpSize |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.WindowPosition |
||||
import androidx.compose.ui.window.application |
||||
import androidx.compose.ui.window.rememberWindowState |
||||
import kotlinx.coroutines.flow.filter |
||||
import kotlinx.coroutines.flow.launchIn |
||||
import kotlinx.coroutines.flow.onEach |
||||
|
||||
fun main() = application { |
||||
val state = rememberWindowState() |
||||
|
||||
Window(onCloseRequest = ::exitApplication, state) { |
||||
// Content |
||||
|
||||
LaunchedEffect(state) { |
||||
snapshotFlow { state.size } |
||||
.onEach(::onWindowResize) |
||||
.launchIn(this) |
||||
|
||||
snapshotFlow { state.position } |
||||
.filter { it.isSpecified } |
||||
.onEach(::onWindowRelocate) |
||||
.launchIn(this) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun onWindowResize(size: DpSize) { |
||||
println("onWindowResize $size") |
||||
} |
||||
|
||||
private fun onWindowRelocate(position: WindowPosition) { |
||||
println("onWindowRelocate $position") |
||||
} |
||||
``` |
||||
|
||||
## Dialogs |
||||
There are two types of window – modal and regular. Below are the functions for creating each: |
||||
|
||||
1. `Window` – regular window type. |
||||
2. `DialogWindow` – modal window type. This type locks its parent window until the user is finished 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.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.window.DialogWindow |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.WindowPosition |
||||
import androidx.compose.ui.window.application |
||||
import androidx.compose.ui.window.rememberDialogState |
||||
|
||||
fun main() = application { |
||||
Window( |
||||
onCloseRequest = ::exitApplication, |
||||
) { |
||||
var isDialogOpen by remember { mutableStateOf(false) } |
||||
|
||||
Button(onClick = { isDialogOpen = true }) { |
||||
Text(text = "Open dialog") |
||||
} |
||||
|
||||
if (isDialogOpen) { |
||||
DialogWindow( |
||||
onCloseRequest = { isDialogOpen = false }, |
||||
state = rememberDialogState(position = WindowPosition(Alignment.Center)) |
||||
) { |
||||
// 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.ui.awt.ComposeWindow |
||||
import java.awt.Dimension |
||||
import javax.swing.JFrame |
||||
import javax.swing.SwingUtilities |
||||
|
||||
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.window.singleWindowApplication |
||||
import java.awt.datatransfer.DataFlavor |
||||
import java.awt.dnd.DnDConstants |
||||
import java.awt.dnd.DropTarget |
||||
import java.awt.dnd.DropTargetAdapter |
||||
import java.awt.dnd.DropTargetDropEvent |
||||
|
||||
fun main() = singleWindowApplication { |
||||
LaunchedEffect(Unit) { |
||||
window.dropTarget = DropTarget().apply { |
||||
addDropTargetListener(object : DropTargetAdapter() { |
||||
override fun drop(event: DropTargetDropEvent) { |
||||
event.acceptDrop(DnDConstants.ACTION_COPY); |
||||
val fileName = event.transferable.getTransferData(DataFlavor.javaFileListFlavor) |
||||
println(fileName) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
If you need a dialog that is implemented in Swing, you can wrap it into a 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.window.AwtWindow |
||||
import androidx.compose.ui.window.application |
||||
import java.awt.FileDialog |
||||
import java.awt.Frame |
||||
|
||||
fun main() = application { |
||||
var isOpen by remember { mutableStateOf(true) } |
||||
|
||||
if (isOpen) { |
||||
FileDialog( |
||||
onCloseRequest = { |
||||
isOpen = false |
||||
println("Result $it") |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
@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 |
||||
) |
||||
``` |
||||
|
||||
## Draggable window area |
||||
If you window is undecorated and you want to add a custom draggable titlebar to it (or make the whole window draggable), you can use `WindowDraggableArea`: |
||||
```kotlin |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.window.WindowDraggableArea |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.application |
||||
|
||||
fun main() = application { |
||||
Window(onCloseRequest = ::exitApplication, undecorated = true) { |
||||
WindowDraggableArea { |
||||
Box(Modifier.fillMaxWidth().height(48.dp).background(Color.DarkGray)) |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
Note that `WindowDraggableArea` can be used only inside `singleWindowApplication`, `Window` and `DialogWindow`. If you need to use it in another Composable function, pass `WindowScope` as a receiver there: |
||||
```kotlin |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.window.WindowDraggableArea |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.WindowScope |
||||
import androidx.compose.ui.window.application |
||||
|
||||
fun main() = application { |
||||
Window(onCloseRequest = ::exitApplication, undecorated = true) { |
||||
AppWindowTitleBar() |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun WindowScope.AppWindowTitleBar() = WindowDraggableArea { |
||||
Box(Modifier.fillMaxWidth().height(48.dp).background(Color.DarkGray)) |
||||
} |
||||
``` |
||||
<img alt="Draggable area" src="draggable_area.gif" height="239" /> |
||||
|
||||
## Transparent windows (e.g. allows to make windows of a custom form) |
||||
To create a transparent window it is enough to pass two parameners to the Window function: `transparent=true` and `undecorate=true` (it is not possible to decorate a transparent Window). Common scenario is to combine transparent window with a Surface of a custom form. Below is an example of a round-cornered Window. |
||||
|
||||
```kotlin |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material.Surface |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.draw.shadow |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.window.Window |
||||
import androidx.compose.ui.window.application |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.* |
||||
|
||||
fun main() = application { |
||||
var isOpen by remember { mutableStateOf(true) } |
||||
if (isOpen) { |
||||
Window( |
||||
onCloseRequest = { isOpen = false }, |
||||
title = "Transparent Window Example", |
||||
transparent = true, |
||||
undecorated = true, //transparent window must be undecorated |
||||
) { |
||||
Surface( |
||||
modifier = Modifier.fillMaxSize().padding(5.dp).shadow(3.dp, RoundedCornerShape(20.dp)), |
||||
color = Color(55, 55, 55), |
||||
shape = RoundedCornerShape(20.dp) //window has round corners now |
||||
) { |
||||
Text("Hello World!", color = Color.White) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
_**Important note:** Window transparency is implemented based on JDK implementation, that contains **known issue on Linux** in case of moving a Window between two monitors with different density. So when you move an App, the Window stops being transparent. And it seems nothing can be done with this situation on Compose side. |
||||
[An issue about it](https://github.com/JetBrains/compose-multiplatform/issues/1339)_ |
Before Width: | Height: | Size: 8.3 KiB |
Before Width: | Height: | Size: 538 KiB |
Before Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 904 KiB |
Before Width: | Height: | Size: 2.8 MiB |
Before Width: | Height: | Size: 3.2 MiB |
Before Width: | Height: | Size: 308 KiB |
Before Width: | Height: | Size: 568 KiB |