📐

Jetpack Compose のレイアウトまとめ1: 基本的な仕組み

2022/10/09に公開

Jetpack Compose でコンポーネントのサイズ計算や配置をする仕組みは従来の Android View や Web などとは異なります[1]。Jetpack Compose でのレイアウトの考え方をよく知らずに使うと、意図通りのレイアウトができなかったり無駄に複雑なレイアウトを組んでしまったりする可能性があります。

ここでは主に従来のプラットフォームに慣れた人が見落としそうな部分を中心に、レイアウトの機能の基本部分を解説します。

なお、レイアウトに関しては公式のドキュメントが非常によく書かれてあります。こちらも参考にすると良いと思います。
https://developer.android.com/jetpack/compose/layouts

Modifier によるレイアウト設定

コンポーネントに Modifier オブジェクトを渡す事でサイズや位置を指定します。

// 20x20 の赤いボックスが作られる
Box(modifier = Modifier.size(20.dp).background(Color.Red))

代表的な modifier

アプリケーションを作る場合は以下の modifier を覚えていれば大体済みます。

メソッド 従来の Android View における機能
size(), width(), height()
サイズを直接指定する
layout_width="〜dp"
layout_height="〜dp"
fillMaxSize(), fillMaxWidth(), fillMaxHeight()
最大サイズで配置する
layout_width="match_parent"
layout_height="match_parent"
sizeIn(), widthIn(), heightIn()
最小サイズ・最大サイズを指定する
minWidth="〜dp"
minHeight="〜dp"
padding()
マージンを指定する
layout_margin="〜dp"
padding="〜dp"
align()
左・中央・右に配置する
layout_gravity="center"など
weight()
余白を指定された分配率で埋める
layout_weight="1"など

一部 〜size(), 〜width(), 〜height() というメソッドがたくさん並んでいますが、名前の通り幅/高さが違うだけで基本的には同じ機能を提供するものです。幅や高さだけを設定したい場合は 〜width()〜height() を、両方指定したい場合は 〜size() を使うと良いでしょう。

このように従来あった layout_* パラメーターに対応する物が揃っています。これなら同じように使えそうですね!でも実際はそれなりに違うので、何も知らないままだとうまく使えなかったりします(後述します)。

コンポーネント引数によるレイアウト設定

サイズや位置調整をコンポーネントの引数で行う場合もあります。例えばボタンは contentPadding 引数を提供しています。

// ボタンのパディングを指定する
Button(onClick = {}, contentPadding = PaddingValues(8.dp)) { Text("Click!") }

代表的なコンポーネント引数

引数 コンポーネント 従来の Android View における機能
contentPadding
パディングを指定する
Button
LazyColumn
など様々
padding="〜dp"
horizontalAlignment,
verticalAlignment

子要素の並びを揃える
Column Row
LazyGrid
などのリスト系
gravity="center_horizontal"など
verticalArrangement,
horizontalArrangement

子要素を詰める方法を指定する
Column Row
LazyGrid
などのリスト系
gravity="center_vertical"など?
textAlign
テキストの並びを揃える
Text gravity="center_horizontal" など

これらの引数は一部 modifier で代用する事もできますが、引数を使った方が便利だったり何らかの事情があって引数を提供しています。従ってこういった引数があれば modifier よりもこちらを使う事を考慮に入れましょう。

Padding

多くの人が真っ先に混乱するのは padding だと思います。padding は以下の特徴があります。

padding の呼び出し順序を変えてみる

実際に paddingsize を指定した二つのボックスを書いてみます。呼んでいる modifier は一緒ですが、順序が違います。

Box(Modifier.size(120.dp).background(Color.White).inspect(Color.Black)) {
    // padding が先
    Box(modifier = Modifier.padding(8.dp).size(80.dp).inspect(Color.Blue))
    // padding が後
    Box(modifier = Modifier.size(80.dp).padding(8.dp).inspect(Color.Red))
}

ちなみに inspect() modifier は今回サイズの可視化用に作ったメソッドで、Jetpack Compose ライブラリに含まれていません。

結果は以下のようになります(1dp = 2px に引き伸ばしています)。

padding サンプル

paddingsize より先に指定した青いボックスの方は、8dp の margin が指定された 80dp のボックスになっています。一方で、赤いボックスの方は 64dp になっています。

Modifier は一種のコンポーネントっぽい動きをする

modifier は Box などのコンポーネントのサイズなどのプロパティをセットしているというより、それ自身が一つのコンポーネントであるように振る舞います。例えば上記の赤いボックスのように padding を後にした場合、

Box {
    Text(
        text = "hello",
        modifier = Modifier.size(80.dp).padding(8.dp)
    )
}

