🫧

【Compose】要素間にDividerを入れたリストをカスタムレイアウトでつくる

2024/08/22に公開

はじめに

リストのようなUIでは、アイテムを縦に並べて、その間にだけDividerを追加するというUIは一般的かと思います。Jetpack Composeでそれを作る際、今まではアイテムをColumnに入れて、間に Dividerが入るようにベタ書きしたり、Composable関数のリストを引数にとってforEachするラッパーをつくる、といった実装をしていました。

しかし、理想的なインターフェースとしては、Columnなどのようにcontent引数の中にアイテムのコンポーネントを雑にぶちこむといい感じに並べてくれる、というもの。

重い腰を上げてカスタムレイアウトに取り組んでみて勉強になったのと、同テーマの記事が意外と引っかからなかったため、こちらに備忘録がてらまとめたいと思います。

つくりたいリストUIの要件

  • ListItemListItemsWrapperがある
  • ListItemは、アイコンやラベル、サブテキスト、スイッチなどが入った横長のUI要素(基本、横幅いっぱい)
  • ListItemsWrapperは、content引数(@Composable () -> Unit)を持ち、そこに単数または複数のListItemを入れると、アイテム要素の間にだけDividerを引いた上で縦に整列し、全体を角丸の形に整形する。

イメージ:

Before
// 引数にComposable関数のリストをとる(普通こんなことはせず、ベタ並べするかと思いますが...)
ListItemsWrapper(
    listOf(
        { ListItem() },
        { ListItem() },
        { ListItem() },
    )
)

↓↓↓

After
// content引数に一括でぶちこんでいる。スマートな実装。
ListItemsWrapper() {
    ListItem()
    ListItem()
    ListItem()
}

カスタムレイアウトの登場

Columnなどのような、content引数に入れた複数のComposable関数それぞれに対して処理を行う、ということを実装したい場合、カスタムレイアウトというものを学ぶ必要があると分かりました。

https://developer.android.com/develop/ui/compose/layouts/custom?hl=ja

カスタムレイアウトは、Composable関数の実際のサイズを取得し、それを元に自由に要素を配置するための方法の総称で、layout修飾子やLayout関数などが用意されています。自前のレイアウトをつくるには、基本的にLayout関数を使うことから始まるようです。

Layout関数

最も基本的なカスタムレイアウト用のComposable関数です。以下の3ステップで、レイアウトを決定します。[参考]

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

使用例(公式のサンプルより):

// Columnと同じようにcontentの中の要素を縦に並べるカスタムレイアウト関数
@Composable
fun MyBasicColumn(
    modifier: Modifier,
    content: @Composable () -> Unit,
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // ①すべての子を測定する = 子要素に親からの制約をかけた新たなサイズのものに変換
        // content引数に入力された子要素をmeasurabesとして取得。
        // それを親からの制約であるconstraintsを考慮してmeasureすることで、
        // 新しいサイズを持った配置可能要素placeableに変換される。
        val placeables = measurables.map { measurable ->
            // constraintsは要素のサイズなどの情報を持っているので、
            // それをmeasurableに適用することで配置可能な要素になる
            measurable.measure(constraints)
        }

        // ②ノード自体のサイズを決定する = 親要素のサイズを決める
        // 配置する全体のサイズを引数に入れる。この例の場合、幅と高さともに最大サイズ。
        // placeableにより子要素のサイズはすでに決まっているため、ここから算出することもできる。
        // 要素の高さが固定値または上限がないと、maxHeightでは高さが無限になってしまい落ちるので注意。
        layout(constraints.maxWidth, constraints.maxHeight) {
            // 配置するY座標は縦に並べたければ自前で更新していく必要がある。
            var yPosition = 0

            // ③子を配置する = 親要素の中に子要素を座標指定して配置していく
            // layoutの中に上で変換したplaceable群を座標をそれぞれ指定して配置していく。
            placeables.forEach { placeable ->
                // 左揃えで縦に重ねる場合。
                placeable.placeRelative(x = 0, y = yPosition)

                // Y座標を、要素の高さ分だけ更新する。
                yPosition += placeable.height
            }
        }
    }
}

よし、これを使って実装しよう、と思ったら思わぬ壁が。Dividerを間に入れたい場合、こちらのスレッドのように、Dividerの個数を動的に設定できない...!

SubcomposeLayout関数

そんなピンチに救いの光を差し伸べてくれたのが、こちらのフォーラムでした。ふむふむ、リンク先のGistでは、SubcomposeLayoutなるものを使っている...。これは一体??

こちらの記事などを参考に理解したところによると、Layout関数では、content引数に渡したものは一気にmeasurablesに変換されてしまうため、事前に要素の個数が決まっている必要がある。一方、SubcomposeLayout関数では、一部の要素を他の部分がどう測定されるかに基づいて遅延してコンポーズする(つまり中でもComposable関数が呼べる)ので、より動的に要素を並べられる。これぞまさしく自分が求めていたもの!!

