Blog

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

この記事は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 です。

Layout Composableは以下の3ステップを実装することで、子Composableを好きな位置に配置できます。(参考)

  1. すべての子を測定する
  2. ノード自体のサイズを決定する
  3. 子を配置する

上記の例で説明していきます。

1. すべての子を測定する

まず最初に、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つ目のmeasurablescontentに指定したComposableが測定可能な状態になったものです。

2つ目のconstraintsは配置するための制約で、maxWidthminHeightが入っています。

Measurableconstraintsを指定して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でtitleMeasurablebuttonMeasurableを取得しましたが、 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")
            /* ... */

2. ノード自体のサイズを決定する

次に、ノード自体(Layout Composable)のサイズを決定します。

今回は、横幅は最大まで広げたいため、constraints.maxWidthを指定します。

縦幅はコンテンツサイズに合わせるため、constraints.minHeighttitlePlaceable.heightbuttonPlaceable.heightの最大値を取るようにします。

決定したサイズはlayout関数に指定します。

measurePolicy = { measurables, constraints ->
    /* ... */

    // 横幅:最大幅
    val width = constraints.maxWidth
    // 高さ:コンテンツサイズに合わせる
    val height = maxOf(
        constraints.minHeight,
        titlePlaceable.height,
        buttonPlaceable.height
    )
    layout(width, height) {
        /* ... */
    }
}

3. 子を配置する

最後に、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は少し複雑ですが、うまく使えると複雑なレイアウト配置を行うことができます。

RowColumnといった標準Composableも内部ではLayout Composableを呼び出しています。

measurePolicy内の配置の計算は複雑になりがちですが、maxOfAlignmentを使うことで整理することができるかもしれません。

Layout Composableよりもさらに高機能なSubcomposeLayoutというものもあるので、今後紹介しようと思います。

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも

Kotlin Coroutinesで共有リソースを扱う