算譜王におれはなる!!!!

偏りはあると思うけど情報技術全般についてマイペースに書くよ。

KotlinのCompose for Desktopを味見してみたよ〜

f:id:ngsw_taro:20201211153534p:plain

先月11月に爆誕した Compose for Desktop というPC向けGUIアプリケーション・フレームワークを味見、ということで簡単なメモ帳アプリをつくってみました。 コーディングや調査した内容を順に紹介していくので、擬似的に開発を体験できて、読者の方も一緒に味見ができればと思います。

なお、このエントリはUbie Advent Calendar 2020の12/11分です。 昨日は@hassy_ubによる「30歳。働き方を見直したら、医療xIT企業のUbieにたどり着きました。」でした。

Compose for Desktopとは

www.jetbrains.com

上記がCompose for Desktopの公式サイトですが、どういう機能が備わっているかをざっと見てみましょう。

f:id:ngsw_taro:20201211105335p:plain
https://www.jetbrains.com/lp/compose/ より引用

やはりKotlinはJVM言語ということで、AWTやSwingとの相互運用性が謳われてますね。 その一方で、Pure Javaでは実現できない通知などのOSの機能が利用できるというのが面白いですね。 そして、おそらくこのフレームワークの最大の売りである特徴が、Jetpack Composeとのコードの共有・再利用でしょう。 あと、Skiaというクロスプラットフォームなグラフィックライブラリを使ってるみたいですね。

つくり始める

IntelliJ IDEAがあれば簡単にCompose for Desktop を使った開発を始められます。 プロジェクトの新規作成ウィザードに、いくつか必要事項を入力してプロジェクトを作成すると、プロジェクト雛形が生成されます。 詳しい手順については公式チュートリアルをご覧ください。

f:id:ngsw_taro:20201211111357p:plain

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 が設置され、テキストの入力・編集が可能になります。

f:id:ngsw_taro:20201211112348g:plain

ただ 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 の中身のテキストが上下中央に表示されてしまし、これを指定することでテキストが上寄りになりました。 この指定方法が正しいのかは不明です…。

f:id:ngsw_taro:20201211113913g:plain

メニューを設置する

まずは単純なメニューから。 「ファイル」→「新規作成」メニューを設置し、新しいメモを取れるようにする機能を作成します。

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 で文字を大きくしています。

f:id:ngsw_taro:20201211121042g:plain

ファイルの読み書き

「名前を付けて保存」機能をメニューに追加します。 いろいろ調査したんですが、現在のところファイル選択ダイアログは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
        }
    }
)

f:id:ngsw_taro:20201211142201g:plain

ダイアログを表示する

ダイアログは 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)
            )
        }
    }
}

f:id:ngsw_taro:20201211150126g:plain

まとめ

まだアルファということで、いろいろ整備されてなさそうだなーという印象はあるもののReact風で個人的には手に馴染みそうです。 Composeの名前が意味するとおり、このフレームワークの真骨頂はカスタムComposable関数をつくって、それらを組み合わせてコードの再利用性やら保守性やらを高めてくれることだと思います。 ただ問題は、Androidアプリとデスクトップアプリを同時に開発したい場面があるのか、というところです。

*1:import文は省略していますが、それ以外は完全なコードです。