アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

UIを組むためのDSLとデータの持ち方の考え方【Jetpack Compose (Desktop)】

バージョン情報

plugins {
    kotlin("jvm") version "1.4.20"
    id("org.jetbrains.compose") version "0.2.0-build132"
}

考え方

普通のKotlinの書き方と全く異なります。Kotlin-Nativeで freeze というものがあるのと同じくらいには違います。Kotlin-NativeやKotlin-JSくらいJetpack Composeは違うものだと考えてください。この認識がないと詰まります。
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.native.concurrent/freeze.html

DSL

以下のように書きます。thisのブロックが何なのかわかりやすいように要らない記述があります。

fun main() = Window {
    MaterialTheme {
        Column {
            this is ColumnScope

            Button(
                onClick = {

                }
            ) {
                this is RowScope

                Text(text = "1")
            }
        }
    }
}
f:id:matsudamper:20210206213856p:plain:w400

UIツリー

ここでColumnの定義を見てみましょう。これは何かの関数ではなく、グローバルな関数として定義されています。
じゃあこれどうやってUIツリーを形成しているんだという疑問が湧いてきます。ここが普通のKotlinでは無いなと思う部分です。
これは @Composable を付けるとコンパイラ側が色々いじっているようなのでこういう書き方でUIツリーが形成されるようになっています。こういうものだと思っておきましょう。 Composition と呼ばれるものに保存されているということだけ覚えておきましょう。
@Composable な関数は @Composable な関数内でしか呼ぶことができません。

@Composable
@OptIn(ExperimentalLayoutNodeApi::class, InternalLayoutApi::class)
inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) { /* 省略 */ }

レンダリング

Columnの更新が必要ならColumnのブロック、Buttonの更新が必要ならButtonのブロックが何度でも呼ばれます。
ここに直接通信処理や重いロジックを書くと大変なことになります。

Viewを更新する

ボタンの値を更新してみましょう。以下のコードは押しても反応がありません。だってプログラム側はbuttonTextの値が変わったことをどうやって知れば良いのでしょうか。

Column {
    var buttonText = 0
    Button(
        onClick = {
            buttonText += 1
        }
    ) {
        Text(text = buttonText.toString())
    }
}

以下のように MutableState 経由で値を操作することによって、Buttonの更新が必要であることを通知します。

Column {
    val buttonText = mutableStateOf(0)
    Button(
        onClick = {
            buttonText.value++
        }
    ) {
        Text(text = buttonText.value.toString())
    }
}

実は上記コードにも欠陥があります。以下のように Column 部分に buttonText を使うコードを差し込むと buttonText が再代入され、値がクリアされます。

Column {
    val buttonText = mutableStateOf(0)
    Button(
        onClick = {
            buttonText.value++
        }
    ) {
        Text(text = buttonText.value.toString())
    }

    Text(text = buttonText.value.toString())
}

そこで使用するのがrememberです。これで前回の値を保持、復元してくれます。

Column {
    val buttonText = remember { mutableStateOf(0) }
    Button(
        onClick = {
            buttonText.value++
        }
    ) {
        Text(text = buttonText.value.toString())
    }

    Text(text = buttonText.value.toString())
}

rememberのスコープ

Tab等、画面を切り替える時に覚えておくと良いことです。今回はシンプルにするために奇数と偶数でViewを出し分けるだけにしています。
Rowを使っているのは見やすくするためだけにです。

f:id:matsudamper:20210206224608p:plain:w300

Column {
    val count = remember { mutableStateOf(0) }
    Row {
        Button(
            onClick = {
                count.value++
            }
        ) {
            Text(text = "count up")
        }
        Text(text = count.value.toString())
    }

    if (count.value % 2 == 1) {
        val buttonText = remember { mutableStateOf(0) }
        Text("Odd")
        Button(
            onClick = {
                buttonText.value++
            }
        ) {
            Text(text = buttonText.value.toString())
        }
    } else {
        val buttonText = remember { mutableStateOf(0) }
        Text("Even")
        Button(
            onClick = {
                buttonText.value++
            }
        ) {
            Text(text = buttonText.value.toString())
        }
    }
}

奇数と偶数の中でカウントアップされたものは奇数と偶数を切り替えるとデータが消失します。GCみたいですね。
以下のように毎回呼ばれる位置に移動することで奇数偶数を切り替えてもデータが保持されます。 remember を使う位置もとても重要になってきます。

val buttonTextOdd = remember { mutableStateOf(0) }
val buttonTextEven = remember { mutableStateOf(0) }
if (count.value % 2 == 1) {
    Text("Odd")
    Button(
        onClick = {
            buttonTextOdd.value++
        }
    ) {
        Text(text = buttonTextOdd.value.toString())
    }
} else {
    Text("Even")
    Button(
        onClick = {
            buttonTextEven.value++
        }
    ) {
        Text(text = buttonTextEven.value.toString())
    }
}    

おわりに

コンパイラを色々いじって実現される独自のUIツリーの作り方、独自のデータの持ち方がとても独特で、最初はとっつきにくいですが、理解してしまえばスムーズに実装できると思います。