この記事はAndroid Advent Calendar 2022の4日目の記事です。
Jetpack Composeで複雑なレイアウトを組むのに、Box, Row, Columnだけでは表現できずに困ったときはありませんか?
もしかしたらLayout Composableを使うことで実現できるかもしれません。
Layout Composableはかなり自由度高くレイアウトを組める一方、複雑で敬遠しがちです。
今回は例をもとにLayout Composableの使い方について紹介します。
例えば、以下のようにタイトルを中央に表示し、左にボタンを配置するレイアウトを考えます。
以下のようにBox
を使って配置すると、タイトルが長いときにボタンに被ってしまいます。
@Composable
fun UseBox() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "Title Title Title Title Title Title Title Title",
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
TextButton(
modifier = Modifier.align(Alignment.CenterEnd),
onClick = { /* ... */ }
) {
Text(text = "Button")
}
}
}
Row
を使って実装すると、タイトルが左に寄ってしまいます。
@Composable
fun UseColumn() {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1F),
contentAlignment = Alignment.Center
) {
Text(
text = "Title",
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
}
TextButton(onClick = { /* ... */ }) {
Text(text = "Button")
}
}
}
こういうときに利用できるのが Layout Composable です。
Layout Composableは以下の3ステップを実装することで、子Composableを好きな位置に配置できます。(参考)
上記の例で説明していきます。
まず最初に、content
に表示するComposableを指定します。
@Composable
fun UseLayout() {
Layout(
modifier = Modifier.padding(16.dp),
content = {
Text(
text = "Title",
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
TextButton(onClick = { /* ... */ }) {
Text(text = "Button")
}
},
measurePolicy = { measurables, constraints ->
/* ... */
}
)
}
次に、MeasurePolicy
を実装していきます。
fun interface MeasurePolicy {
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
/* ... */
}
MeasurePolicy
はメソッドが一つのFunctional interfaceになっており、measure
メソッドには引数が2つあります。
1つ目のmeasurables
はcontent
に指定したComposableが測定可能な状態になったものです。
2つ目のconstraints
は配置するための制約で、maxWidth
やminHeight
が入っています。
Measurable
はconstraints
を指定してmeausre
することで、配置可能なPlaceable
に変換させることができます。
interface Measurable : IntrinsicMeasurable {
fun measure(constraints: Constraints): Placeable
}
上記の例だと、このように計測します。
measurePolicy = { measurables, constraints ->
val titleMeasurable = measurables[0]
val buttonMeasurable = measurables[1]
// 子コンポーネントはminWidth, minHeightは無視するので0を指定する
val buttonConstraints = constraints.copy(
minHeight = 0,
minWidth = 0
)
val buttonPlaceable = buttonMeasurable.measure(buttonConstraints)
// 左右にボタンの幅分だけ余白を用意するので、ボタンの幅 * 2だけ引く
// maxWidthは0未満にできないのでmaxOfで0以上にする
val titleConstraints = constraints.copy(
maxWidth = maxOf(constraints.maxWidth - buttonPlaceable.width * 2, 0),
minHeight = 0,
minWidth = 0
)
val titlePlaceable = titleMeasurable.measure(titleConstraints)
/* ... */
ポイントは、左右にボタン幅の余白を用意するため、Titleの最大横幅をボタンの横幅 * 2だけ引いた値にします。
先程はindexでtitleMeasurable
とbuttonMeasurable
を取得しましたが、 Modifier.layoutId
を使うことで、measurables
からidベースで検索することができます。
Layout(
modifier = /* ... */,
content = {
Text(
modifier = Modifier.layoutId("title"),
text = "Title",
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
TextButton(
modifier = Modifier.layoutId("button"),
onClick = { /* ... */ }
) {
Text(text = "Button")
}
},
measurePolicy = { measurables, constraints ->
val titleMeasurable = measurables.find { it.layoutId == "title" }
?: error("title not found")
val buttonMeasurable = measurables.find { it.layoutId == "button" }
?: error("button not found")
/* ... */
次に、ノード自体(Layout Composable)のサイズを決定します。
今回は、横幅は最大まで広げたいため、constraints.maxWidth
を指定します。
縦幅はコンテンツサイズに合わせるため、constraints.minHeight
とtitlePlaceable.height
とbuttonPlaceable.height
の最大値を取るようにします。
決定したサイズはlayout
関数に指定します。
measurePolicy = { measurables, constraints ->
/* ... */
// 横幅:最大幅
val width = constraints.maxWidth
// 高さ:コンテンツサイズに合わせる
val height = maxOf(
constraints.minHeight,
titlePlaceable.height,
buttonPlaceable.height
)
layout(width, height) {
/* ... */
}
}
最後に、layout
のラムダ式内でPlaceable.place
を呼び出すことで配置します。
abstract class PlacementScope {
fun Placeable.place(x: Int, y: Int, zIndex: Float = 0f) = /* ... */
fun Placeable.place(position: IntOffset, zIndex: Float = 0f) = /* ... */
/* ... */
}
タイトルは中央に配置したいのでAlignment.Center
を、ボタンは左端に配置したいのでAlignment.CenterEnd
を使ってそれぞれのoffset
を計算します。
measurePolicy = { measurables, constraints ->
/* ... */
layout(width, height) {
val space = IntSize(width, height)
val titleSize = IntSize(titlePlaceable.width, titlePlaceable.height)
val buttonSize = IntSize(buttonPlaceable.width, buttonPlaceable.height)
val titleOffset = Alignment.Center.align(titleSize, space, layoutDirection)
val buttonOffset = Alignment.CenterEnd.align(buttonSize, space, layoutDirection)
titlePlaceable.place(titleOffset)
buttonPlaceable.place(buttonOffset)
}
}
ソースコードの全体はこちらから確認できます。
Layout Composableは少し複雑ですが、うまく使えると複雑なレイアウト配置を行うことができます。
Row
やColumn
といった標準Composableも内部ではLayout Composableを呼び出しています。
measurePolicy
内の配置の計算は複雑になりがちですが、maxOf
やAlignment
を使うことで整理することができるかもしれません。
Layout Composableよりもさらに高機能なSubcomposeLayout
というものもあるので、今後紹介しようと思います。