Jetpack Composeでグリッドに柔軟にスペースを入れたい

こんにちは、Androidエンジニアの @syarihu です。

ギフトモールでは、Jetpack Composeを積極的に使って開発をしています。Jetpack Composeでは基本的には簡単にいろいろなUIを作成することができますが、ときどき少し工夫をしないと期待通りのUIを実現できないことがあります。

今回は、Jetpack Composeによるグリッド表示グリッド間にスペースを入れる際にハマったこととその解決策について紹介したいと思います。

本記事は、shibuya.apk #41で発表した内容と同じものになります。発表資料を見たい方はこちらをご覧ください。

実現したいレイアウト

はじめに、今回実現したいレイアウトを実際の画面を使って説明します。

Jetpack Composeで縦方向にスクロールするグリッド表示を実現するにはLazyVerticalGridというComposable関数を使いますが、これを使って次のようなレイアウトを作成します。

  • あるアイテムではスペースなしで1行で表示したい
  • あるアイテムでは1行に3列表示するグリッドを作成し、そのグリッド内ではスペースを入れたい

言葉だけだと分かりにくいかもしれないので、次に実現したい具体的なレイアウトを示します。

上記の例をもとに説明すると、ヘッダーやフッターなどは画像を表示したりするので、paddingなしで表示し、商品などのアイテムを表示するグリッドではアイテムとアイテムの間にスペースを入れたい、といった感じになります。

一見すると簡単にできそうに思えますが、実はLazyVerticalGridのデフォルトの機能でこれを実現することは難しいです。具体的にどのような課題があるのか、実際のコードを使って見ていきましょう。

LazyVerticalGridの標準機能で頑張ってみる

まずはスペースを入れない通常のレイアウトを作成します。

LazyVerticalGrid(
    modifier = Modifier.fillMaxSize(),
    columns = GridCells.Fixed(count = GRID_COLUMN_SIZE)
) {
  item(span = { GridItemSpan(GRID_COLUMN_SIZE) }) {
    Box(
      modifier = Modifier
        .fillMaxWidth()
        .height(100.dp)
        .background(Color.DarkGray),
      contentAlignment = Alignment.Center
    ) {
      Text(text = "Header", color = Color.White)
    }
  }
  items(
    items = items,
    span = { GridItemSpan(1) },
  ) {
    Column(
      horizontalAlignment = Alignment.CenterHorizontally,
      modifier = Modifier
        .fillMaxWidth()
        .background(color = Color.LightGray)
                .padding(8.dp)
    ) {
      Image(
        painter = painterResource(id = R.drawable.ic_launcher_foreground),
        contentDescription = null
      )
      Text(text = it)
    }
  }
  item(span = { GridItemSpan(GRID_COLUMN_SIZE) }) {
    Box(
      modifier = Modifier
        .fillMaxWidth()
        .height(100.dp)
        .background(Color.DarkGray),
      contentAlignment = Alignment.Center
    ) {
      Text(text = "Footer", color = Color.White)
    }
  }
}

このレイアウトでグリッド表示をしている部分だけにLazyVerticalGridの標準機能を使ってスペースを入れられないか、いくつかのアプローチを考えます。

verticalArrangementやhorizontalArrangementでspacedByを使ってスペースを設定する

一番最初に、LazyVerticalGridでグリッドの間に空白を入れる際に最初に目に付きそうなspacedByを使ってみます。

