Jetpack Compose入門(13) UIの階層化と制御構文



これまでの基本編のサンプルは、「基本編のベースとなるプロジェクト」で説明したAppScreen()関数に、簡単なUIコンポーネントのコードを直接記述してきました。実際のアプリのUIはもっと複雑なので、ソースコードも階層化して作成すべきです。適切に階層化することで、状態に応じてUIを変化させる場合の処理も記述しやすくなります。今回は、UIを階層化し、Kotlinの制御構文を使ってよりシンプルかつ柔軟にUIを記述する方法を説明します。

UIを階層化する

複雑なUIを作成する場合は、コンポーザブル関数から別のコンポーザブル関数を呼び出すことによって、UIを階層化することができます。一つのコンポーザブル関数が大きくなりすぎると管理しづらくなりますので、UIのまとまり毎にコンポーザブル関数を定義して、一つ一つのコンポーザブル関数が大きくなりすぎないように保つことが望ましいです。

コンポーザブル関数はKotlinの関数と同じように扱えるので、ソースコードを書いている途中でいつでも、処理の一部を別の関数に分割することができます。従来のXMLで記述するViewの場合、一部分を別のXMLに分離しようとするとそれなりに手間でしたので、UI設計時にどの範囲を一つのXMLで書くかを決めておくことが多かったと思います。あるいは、一つのフラグメントには一つのXMLと決め打ちということも多かったと思います。Jetpack Composeでは関数の分離が簡単なので、必要に応じて関数を分離したり統合したりして、一つ一つの関数はシンプルな状態にしておくことができます。

例えば図のようなUIを作るとします。

Image2つ、Text3つ、Button1つを配置しているこのUIを一つのコンポーザブル関数で書くと、次のようになります。コメントを書いていないせいもありますが、お世辞にも見やすいとは言えませんね。

@Composable
fun AppScreen() {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Row {
            Column(
                modifier = Modifier
                    .weight(1f)
                    .padding(10.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Image(
                    painter = painterResource(id = R.drawable.dog),
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .aspectRatio(1f),
                    contentScale = ContentScale.Crop
                )
                Text(
                    text = "Dog",
                    fontSize = 20.sp,
                )
            }
            Column(
                modifier = Modifier
                    .weight(1f)
                    .padding(10.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Image(
                    painter = painterResource(id = R.drawable.cat),
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .aspectRatio(1f),
                    contentScale = ContentScale.Crop
                )
                Text(
                    text = "Cat",
                    fontSize = 20.sp,
                )
            }
        }
        Text(
            text = "Do you like animals?",
            fontSize = 25.sp,
            modifier = Modifier.padding(vertical = 10.dp)
        )
        Button(
            onClick = { /*TODO*/ },
            modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
        ) {
            Text(text = "Yes")
        }
    }
}

ではこのコンポーザブル関数を分割して階層化し、UIの構造を分かりやすくしましょう。上記のUIは、このような構造になってます。上部には動物の情報を表示するエリアが2つ並んでいます。表示内容は犬と猫で異なりますが、UIの構造は同じなので、関数化して再利用することができそうです。下部には質問と回答用ボタンを表示するエリアがあります。

AppScreen()関数は次のようになります。最上位にColumn()を配置し、全体を上下の2段に分割しています。上部はさらにRow()で左右に分割し、AnimalComposable()を2つ配置しています。下段にはQuestionComposable()を配置しています。ColumnRowの組み合わせ方は最初のサンプルと同じですが、今回は全体の構造の見通しがとてもよくなりました。

@Composable
fun AppScreen() {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Row {
            AnimalComposable(
                resourceId = R.drawable.dog,
                text = "Dog",
                modifier = Modifier
                    .weight(1f)
                    .padding(10.dp)
            )
            AnimalComposable(
                resourceId = R.drawable.cat,
                text = "Cat",
                modifier = Modifier
                    .weight(1f)
                    .padding(10.dp)
            )
        }
        QuestionComposable()
    }
}

AnimalComposable()QuestionComposable()の実装は以下の通りで、基本的には最初のサンプルの該当部分を切り出しただけです。それでも、適切に関数を分割することで見通しがよくなっていることが実感できると思います。

AnimalComposable()には引数を定義していることもポイントです。コンポーザブル関数も通常のKotlinの関数と同じように、引数を定義することができます。これにより同じ構造のUIを部品として再利用できます。今回の例では画像のリソースIDと表示文字列を呼び出し側から指定することによって、共通で使える動物情報表示のUI部品を作っています。第三引数はModifierです。呼び出し元では、Column()Row()のスコープの中だけで使えるModifier.weight()を設定しています。これをAnimalComposable()のルート階層のColumn()にそのまま設定しています。このようにすることで、AnimalComposable()Row()の中で正しいサイズと位置に表示されるようにしています。

@Composable
fun AnimalComposable(resourceId: Int, text: String, modifier: Modifier = Modifier) {
    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Image(
            painter = painterResource(id = resourceId),
            contentDescription = null,
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f),
            contentScale = ContentScale.Crop
        )
        Text(
            text = text,
            fontSize = 20.sp,
        )
    }
}
@Composable
fun QuestionComposable() {
    Text(
        text = "Do you like animals?",
        fontSize = 25.sp,
        modifier = Modifier.padding(vertical = 10.dp)
    )
    Button(
        onClick = { /*TODO*/ },
        modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
    ) {
        Text(text = "Yes")
    }
}

