Compose Multiplatform da JetBrains levou o modelo declarativo do Jetpack Compose para alem do Android. Com ele, voce cria aplicativos desktop nativos para Windows, macOS e Linux usando Kotlin e os mesmos conceitos de UI que ja domina no Android. Neste guia, vamos cobrir tudo que voce precisa para comecar a desenvolver aplicativos desktop com Compose Multiplatform.

O que e Compose Multiplatform?

Compose Multiplatform e um framework de UI declarativo da JetBrains que estende o Jetpack Compose do Google para rodar em multiplas plataformas: Android, iOS, Desktop (JVM) e Web. No contexto desktop, a aplicacao roda sobre a JVM e renderiza usando Skia, a mesma engine grafica usada pelo Chrome e Flutter.

A proposta e clara: escrever UI uma vez em Kotlin e rodar em todas as plataformas. Embora cada plataforma tenha particularidades, a maior parte do codigo de interface e logica pode ser compartilhada.

Configuracao do projeto

A maneira mais rapida de iniciar e usar o Kotlin Multiplatform Wizard da JetBrains ou o template oficial. Vamos configurar manualmente para entender cada parte.

Estrutura do projeto

meu-app-desktop/
    build.gradle.kts
    settings.gradle.kts
    gradle.properties
    src/
        main/
            kotlin/
                Main.kt
            resources/
                icone.png

build.gradle.kts

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

plugins {
    kotlin("jvm") version "2.1.0"
    id("org.jetbrains.compose") version "1.7.3"
    id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
}

repositories {
    mavenCentral()
    maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}

dependencies {
    implementation(compose.desktop.currentOs)
    implementation(compose.material3)
}

compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "MeuApp"
            packageVersion = "1.0.0"
        }
    }
}

Ponto de entrada

import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "Meu App Desktop"
    ) {
        App()
    }
}

@Composable
fun App() {
    MaterialTheme {
        var texto by remember { mutableStateOf("") }
        var itens by remember { mutableStateOf(listOf<String>()) }

        Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
            Text(
                text = "Gerenciador de Tarefas",
                style = MaterialTheme.typography.headlineMedium
            )

            Spacer(modifier = Modifier.height(16.dp))

            Row(verticalAlignment = Alignment.CenterVertically) {
                OutlinedTextField(
                    value = texto,
                    onValueChange = { texto = it },
                    label = { Text("Nova tarefa") },
                    modifier = Modifier.weight(1f)
                )
                Spacer(modifier = Modifier.width(8.dp))
                Button(onClick = {
                    if (texto.isNotBlank()) {
                        itens = itens + texto
                        texto = ""
                    }
                }) {
                    Text("Adicionar")
                }
            }

            Spacer(modifier = Modifier.height(16.dp))

            LazyColumn {
                items(itens.size) { indice ->
                    Card(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(vertical = 4.dp)
                    ) {
                        Row(
                            modifier = Modifier.padding(16.dp),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Text(
                                text = itens[indice],
                                modifier = Modifier.weight(1f)
                            )
                            IconButton(onClick = {
                                itens = itens.filterIndexed { i, _ -> i != indice }
                            }) {
                                Icon(Icons.Default.Delete, "Remover")
                            }
                        }
                    }
                }
            }
        }
    }
}

Para executar, use ./gradlew run no terminal.

Gerenciamento de janelas

Compose Multiplatform oferece controle completo sobre janelas, incluindo tamanho, posicao, icone e comportamento.

Multiplas janelas

fun main() = application {
    var mostrarConfiguracoes by remember { mutableStateOf(false) }

    Window(
        onCloseRequest = ::exitApplication,
        title = "Janela Principal",
        state = rememberWindowState(
            width = 800.dp,
            height = 600.dp
        )
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text("Janela Principal")
            Button(onClick = { mostrarConfiguracoes = true }) {
                Text("Abrir Configuracoes")
            }
        }
    }

    if (mostrarConfiguracoes) {
        Window(
            onCloseRequest = { mostrarConfiguracoes = false },
            title = "Configuracoes",
            state = rememberWindowState(
                width = 400.dp,
                height = 300.dp
            )
        ) {
            Text("Painel de Configuracoes", modifier = Modifier.padding(16.dp))
        }
    }
}

Dialogo personalizado

@Composable
fun DialogoConfirmacao(
    titulo: String,
    mensagem: String,
    onConfirmar: () -> Unit,
    onCancelar: () -> Unit
) {
    Dialog(
        onCloseRequest = onCancelar,
        title = titulo,
        state = rememberDialogState(width = 350.dp, height = 200.dp)
    ) {
        Column(
            modifier = Modifier.fillMaxSize().padding(16.dp),
            verticalArrangement = Arrangement.SpaceBetween
        ) {
            Text(mensagem)
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.End
            ) {
                TextButton(onClick = onCancelar) { Text("Cancelar") }
                Spacer(modifier = Modifier.width(8.dp))
                Button(onClick = onConfirmar) { Text("Confirmar") }
            }
        }
    }
}