LazyVerticalGrid(
  verticalArrangement = Arrangement.spacedBy(8.dp),
  horizontalArrangement = Arrangement.spacedBy(8.dp)
) {

これを実行すると次のようになります。

spacedByではグリッドアイテム間にのみスペースが入るため、左端と右端のアイテムにはスペースが入りません。また、すべてのアイテムにスペースが適用されてしまうため、スペースを設定したくないところにまで入ってしまうという欠点もあります。

そのため、このアプローチは使えないことが分かりました。

contentPaddingを使う

次に、contentPaddingを使ってみます。

LazyVerticalGrid(
  contentPadding = PaddingValues(8.dp)
) {

これを実行すると次のようになります。

LazyVerticalGridの上下左右にpaddingが設定されてしまいました。

今回のケースで言うと、ヘッダーの上と左右にはpaddingを設定したくないので、今回はこれは使えないことが分かります。

アイテムごとにpaddingを設定する

次に、itemsのitemContent内でpaddingを入れることを考えてみます。

Columnにpaddingを設定します。

items(
  items = items,
  span = { GridItemSpan(1) },
) {
  Column(
    horizontalAlignment = Alignment.CenterHorizontally,
    modifier = Modifier
      .fillMaxWidth()
      .padding(8.dp)
      .background(color = Color.LightGray)
      .padding(8.dp)
    ) {

背景色の外にpaddingを設定したいので、backgroundの前にpaddingの設定を追加しています。

これを実行すると次のようになります。

なんとなくできてそうに見えますが、1つのアイテムの上下左右にpaddingが設定されているため、アイテムとアイテムの間のpaddingが大きくなってしまいました。

アイテムによってpaddingを動的に変える

8dpのpaddingを設定したいので、アイテムのpaddingを4dpにして、グリッドの外側のpaddingだけ8dpにすることを考えてみます。

private const val GRID_COLUMN_SIZE = 3

// アイテムの右端と左端のindexを予め計算しておく
val startIndexes = (1..items.size)
    .map { GRID_COLUMN_SIZE * it - (GRID_COLUMN_SIZE - 1) }
    .toList()
val endIndexes = (1..items.size)
    .map { GRID_COLUMN_SIZE * it }
    .toList()

LazyVerticalGrid(
  modifier = Modifier.fillMaxSize(),
  columns = GridCells.Fixed(count = GRID_COLUMN_SIZE),
) {
  itemsIndexed(
    items = items,
    span = { _, _ -> GridItemSpan(1) },
  ) { index, item ->
    Column(
      horizontalAlignment = Alignment.CenterHorizontally,
      modifier = Modifier
        .fillMaxWidth()
        .padding(
            // topは一番上だけ8dpにしたいので、indexがカラムサイズを超えた場合に4dpに変える
            top = if (index < GRID_COLUMN_SIZE) 8.dp else 4.dp,
            // bottomは一番下だけ8dpにして、それ以外のアイテムは4dpにする
            bottom = if (index > items.size - GRID_COLUMN_SIZE + 1) 8.dp else 4.dp,
            // 左右のpaddingは、最初に定義した右端と左端のindexだった場合は8dpにして、それ以外は4dpにする
            start = if (index == 0 || startIndexes.contains(index + 1)) 8.dp else 4.dp,
            end = if (endIndexes.contains(index + 1)) 8.dp else 4.dp,
        )
        .background(color = Color.LightGray)
        .padding(8.dp)
    ) {

これを実行してみると、3列かつ特定の画面幅の場合は次のようにうまく表示されることもあります。

しかし、画面幅が変わると次のようにずれてしまいます

また、4列にした場合もこのようにずれてしまいます。

左端と右端だけpaddingを変えたことにより、左端と右端のアイテムサイズだけ広くなり、paddingが設定されていないアイテムが押し出されるので、このように真ん中のアイテムだけ少し長くなってしまいます。

そのため、このアプローチも使えないことがわかりました。

ちなみに、horizontalArrangementやhorizontalArrangementでspacedByを使ってスペースを設定した上で左端と右端に動的にpaddingを設定した場合も、アイテムサイズのズレが発生するため、このアプローチを使うことはできません。

いくつかのアプローチを試してみましたが、LazyVerticalGridの標準機能では今回実現したいレイアウトを作成することは難しいことがわかりました。

1行ごとにRowを生成し、自前でグリッドを作ってみる

デフォルトの機能で作れないのであれば自分でそういった仕組みを作るしかありません。

そこで考えたのが、グリッド表示したいアイテムを1行ごとの塊に分割し、そのアイテムの塊を自分でRowを使ってグリッドを表現する方法です。

Rowを使えばRowにpaddingを設定し、自分で列と列の間にSpacerを追加すればアイテムがずれることがないため、期待通りのレイアウトが実現できると考えました。

はじめに、アイテムを特定の個数の塊ごとに分割します。

アイテムを塊で分割するには、Kotlinのchunked関数を使えば簡単にできるので、これを利用します。

const val chunkSize = 3
val chunkedItems = items.chunked(chunkSize)

今回は3列にしたいので、chunked関数に3を渡しています。

次に、この塊ごとにRowを使ってレイアウトを作ります。

items(
  items = chunkedItems,
  span = { GridItemSpan(chunkSize) },
) { chunkedItems ->
  Row(
    Modifier
      .fillMaxWidth()
      // Rowの左右にpaddingを入れる。
      // 上下でpaddingが重ならないように上下ではtopのみにpaddingを入れる
      .padding(top = 8.dp, start = 8.dp, end = 8.dp),
    ) {
    chunkedItems.forEachIndexed { index, item ->
      // アイテムサイズが均等になるようにweightを1にする
      Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
        // ここにアイテムのレイアウトを書く
        itemContent(item)
      }
      // アイテムとアイテムの間にスペースを入れる
      // chunkedItemsがchunkSizeよりも小さくなる場合はlastIndexの条件に当てはまらなくなるので
      // 条件に入るようにしておく
      if (index < chunkedItems.lastIndex || chunkedItems.size < chunkSize) {
        Spacer(modifier = Modifier.size(rowSpace))
      }
    }
    val spacerLastIndex = chunkSize - chunkedItems.size - 1
    // 最後行ではカラムサイズよりもchukedItemsの数の方が少ないケースがあるため、
    // 空のアイテムで埋める
    repeat(chunkSize - chunkedItems.size) { index ->
      // 空のアイテムも通常のアイテムと均等になるように1にする
      Box(modifier = Modifier.fillMaxWidth().weight(1f))
      if (index < spacerLastIndex) {
        // 空のアイテムにもスペースが入っていないとズレるので、
        // アイテムとアイテムの間にスペースを入れる
        Spacer(modifier = Modifier.size(rowSpace))
      }
    }

色々と書いてありますが、基本的には次のようなことをしています。

  • アイテムをchunked(決まった数ごと)で取り出す
  • chunkごとにRowを生成し、取り出したアイテムをそのRow内でweight=1fで均等に表示する
  • アイテムが列数より少なくなるケースでは足りないアイテム分、weight=1fのBoxで埋める

自分でRowを使ってグリッドを表現しているため、これでグリッド内のスペースを自分でコントロールできるようになりました。

あとはこれを通常のitemsのように汎用的に使える拡張関数を作成します。

inline fun <T> LazyGridScope.chunkedItems(
  items: List<T>,
  chunkSize: Int,
  chunkedContentPadding: PaddingValues = PaddingValues(0.dp),
  rowSpace: Dp = 0.dp,
  noinline key: ((item: List<T>) -> Any)? = null,
  noinline span: (LazyGridItemSpanScope.(item: List<T>) -> GridItemSpan)? = null,
  noinline contentType: (item: List<T>) -> Any? = { null },
  crossinline itemContent: @Composable LazyGridItemScope.(item: T) -> Unit,
) = items(
  items = items.chunked(chunkSize),
  span = span,
  key = key,
  contentType = contentType,
) { chunkedItems ->
  Row(
    Modifier
      .fillMaxWidth()
      .padding(chunkedContentPadding),
    ) {
    chunkedItems.forEachIndexed { index, item ->
      Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
        itemContent(item)
        }
      if (index < chunkedItems.lastIndex || chunkedItems.size < chunkSize) {
        Spacer(modifier = Modifier.size(rowSpace))
      }
    }
    val lastIndex = chunkSize - chunkedItems.size - 1
    repeat(chunkSize - chunkedItems.size) { index ->
      Box(modifier = Modifier.fillMaxWidth().weight(1f))
      if (index < lastIndex) {
        Spacer(modifier = Modifier.size(rowSpace))
      }
    }
  }
}

これを実行してみると次のようになります。

3列の場合 4列の場合

3列の場合はもちろんのこと、列数を変えて4列にしてみても正しくグリッドが表示でき、期待通りアイテムの間とグリッドの上下左右にスペースを入れることができました。

おわりに

LazyVerticalGridの標準機能では、今回実現したいレイアウトを作成することはできませんでしたが、少し工夫することで無事に期待通りのレイアウトを実現することができました。

本当は自前で作らず公式で機能を提供していてくれると嬉しいところではありますが、こういったカスマイズがしやすいところもComposeの良いところかなと思っています。

今回作成したサンプルコードは以下のgistに公開しています。

今回は紹介しませんでしたが、itemsIndexedのようにindexを入れたいケースのサンプルコードも記載していますので、よろしければ参考にしてみてください。


ギフトモールでは、一緒に働く仲間を募集しています!

まずは一度話してみるだけなどのカジュアル面談も歓迎ですので、少しでも興味を持った方はお気軽に連絡ください!

ギフトモールCulture Deck

speakerdeck.com

募集職種一覧

open.talentio.com