and factoryの2024年アドベントカレンダーの記事です。
はじめに
Android Jetpack ComposeでのGrid(グリッド)表示をする場合に、まず一番最初に思いつくのがLazyVerticalGridだと思います。
しかし、Composeは同一方向へのLazyListのネストは許容されていません。例えばすでにLazyColumnを使っている画面で部分的にグリッド表示をしたい場合は全体をLazyVerticalGridで実装するか、あるいは別の実装の仕方を考慮する必要があります。
このQiitaでは他の方法でのGrid表示を含め自分の知っている実装の仕方をまとめてみたいと思います。
表示する要素
グリッド要素としてBoxを用意しました。
@Composable
fun BoxItem(modifier: Modifier = Modifier) {
Box(modifier = modifier
.width(100.dp)
.height(150.dp)
.background(Color.Gray)
)
}
また、グリッド内での横幅一杯表示用の要素としてバナーBoxも用意しました。
@Composable
fun BannerBoxItem(modifier: Modifier = Modifier) {
Box(modifier = modifier
.width(300.dp)
.height(60.dp)
.background(Color.Gray)
)
}
LazyVerticalGrid
一番シンプルな実装方法としてまずはLazyVerticalGridがあります。この表示はLazyListを使っているので巨大コンテンツを遅延読込して一番効率的に表示することができます。
例えばグリッド要素数が不定であったり、50件、100件以上あるなどコンテンツ数が多い場合はおそらくこれ一択です。
@Composable
fun LazyVerticalGridSample(modifier: Modifier = Modifier) {
LazyVerticalGrid(
modifier = modifier,
columns = GridCells.Fixed(4),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item(span = { GridItemSpan(4) }) {
BannerBoxItem(modifier = Modifier.fillMaxWidth().aspectRatio(300f / 60f))
}
items(100) { index ->
BoxItem(modifier = Modifier.fillMaxWidth().aspectRatio(100f / 150f))
}
}
}
シンプルな表示の例です。
LazyVerticalGridの場合は、columnsにFixedを指定すると指定した要素でwidthいっぱいになるように配置してくれます。この際に比率は考慮してくれないため、子要素側でaspectを指定する必要があります。
また子要素側で GridItemSpan
を使うことでその子要素がいくつ分確保するかを指定することができます。今回はFixedで4等分指定をしているので、 GridItemSpan(4)
とすることでバナーを横幅いっぱいに表示しています。
@Composable
fun LazyVerticalGridSample(modifier: Modifier = Modifier) {
LazyVerticalGrid(
modifier = modifier,
columns = GridCells.Adaptive(minSize = 100.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(100) { index ->
BoxItem(modifier = Modifier.fillMaxWidth().aspectRatio(100f / 150f))
}
}
}
columnsにAdaptiveを指定した場合は要素のサイズのまま配置できる要素数分でグリッド表示をしてくれます。
LazyColumnとは組み合わせることができない
これはComposeを実装するうえでの悩みの種ですが、LazyListは同一方向へのネストはサポートしてません。例えばLazyColumnとLazyRowは縦横方向の組み合わせのため実装することができますが、LazyColumn内にLazyVerticalGridは実装することができません。
@Composable
fun LazyVerticalGridSample(modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier.fillMaxSize()) {
item { Spacer(Modifier.height(16.dp)) }
item {
// これは配置可能
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(10) {
BoxItem()
}
}
}
item { Spacer(Modifier.height(16.dp)) }
item {
// 同一方向のLazyListのためエラーになる
LazyVerticalGrid(
modifier = modifier,
columns = GridCells.Fixed(4),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item(span = { GridItemSpan(4) }) {
BannerBoxItem(modifier = Modifier.fillMaxWidth().aspectRatio(300f / 60f))
}
items(100) { index ->
BoxItem(modifier = Modifier.fillMaxWidth().aspectRatio(100f / 150f))
}
}
}
item {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(10) {
BoxItem()
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
FlowRowを使ったGrid表示
LazyListを使わずに単純な表示としてグリッドを表示する方法としてはFlowRowを使うのが最もシンプルでおすすめです。FlowRowは複雑なUI構造を組み立てるのに使えるComposableです。
Android developersにもまさしくグリッド表示をするためのページが用意されています。
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun FlowRowGridSample(modifier: Modifier = Modifier) {
val gridColumn = 3
val itemNumber = 11
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
maxItemsInEachRow = gridColumn,
) {
BannerBoxItem(modifier = Modifier.fillMaxWidth().aspectRatio(300f / 60f))
repeat(itemNumber) {
BoxItem(
modifier = Modifier.weight(1f).aspectRatio(100f / 150f)
)
}
// グリッドの余白を埋めるための要素
repeat(gridColumn - itemNumber % gridColumn) {
Spacer(
modifier = Modifier.weight(1f).aspectRatio(100f / 150f)
)
}
}
}
グリッドの要素数はmaxItemsInEachRowに指定することができます。子要素側では weight(1f)
を指定することで指定した個数で等分してくれます。
また、上部バナー表示のようにmaxItemsInEachRowに関わらず横幅いっぱいに伸ばしたい場合は weight
を使わずに fillMaxWidth()
を使うだけでOKです。
1つ注意点としては、表示する要素数がきれいに等分できない場合、つまり上記の例であれば3つ表示のグリッドに対して要素が11個のため1つぶん足りないという場合です。LazyVerticalGridの場合はそういう場合もよしなにしてくれますが、FlowRowの場合は weight(1f)
指定なので最終行だけ2つで一杯表示になります。
これを回避するには上記例のように最後に余白のSpacerなどを置くことで回避することができます。
バグ?めり込んで正しく表示されない場合
以前のFlowRowとComposeバージョンでは上記の書き方で問題なく表示できたのですが、2024年11月現在のバージョンではバグなのか正しく表示されなくなってしまいました。aspectを使って表示すると要素がめり込んで表示されるような挙動になってしまったため、もし同じ症状が出た場合は次のRowとColumnを使った実装をしてみてください。
RowとColumnをつかったGrid表示
シンプルにRowとColumnを使ってグリッド表示をすることもできます。単純な要素で作成しているため意外とシンプルで使い勝手が良く、自分はこれを使って実装することが多いです。
@Composable
fun RowColumnGridSample(modifier: Modifier = Modifier) {
val gridColumn = 3
val itemNumber = 11
Column(modifier = modifier) {
BannerBoxItem(modifier = Modifier
.fillMaxWidth()
.aspectRatio(300f / 60f))
val rowGroup = (1..itemNumber).toList().chunked(gridColumn)
rowGroup.forEachIndexed { rowIndex, rowItems ->
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
rowItems.forEachIndexed { index, item ->
BoxItem(
modifier = Modifier
.weight(1f)
.aspectRatio(100f / 150f)
)
}
// グリッドの余白を埋めるための要素
repeat(gridColumn - rowItems.size) {
Spacer(
modifier = Modifier
.weight(1f)
.aspectRatio(100f / 150f)
)
}
}
}
}
}
基本的な実装の仕方はFlowRowと同じく weight(1f)
を使って要素の等分を表示をしています。表示するためのリスト要素を作成するのにKotlinの chunked
を使っています。今回の例では対象のデータというのはないので、1から11までの数字のリストをあえて作ってグリッド表示のしやすいデータ構造を作成しました。
val items = (1..11).toList() // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
val gridRows = items.chunked(3) // [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11]]
LazyColumn内で使う
RowとColumnを使った実装と、FlowRowを使った実装の場合はLazyListを使っていないためLazyColumnの中などでも問題なく使うことができます。
LazyColumn {
item { Spacer(Modifier.height(8.dp)) }
item {
// LazyColumn in LazyRow
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(10) {
BoxItem()
}
}
}
item { Spacer(Modifier.height(8.dp)) }
item {
// LazyColumn in Grid
RowColumnGridSample(modifier = Modifier.fillMaxSize())
}
item {
// LazyColumn in LazyRow
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(10) {
BoxItem()
}
}
}
item { Spacer(Modifier.height(8.dp)) }
}
注意点として、グリッド表示部分は遅延読み込みはされず一括で読み込まれます。そのためデータ量が多い場合は注意です。
LazyColumn内で大量データのグリッド表示がしたい
どうしてもLazyColumn内で大量のデータをグリッド表示したい場合はどうすればいいでしょうか。力技ですが2つやり方を考えてみました。
1つめはColumnとRowを使ったグリッド実装を活用したやり方です。
この例では一気にグリッド表示をするのではなく事前にデータを分割してグリッド表示を区切ってい表示することで item{}
の単位を分割して遅延ロードさせるようにしてみました。
val items = (1..101).toList()
val gridItems = items.chunked(9) // 3*3ずつ表示する
LazyColumn {
item { Spacer(Modifier.height(8.dp)) }
item {
// これは配置可能
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(10) {
BoxItem()
}
}
}
gridItems.forEachIndexed { index, rowItems ->
item {
RowColumnGridSample(modifier = Modifier.fillMaxSize(), items = rowItems)
}
}
item { Spacer(Modifier.height(8.dp)) }
item {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(10) {
BoxItem()
}
}
}
item { Spacer(Modifier.height(8.dp)) }
}
2つ目は実装的にパット見グリッド感がなくなりますが、そもそも各業をitem{}で呼び出す実装です。読みづらいですが実装のやり方としては変に分割するよりもわかりやすい気がします。
val items = (1..101).toList()
val gridItems = items.chunked(9) // 3*3ずつ表示する
LazyColumn {
item { Spacer(Modifier.height(8.dp)) }
item {
// これは配置可能
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(10) {
BoxItem()
}
}
}
gridItems.forEachIndexed { index, rowItems ->
item {
RowColumnGridSample(modifier = Modifier.fillMaxSize(), items = rowItems)
}
}
item { Spacer(Modifier.height(8.dp)) }
item {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(10) {
BoxItem()
}
}
}
item { Spacer(Modifier.height(8.dp)) }
}