使用例:

// Columnと同じようにcontentの中の要素を縦に並べるカスタムレイアウト関数
@Composable
fun MyBasicColumn(
    modifier: Modifier,
    content: @Composable () -> Unit,
) {
    SubcomposeLayout(
        modifier = modifier,
    ) { constraints -> // measurable取得は遅延されるため、ここにはない。
        // ①すべての子を測定する = 子要素に親からの制約をかけた新たなサイズのものに変換
        // 子要素contentを遅延コンポーズしてMeasurabesとして取得。
        // Measurable -> Placeableに変換
        val contentMesurables = subcompose("content", content)
        val contentPlaceables = contentMesurables.map { it.measure(constraints) }

        // なんと追加でComposable関数を呼べる
        // Mesurable -> Placeableに変換する
        // 上記の"content"や下記の"additional"の文字列はSlotId: Any?で、
        // スコープ中でかぶらなければなんでも良さそう
        val additionalMeasurables = subcompose("additional") {
                // 呼びたいComposable関数:例えばDivider()
        }
        val additionalPlaceables = additionalMeasureables.map { it.measure(constraints) }

        // ②ノード自体のサイズを決定する = 親要素のサイズを決める
        // 配置する全体のサイズを引数に入れる。この例の場合、幅と高さともに最大サイズ。
        // placeableにより子要素のサイズはすでに決まっているため、ここから算出することもできる。
        // 要素の高さが固定値または上限がないと、maxHeightでは高さが無限になってしまい落ちるので注意。
        layout(constraints.maxWidth, constraints.maxHeight) {
            // 配置するY座標は縦に並べたければ自前で更新していく必要がある。
            var yPosition = 0

            // ③子を配置する = 親要素の中に子要素を座標指定して配置していく
            // layoutの中に上で変換したplaceable群を座標をそれぞれ指定して配置していく。
            contentPlaceables.forEach { placeable ->
                // 左揃えで縦に重ねる場合。
                placeable.placeRelative(x = 0, y = yPosition)

                // Y座標を、要素の高さ分だけ更新する。
                yPosition += placeable.height
            }

            // 好きなようにadditionalPlaceablesも配置する。
        }
    }
}

Layout関数とSubcomposeLayout関数の違い

まとめると、

  • Layout ... content引数でとったComposable関数を一度に測定し、カスタムルールに従って配置するための汎用的なAPI。 -> 中でComposable関数は呼べない。
  • SubcomposeLayout ... contentで引数にとった要素や他の内部で定義したComposable関数を遅延してmeasurableに変換でき、他の要素の測定結果に基づいて配置できるAPI。 -> 中でComposable関数を呼べる。

おまけ: Placeable.place.placeRelativeの使い分け

いろんなサイトでLayoutの解説記事を見ていると、Placeable.placeを使って配置している場合と、Placeable.placeRelativeを使って配置している場合の両方が散見されました。一体どう違うのか?
最高に分かりやすい解説がこちらにありました。これによれば、アラビア語系などの右横書き言語に対応しているかどうか、が主な違いだそうです。

  • .place ... Placeable を親の座標系の x、y に配置する関数。右横書き言語に対応していない。
  • .placeRelative ... Placeable を親の座標系の x、y に配置する関数。右横書き言語に対応している。

いざ実装

ではどう実装するか。最終的に以下のようになりました。

