KotlinのCompose for Desktopを味見してみたよ〜
先月11月に爆誕した Compose for Desktop というPC向けGUIアプリケーション・フレームワークを味見、ということで簡単なメモ帳アプリをつくってみました。 コーディングや調査した内容を順に紹介していくので、擬似的に開発を体験できて、読者の方も一緒に味見ができればと思います。
Compose for Desktopとは
上記がCompose for Desktopの公式サイトですが、どういう機能が備わっているかをざっと見てみましょう。
やはりKotlinはJVM言語ということで、AWTやSwingとの相互運用性が謳われてますね。 その一方で、Pure Javaでは実現できない通知などのOSの機能が利用できるというのが面白いですね。 そして、おそらくこのフレームワークの最大の売りである特徴が、Jetpack Composeとのコードの共有・再利用でしょう。 あと、Skiaというクロスプラットフォームなグラフィックライブラリを使ってるみたいですね。
つくり始める
IntelliJ IDEAがあれば簡単にCompose for Desktop を使った開発を始められます。 プロジェクトの新規作成ウィザードに、いくつか必要事項を入力してプロジェクトを作成すると、プロジェクト雛形が生成されます。 詳しい手順については公式チュートリアルをご覧ください。
main.kt
という生成されたソースファイルには、あらかじめコードが記述されており、これをそのまま実行すると上の画像のようにウィンドウとボタンを持ったアプリケーションが起動します。
記述されているコードからわかるとおり「Hello, World!」ボタンをクリックすると、そのテキストが「Hello, Desktop!」に変化します。
僕はJetpack Composeの経験はないですが、入れ子構造で宣言的にUIを構築していけるのは馴染みやすいと思いました。
テキスト編集エリアを設置する
今回は単純なメモ帳アプリをつくるということで、画面に必要なのはテキスト編集エリアですね。
ということで元のコードを下記のように編集して TextField
を設置します*1。
fun main() = Window { var text by remember { mutableStateOf("") } MaterialTheme { TextField( value = text, onValueChange = { text = it } ) } }
これを実行すると、下の画像のように TextField
が設置され、テキストの入力・編集が可能になります。
ただ TextField
が左上に寄っているので、これをウィンドウいっぱいに広げたいと思います。
fun main() = Window { var text by remember { mutableStateOf("") } MaterialTheme { TextField( value = text, onValueChange = { text = it }, modifier = Modifier.fillMaxSize(), label = { Text("") } ) } }
引数 modifier
にはコンポーネントの様々な属性を設定できるようで、今回はサイズを最大にするよう指定しました。
Modifier
をインポートする際にはパッケージに注意してください。多くの Modifier
がサジェストされますが、インポートすべきは androidx.compose.ui.Modifier
です。
ラベルに空のテキストを指定している label = { Text("") }
というコードがありますが、これは何か意味があると思いますか?
この指定がないと TextField
の中身のテキストが上下中央に表示されてしまし、これを指定することでテキストが上寄りになりました。
この指定方法が正しいのかは不明です…。
メニューを設置する
まずは単純なメニューから。 「ファイル」→「新規作成」メニューを設置し、新しいメモを取れるようにする機能を作成します。
fun main() { val text = mutableStateOf("") AppManager.setMenu( MenuBar( Menu( name = "ファイル", item = arrayOf( MenuItem(name = "新規作成", onClick = { text.value = "" }, shortcut = KeyStroke(Key.N)) ) ) ) ) Window { MaterialTheme { TextField( value = text.value, onValueChange = { text.value = it }, modifier = Modifier.fillMaxSize(), label = { Text("") }, textStyle = TextStyle(fontSize = 24.sp) ) } } }
メニューの設置のために追加されたコードの前に関数 Window
の呼び出しや 変数 text
まわりの変更に注目してください。
メニューの設置には AppManager
を使う方法と、関数 Window
の引数に指定する方法があるようですが、今回は前者 AppManager
を使う方法を選びました。
関数 Window
を呼び出す前に AppManager
でメニューを設置する必要があるので、それに伴い関数 Window
の呼び出しや 変数 text
まわりに変更を行ったわけです。
なお、キャプチャ上の視認性のため TextStyle
で文字を大きくしています。
ファイルの読み書き
「名前を付けて保存」機能をメニューに追加します。
いろいろ調査したんですが、現在のところファイル選択ダイアログはCompose for Desktopでは提供されていないようです、たぶん。
せっかくSwingとの共存もできるということで、Swingのファイル選択ダイアログ JFileChooser
を使って実現することにします。
fun main() { val text = mutableStateOf("") val currentFile = mutableStateOf<File?>(null) fun saveAs() { val parentComponent = AppManager.focusedWindow?.window /* ① */ val fileChooser = JFileChooser() val result = fileChooser.showSaveDialog(parentComponent) if (result == JFileChooser.APPROVE_OPTION) { val file = fileChooser.selectedFile file.writeText(text.value) currentFile.value = file /* ② */ } } AppManager.setMenu( MenuBar( Menu( name = "ファイル", item = arrayOf( MenuItem(name = "新規作成", onClick = { text.value = "" }, shortcut = KeyStroke(Key.N)), MenuItem( name = "名前を付けて保存", onClick = { saveAs() } ) ) ) ) ) Window(title = "メモ帳") { MaterialTheme { TextField( value = text.value, onValueChange = { text.value = it }, modifier = Modifier.fillMaxSize(), label = { Text("") }, textStyle = TextStyle(fontSize = 24.sp) ) } } }
「名前を付けて保存」という name
を持つ MenuItem
を追加しました。
クリック時の処理として JFileChooser
を使って保存先ファイルを取得しています。
JFileChooser
のメソッドshowSaveDialog
の引数には親コンポーネントを指定するのですが、その型は java.awt.Component
です。
これは AppManager.focusedWindow?.window
で取得可能です(①部分)。
保存先ファイルを取得したら、Kotlin標準の便利拡張関数 writeText
を使ってファイルにテキストを書き出します。
そして②の箇所で保存先ファイルを currentFile
に保存しておきます。これはあとで「上書き保存」や「開く」などで利用します。
「上書き保存」は次のような MenuItem
を追加するだけです。
MenuItem( name = "上書き保存", shortcut = KeyStroke(Key.S), onClick = { val file = currentFile.value if (file != null) { file.writeText(text.value) } else { saveAs() } } )
「開く」は下記のとおり。
MenuItem( name = "開く", shortcut = KeyStroke(Key.O), onClick = { val parentComponent = AppManager.focusedWindow?.window val fileChooser = JFileChooser() fileChooser.fileFilter = FileNameExtensionFilter("テキストファイル", "txt") val result = fileChooser.showOpenDialog(parentComponent) if (result == JFileChooser.APPROVE_OPTION) { val file = fileChooser.selectedFile text.value = file.readText(Charsets.UTF_8) currentFile.value = file } } )
ダイアログを表示する
ダイアログは Dialog
という関数で Compose for Desktopから提供されています。
今回は未保存のデータが消える操作がなされたときに注意を促す、いわゆるフールプルーフを実装するので関数AlertDialog
を利用します。
AlertDialog
の表示・非表示の切り分けは、フラグを用意して if文で実現しました(Composeのお作法をわかってないですがReact風なやり方でやりすごした)。
「テキスト編集」→「開く」or「新規作成」→処理を中断して「保存しますか?」を表示→「はい」なら保存してから処理を続行、「いいえ」ならそのまま処理を続行、というところでコードの見かけ上で処理が飛び飛びになるのがけっこう面倒でした。
最終的にコードとアプリの動きはこうなりました。
fun main() { val text = mutableStateOf("") val currentFile = mutableStateOf<File?>(null) val modified = mutableStateOf(false) val suspendedFunction = mutableStateOf<(() -> Unit)?>(null) fun new() { if (modified.value) { suspendedFunction.value = ::new return } text.value = "" modified.value = false } fun open() { if (modified.value) { suspendedFunction.value = ::open return } val parentComponent = AppManager.focusedWindow?.window val fileChooser = JFileChooser() fileChooser.fileFilter = FileNameExtensionFilter("テキストファイル", "txt") val result = fileChooser.showOpenDialog(parentComponent) if (result == JFileChooser.APPROVE_OPTION) { val file = fileChooser.selectedFile text.value = file.readText(Charsets.UTF_8) currentFile.value = file modified.value = false } } fun saveAs() { val parentComponent = AppManager.focusedWindow?.window val fileChooser = JFileChooser() val result = fileChooser.showSaveDialog(parentComponent) if (result == JFileChooser.APPROVE_OPTION) { val file = fileChooser.selectedFile file.writeText(text.value) modified.value = false currentFile.value = file } } fun save() { val file = currentFile.value if (file != null) { file.writeText(text.value) modified.value = false } else { saveAs() } } AppManager.setMenu( MenuBar( Menu( name = "ファイル", item = arrayOf( MenuItem( name = "新規作成", shortcut = KeyStroke(Key.N), onClick = { new() }, ), MenuItem( name = "開く", shortcut = KeyStroke(Key.O), onClick = { open() } ), MenuItem( name = "名前を付けて保存", onClick = { saveAs() } ), MenuItem( name = "上書き保存", shortcut = KeyStroke(Key.S), onClick = { save() } ) ) ) ) ) Window(title = "メモ帳") { MaterialTheme { if (suspendedFunction.value != null) { AlertDialog( onDismissRequest = {}, confirmButton = { Button(onClick = { save() suspendedFunction.value?.invoke() suspendedFunction.value = null }) { Text("はい") } }, dismissButton = { Button(onClick = { modified.value = false suspendedFunction.value?.invoke() suspendedFunction.value = null }) { Text("いいえ") } }, text = { Text("保存しますか?") }) } TextField( value = text.value, onValueChange = { text.value = it modified.value = true }, modifier = Modifier.fillMaxSize(), label = { Text("") }, textStyle = TextStyle(fontSize = 24.sp) ) } } }
まとめ
まだアルファということで、いろいろ整備されてなさそうだなーという印象はあるもののReact風で個人的には手に馴染みそうです。 Composeの名前が意味するとおり、このフレームワークの真骨頂はカスタムComposable関数をつくって、それらを組み合わせてコードの再利用性やら保守性やらを高めてくれることだと思います。 ただ問題は、Androidアプリとデスクトップアプリを同時に開発したい場面があるのか、というところです。
*1:import文は省略していますが、それ以外は完全なコードです。