コンポーネントが modifier に対応するボックスでラップされたような形になります。仮に modifier をコンポーネントとして表すと以下のコードのようになります。

Box {
    SizeModifier(80.dp) {
        PaddingModifier(8.dp) {
            Text("hello")
        }
    }
}

modifier のイメージ

このように padding()size() の後に呼び出した場合、padding は内側に作られるので従来の padding のような動きになります。一方で padding()size() の前に呼び出した場合、padding は外側に作られるので従来の margin のような動きになります。

contentPadding 引数

ButtonLazyColumn などの多くのコンポーネントは contentPadding 引数を用意しています。

Button(onClick = {}, contentPadding = PaddingValues(8.dp)) { Text("Click!") }

contentPadding を使わなくても padding modifier で代用できる事も多いですが、意図しない動作になったりする事も多いです。そのため contentPadding がある場合は基本的にはそちらを使う方が良いです。

Constraints の伝搬

padding と同じく慣れないうちはややこしいのが、実際のサイズとは異なる制約(constraint)という概念です。例えば sizefillMaxSize はサイズを決めているのではなく、制約を追加しています。

シンプルなケースだとあまり気にしなくても動作するレイアウトを書けるのですが、多少複雑なレイアウトを組む時や、Jetpack Compose のソースを参考にする場合などは仕組みを理解しないと難しい事があるので、理解しておいた方が良いでしょう。

Constraints

Jetpack Compose のサイズ制約は Constraints というクラスで表現されています。 Constraints は以下のようなクラスで[2]、幅/高さそれぞれの範囲で表現されます。

class Constraints(val minWidth: Int, val maxWidth: Int, val minHeight: Int, val maxHeight: Int)

Constraints はルート要素から始まり、値を変えながら子のコンポーネントや次の modifier に再帰的に渡していきます(modifier がコンポーネントのように振る舞うのは先ほどと同様です)。

各 modifier やコンポーネントが子要素に渡す制約は以下のようになります。

変換内容 変換例
(左は受け取った制約、右は子要素に渡す制約)
Modifier.size()
制約の上限・下限を指定の値にする(可能な限り)
Modifier.size(40.dp)
Modifier.fillMaxSize()
制約の下限を上限値にする
Modifier.fillMaxSize()
Modifier.sizeIn()
制約を指定の範囲に狭める
Modifier.sizeIn(20.dp, 20.dp, 60.dp, 60.dp)
Modifier.padding()
制約の上限・下限を狭める
Modifier.padding(8.dp)
Box, Column などのコンポーネントの多く
制約の下限を0にリセットする
Box { … }

size() fillMaxSize() sizeIn() は制約を強める事はあっても弱める事がない事に注意が必要です。例えば、Modifier.fillMaxSize().size(40.dp) という風に指定したとしても fillMaxSize() で既に上限・下限が固定されてしまっているため、size(40.dp) の指定は無視されます。

(発展) wrapContentSize と propagateMinConstraints

Box などの多くのコンポーネントは制約の下限を0にリセットしますが、同様の挙動を wrapContentSize という modifier で行う事もできます。wrapContentSize を使う事でコンポーネントの入れ子を減らしコードを簡潔にできる場合があります。また wrapContentSize は上限もリセットできます。

逆に Box で制約の下限をリセットしたくない場合は propagateMinConstraints 引数を指定する事で挙動を変える事ができます。

変換内容 変換例
(左は受け取った制約、右は子要素に渡す制約)
Modifier.wrapContentSize()
制約の下限を0にリセットする
Modifier.wrapContentSize()
Box(propagateMinConstraints = true)
制約をそのままにする
Box(propagateMinConstraints = true) { … }

サイズの決定

制約を受け取ったコンポーネントや modifier は子要素に制約を渡した後、各子要素からサイズを受け取り、それを元に自身のサイズを決定します。

大抵の要素は制約を満たした上でなるべく小さいサイズを返します。modifier で制約が指定されておらず、子要素がない場合はサイズは0になります。

Column を例に挙げると、高さは各子要素の高さの合計、幅は各子要素の幅の最大値になります(デフォルト設定の場合)。

Column のサイズ計算

このように各要素のサイズは子要素のサイズが決まってから決まるため、制約とは逆にサイズは下から上へと順に決まっていく事になります。

まとめ

今回は Jetpack Compose のレイアウトの基本的な仕組み部分について解説しました。

次回

https://zenn.dev/wm3/articles/7332788c626b39

脚注
  1. サイズ計算の考え方は SwiftUI が近いようです。 ↩︎

  2. 実際には最適化がかかっているためこれとは違うクラス定義になります。実際のソースは Android Studio から探すかここから見られます。 ↩︎

Discussion