ListItemsWrapper.kt
ListItemsWrapper.kt
@Composable
fun ListItemsWrapper(
    modifier: Modifier = Modifier,
    cornerRadius: Dp = 10.dp,
    divider: @Composable () -> Unit = {
        Divider()
    },
    inset: Dp = 16.dp,
    content: @Composable () -> Unit,
) {
    Box(
        modifier = modifier
            .clip(RoundedCornerShape(cornerRadius))
            .wrapContentHeight()
            .background(MaterialTheme.colorScheme.background)
    ) {
        SubcomposeLayout(
            modifier = Modifier
        ) { constraints ->
            // ここで、contentとして引数にとった要素をmeasurable -> placeableに変換
            val contentMeasurables = subcompose("content", content)
            val contentPlaceables = contentMeasurables.map { it.measure(constraints) }
            // contentに入っている要素数を元に、Dividerのサイズを計算
            val dividerCount = contentPlaceables.size - 1
            // 上記の個数分だけ、Dividerを量産
            // なぜこれが必要かというと、一度配置したplaceableはもう配置できないため、
            // 配置したい個数分だけ用意しなくてはならないから。
            val dividerMeasurables = subcompose("divider") {
                repeat(dividerCount) {
                    Box(modifier = Modifier.padding(start = inset)) {
                        divider()
                    }
                }
            }
            val dividerPlaceables = dividerMeasurables.map { it.measure(constraints) }

            // 高さを事前に計算せずにmaxHeightにすると、無限高さになる場合があるため、
            // 固定値か上限があることを確認して使い、基本的には要素高さの合計から算出。
            val hasFixedHeight = constraints.hasFixedHeight
            val hasBoundedHeight = constraints.hasBoundedHeight
            val height = if (hasFixedHeight && hasBoundedHeight) {
                constraints.maxHeight
            } else {
                contentPlaceables.sumOf { it.height } + dividerPlaceables.sumOf { it.height }
            }

            // 子要素を配置していく
            layout(constraints.maxWidth, height) {
                var yPosition = 0

                contentPlaceables.forEachIndexed { index, placeable ->
                    // 一番上の要素の場合はDividerをつけない。
                    if (index > 0) {
                        val dividerPlaceable = dividerPlaceables[index - 1]
                        dividerPlaceables[index - 1].placeRelative(x = 0, y = yPosition)
                        // Dividerの高さ分も忘れず加算する。
                        yPosition += dividerPlaceable.height
                    }

                    placeable.placeRelative(x = 0, y = yPosition)
                    yPosition += placeable.height
                }
            }
        }
    }
}
ListItem.kt
ListItem.kt
@Composable
fun ListItem(
    text: String,
    modifier: Modifier = Modifier,
    subText: String? = null,
    leadingIcon: ImageVector? = null,
    trailingIcon: ImageVector? = null,
    onClick: (() -> Unit)? = null,
) {
    Box(
        modifier = modifier
            .then(
                onClick?.let {
                    Modifier.clickable(onClick = it)
                } ?: Modifier
            )
            .wrapContentSize()
            .background(MaterialTheme.colorScheme.background)
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                modifier = Modifier.weight(1f)
            ) {
                leadingIcon?.let {
                    Icon(
                        imageVector = it,
                        contentDescription = "leading icon",
                        modifier = Modifier.size(24.dp)
                    )
                }

                Column(
                    verticalArrangement = Arrangement.spacedBy(2.dp),
                ) {
                    Text(
                        text = text,
                        style = MaterialTheme.typography.bodyLarge,
                        color = MaterialTheme.colorScheme.onBackground,
                        modifier = Modifier.fillMaxWidth(),
                    )

                    subText?.let {
                        if (it.isNotBlank()) {
                            Text(
                                text = it,
                                style = MaterialTheme.typography.bodySmall,
                                color = MaterialTheme.colorScheme.onSurface,
                                modifier = Modifier.fillMaxWidth(),
                            )
                        }
                    }
                }
            }

            trailingIcon?.let {
                Icon(
                    imageVector = it,
                    contentDescription = "trailing icon",
                    modifier = Modifier.size(24.dp),
                )
            }
        }
    }
}

使い方:

SampleList.kt
ListItemsWrapper {
        ListItem(
            text = "Label",
            subText = "Detail",
            leadingIcon = Icons.Default.Face,
            trailingIcon = Icons.AutoMirrored.Default.ArrowForward,
            onClick = {}
        )
        ListItem(
            text = "Label",
            subText = "Detail",
            leadingIcon = Icons.Default.Face,
            trailingIcon = Icons.AutoMirrored.Default.ArrowForward,
            onClick = {}
        )
        ListItem(
            text = "Label",
            subText = "Detail",
            leadingIcon = Icons.Default.Face,
            trailingIcon = Icons.AutoMirrored.Default.ArrowForward,
            onClick = {}
        )
    }

プレビューはこんな感じ。
ListItem
ListItem

ListItemsWrapper

リストのアイテムを雑にcontent引数にぶちこむだけで、間にDividerが入って、全体が角丸になっています。これで非常に楽にリストUIがつくれそうです。

まとめ

  • カスタムレイアウトを使うと、Columnなどのようにcontent引数に入れたComposable関数を、リスト要素のようにして個別にレイアウトできる。
  • Layout関数とSubcomposeLayout関数が代表的に使え、前者はシンプルだが、最初に入力した全Composable関数を一度にMesurableにするため、要素数に合わせた処理などができない。後者は遅延コンポーズを行うため、内部で要素数に応じて新たにComposable関数を呼び出すこともできる、いわば上位互換。
  • Layout関数もSubcomposeLayout関数も中で行なっているのは以下の3ステップ
    1. すべての子を測定する
    2. ノード自体のサイズを決定する
    3. 子を配置する

おわりに

カスタムレイアウトを使ってみて、Composable関数がどう配置されているのか勉強になったのと、他にも適用できそうなコンポーネントが自分のコードの中にありそうだったので、改良したくなりました。レイアウトの知見を高めて、画面サイズの違いに耐性の強いUIや、一貫性の高いUIを作っていきたいと思います。アニメーションつきの動的なレイアウトにも挑戦したいです。

不備や間違いなどございましたら、ご遠慮なくコメントでご指摘いただければと思います。

Discussion