イベントを上位に返す

UIを階層化すると、下位レイヤーで発生したイベントを上位レイヤーに返したい場合が出てきます。そのような場合は、コンポーザブル関数の引数を使ってイベントハンドラを実装します。例えば上記のQuestionComposableのボタンクリックイベントを、上位階層のAppScreenに返したい場合は、下記のような実装になります。

@Composable
fun QuestionComposable(onClick: ()->Unit = {}) {
    Text(
        ...
    )
    Button(
        onClick = onClick,
        modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
    ) {
        Text(text = "Yes")
    }
}

QuestionComposable()の引数にonClickを定義し、ButtononClickにそのまま渡します。呼び出し元のAppScreen()でイベントを受け取るには、次のようにラムダ式を追加します。

@Composable
fun AppScreen() {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Row {
            ...
        }
        QuestionComposable {
            /* Button onClick Handler */
        }
    }
}

なお、QuestionComposable()onClick引数はデフォルト値に{ }(何も処理をしない空っぽのラムダ式)を指定しているので、QuestionComposable()の呼び出し元で何も指定しなくてもエラーにはなりません。また、上位階層に渡すイベントは必ずしもオリジナルのイベントと同じでなくても構いません。例えばQuestionComposableにボタンが2つある場合、ボタンごとにIDを割り当て、上位に返すイベントの引数にIDを渡すようにするといったことも考えられます。

コンポーザブル関数内で制御構文を使う

繰り返しになりますが、コンポーザブル関数は基本的に通常のKotlinの関数と同じことができます。したがって、ifforなどの制御構文も使えます。

if文で表示を切り替える

コンポーネントの引数の変更ではカバーしきれない大きな変更を実装するには、ifwhenによる分岐を活用します。例えば、コンポーネントの表示・非表示を切り替える場合、従来のViewシステムではvisibleパラメータを使ったり、Flagmentを追加・削除したりしていました。Jetpack Composeではif文などで表示すべきかどうかの条件を判定し、条件を満たす場合にコンポーザブル関数を呼び出します。

上のサンプルで、ボタンが押されたら応答を表示するようにしてみましょう。

@Composable
fun AppScreen() {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        var isButtonPressed by remember { mutableStateOf(false) }
        Row {
            ...
        }
        QuestionComposable {
            isButtonPressed = true
        }
        if (isButtonPressed) {
            ResponseComposable()
        }
    }
}

@Composable
fun ResponseComposable() {
    Text(
        text = "Thank you!!!",
        fontSize = 25.sp
    )
}

まずボタンが押されたかどうかの状態を保持する変数isButtonPressedrememberで定義します。初期値はfalseです。remembermutableStateOf()についての説明は、「ボタンクリックでUIを更新する」をご覧くださいそして、QuestionComposable()onClick引数(ラムダ式の部分)で、isButtonPressedtrueにします。

ResponseComposable()は、isButtonPressedtrueの場合に呼び出されます。isButtonPressedの初期値はfalseですので、最初にUIを描画する時点ではResponseComposable()は描画されません。その後QuestionComposable()内のボタンがクリックされると、onClickイベントが呼び出され、isButtonPressedtrueに変化します。Jetpack Composeはこの変化を検出し、UIを再構築します。そして再度AppScreen()が呼び出されたときには、isButtonPressedtrueになっているので、ResponseComposableが表示されます。

for文で同じUIを並べる

同じUIのブロックを複数並べる場合には、for文が使えます。例えば上の例では2つのAnimalComposableをならべて記述していますが、これをfor文に置き換えることもできます。せっかくなので3列に増やしてfor文で書いてみます。

fun AppScreen() {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        val animalList = listOf<Animal>(
            Animal("Dog", R.drawable.dog),
            Animal("Cat", R.drawable.cat),
            Animal("Bird", R.drawable.bird),
        )
        
        Row {
            for (animal in animalList) {
                AnimalComposable(
                    resourceId = animal.resourceId,
                    text = animal.text,
                    modifier = Modifier
                        .weight(1f)
                        .padding(10.dp)
                )
            }
        }

        ...
    }
}

ただし気を付けてほしいのは、このようなfor文の使い方をするのは、繰り返しの数が固定で、それほど多くない場合に限られます。長いリストを表示するような場合は、実践編で説明するLazyColumnLazyRowを使います。

まとめ

今回は、UIを階層化し、制御構文を活用したコンポーザブル関数の記述も導入して、より柔軟にUIを記述する方法を学びました。動的なUIを作成する際には、UIの記述中にif文やfor文などを使えるのはとても便利です。ただし気を付けてほしいのは、UIのソースコード内にビジネスロジックを書いてしまうと、メンテナンスしづらいコードになってしまいます。コンポーザブル関数内ではあくまでUIの条件判定などにとどめ、ビジネスロジックはModel(あるいはViewModel)に記述しましょう。コンポーザブル関数とViewModelの結び付け方は、実践編で説明するつもりです。


Jetpack Compose入門