Aplicacoes desktop geralmente possuem barra de menus. Compose Multiplatform suporta menus nativos da plataforma:

Window(
    onCloseRequest = ::exitApplication,
    title = "Editor de Texto"
) {
    MenuBar {
        Menu("Arquivo") {
            Item("Novo", shortcut = KeyShortcut(Key.N, meta = true)) {
                println("Novo arquivo")
            }
            Item("Abrir", shortcut = KeyShortcut(Key.O, meta = true)) {
                println("Abrir arquivo")
            }
            Item("Salvar", shortcut = KeyShortcut(Key.S, meta = true)) {
                println("Salvar arquivo")
            }
            Separator()
            Item("Sair") { exitApplication() }
        }
        Menu("Editar") {
            Item("Desfazer", shortcut = KeyShortcut(Key.Z, meta = true)) {}
            Item("Refazer", shortcut = KeyShortcut(Key.Z, meta = true, shift = true)) {}
        }
    }

    // Conteudo da janela
    TextField(
        value = texto,
        onValueChange = { texto = it },
        modifier = Modifier.fillMaxSize().padding(16.dp)
    )
}

System Tray

Voce pode adicionar um icone na bandeja do sistema para que o app continue rodando em segundo plano:

fun main() = application {
    var visivel by remember { mutableStateOf(true) }

    Tray(
        icon = painterResource("icone.png"),
        tooltip = "Meu App",
        menu = {
            Item("Mostrar") { visivel = true }
            Item("Sair") { exitApplication() }
        }
    )

    if (visivel) {
        Window(
            onCloseRequest = { visivel = false },
            title = "Meu App"
        ) {
            Text("Feche a janela - o app continua na bandeja")
        }
    }
}

Acesso a arquivos do sistema

Diferente de aplicacoes mobile, apps desktop frequentemente precisam ler e escrever arquivos. Compose Multiplatform oferece dialogos de arquivo nativos:

@Composable
fun SeletorDeArquivo() {
    var caminhoArquivo by remember { mutableStateOf("Nenhum arquivo selecionado") }
    var conteudo by remember { mutableStateOf("") }

    Column(modifier = Modifier.padding(16.dp)) {
        Button(onClick = {
            val dialogo = FileDialog(ComposeWindow(), "Selecionar arquivo", FileDialog.LOAD)
            dialogo.filenameFilter = FilenameFilter { _, nome -> nome.endsWith(".txt") }
            dialogo.isVisible = true
            val arquivo = dialogo.file
            val diretorio = dialogo.directory
            if (arquivo != null && diretorio != null) {
                val path = File(diretorio, arquivo)
                caminhoArquivo = path.absolutePath
                conteudo = path.readText()
            }
        }) {
            Text("Abrir Arquivo")
        }

        Spacer(modifier = Modifier.height(8.dp))
        Text("Arquivo: $caminhoArquivo")
        Spacer(modifier = Modifier.height(8.dp))
        Text(conteudo)
    }
}

Atalhos de teclado

Aplicacoes desktop dependem fortemente de atalhos de teclado para produtividade:

@Composable
fun EditorComAtalhos() {
    var texto by remember { mutableStateOf("") }
    var salvo by remember { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        // Atalhos podem ser tratados via KeyEvent
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .onPreviewKeyEvent { evento ->
                if (evento.isMetaPressed && evento.key == Key.S && evento.type == KeyEventType.KeyDown) {
                    salvo = true
                    true
                } else {
                    false
                }
            }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            if (salvo) {
                Text("Arquivo salvo!", color = MaterialTheme.colorScheme.primary)
            }
            TextField(
                value = texto,
                onValueChange = {
                    texto = it
                    salvo = false
                },
                modifier = Modifier.fillMaxSize()
            )
        }
    }
}

Distribuicao do aplicativo

Para distribuir seu app, o plugin Compose gera instaladores nativos para cada plataforma:

# macOS: gera .dmg
./gradlew packageDmg

# Windows: gera .msi
./gradlew packageMsi

# Linux: gera .deb
./gradlew packageDeb

Voce pode personalizar metadados do instalador no build.gradle.kts:

compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "MeuApp"
            packageVersion = "1.0.0"
            description = "Meu aplicativo desktop em Kotlin"
            vendor = "Minha Empresa"

            macOS {
                iconFile.set(project.file("icone.icns"))
            }
            windows {
                iconFile.set(project.file("icone.ico"))
                menuGroup = "Meus Apps"
            }
            linux {
                iconFile.set(project.file("icone.png"))
            }
        }
    }
}

Conclusao

Compose Multiplatform para Desktop abre um caminho empolgante para desenvolvedores Kotlin que querem criar aplicacoes desktop modernas. A combinacao de UI declarativa, acesso completo a JVM e APIs nativas de cada sistema operacional resulta em uma experiencia de desenvolvimento produtiva. Se voce ja conhece Jetpack Compose no Android, a transicao para desktop e quase imediata. O investimento em aprender Compose se multiplica: o mesmo conhecimento vale para Android, iOS, Desktop e Web.