Jetpack ComposeのLazyRowカルーセルで見える要素数を指定する


Jetpack ComposeでLazyRowを使ったカルーセル表示をした際に、指定の要素数は必ず見えるように実装してみました🙂

今回実装したサンプルコードはこちらにおいてます

https://github.com/yasukotelin/JetpackComposeCarouselitemsonscreen

## まえおき
カルーセル表示は横方向へのリスト表示ですが、ディスプレイやコンテンツサイズとの兼ね合いで運が悪いとちょうど収まってしまい、ユーザーがスクロールできることを認知できない可能性があります。
そういうケースを想定して、必ず次の要素がチラ見えするように、3.2個表示などをしておくとユーザーにとても親切です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/651fe2d7-2f01-50a9-54b9-ad56c034989c.gif" width="320" />

↑ このようにちょうどコンテンツがディスプレイに収まってしまっているとスクロール可能であることがわからない

## まずは通常のカルーセル表示

まずは通常のカルーセル表示です。Android　ViewのRecyclerViewを使う場合はカルーセル表示は大変でしたが、Jetpack ComposeではLazyRowを使うだけで実装できてしまいます🤗

:::note warn
URLから画像を読み込むのにcoilを使っていますが、2.0系でAPIが変わったので適宜読み替えてください。この記事では1.4を使っています。
:::


```kotlin
@Composable
fun MainScreen() {
    Scaffold(modifier = Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
        ) {
            ImageCarousel(
                modifier = Modifier.fillMaxWidth(),
                contentPadding = PaddingValues(horizontal = 16.dp)
            )
        }
    }
}

@Composable
fun ImageCarousel(
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp)
) {
    LazyRow(
        contentPadding = contentPadding,
        modifier = modifier,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(10) {
            Image(
                painter = rememberImagePainter(
                    data = "https://placehold.jp/3d4070/ffffff/150x150.png",
                ),
                contentDescription = null,
                modifier = Modifier
                    .size(150.dp)
                    .background(Color(0xFFEDEDED)),
            )
        }
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/fa3a3691-ee26-b214-d371-48a172b50f4d.gif" width="320" />

150px*150pxの画像をURLから取ってきて表示しています。とくにサイズ指定などはしていませんが、Pixel 3aの場合は2.4個程度の表示になってます。もちろん、画像サイズのまま表示しているので、違うスマートフォンで見た場合にどのようにカルーセル表示されるかはわかりません。

細かいところとして、contentPaddingの部分で `PaddingValues(horizontal = 16.dp)` を指定しています。これは、LazyRowの最初との要素のstartと最後の要素のendに16dpのpaddingを取る指定です。通常のmodifierでLazyRow自体にpaddingを指定するのとは違い、contentPaddingを使うとLazyRow自体のサイズは横幅一杯にして、最初と最後の要素にだけpaddingをつけることができます。

また、要素同士のpaddingは8dpにしています。

### 要素数を指定してカルーセル表示する

それでは、同じ150*150の画像を読み込んだ場合でも、3.2個表示したいと思ったらそう表示されるように実装してみます。

```kotlin
@Composable
fun MainScreen() {
    Scaffold(modifier = Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
        ) {
            ImageCarousel(
                itemNumber = 3.2f,
                modifier = Modifier.fillMaxWidth(),
                startPadding = 12.dp,
                endPadding = 12.dp,
            )
        }
    }
}

@Composable
fun ImageCarousel(
    itemNumber: Float,
    modifier: Modifier = Modifier,
    startPadding: Dp = 0.dp,
    endPadding: Dp = 0.dp,
) {
    BoxWithConstraints(
        modifier = modifier,
    ) {
        val space = 8.dp
        val spacedCount = itemNumber.absoluteValue
        val itemWidth = (maxWidth - startPadding - (space * spacedCount)) / itemNumber

        LazyRow(
            contentPadding = PaddingValues(start = startPadding, end = endPadding),
            horizontalArrangement = Arrangement.spacedBy(space),
        ) {
            items(10) {
                Box(
                    modifier = Modifier
                        .width(itemWidth)
                        .wrapContentHeight()
                ) {
                    Image(
                        painter = rememberImagePainter(
                            data = "https://placehold.jp/3d4070/ffffff/150x150.png",
                        ),
                        contentScale = ContentScale.Fit,
                        contentDescription = null,
                        modifier = Modifier
                            .aspectRatio(1f)
                            .fillMaxWidth()
                            .background(Color(0xFFEDEDED)),
                    )
                }
            }
        }
    }
}
```

`ImageCarousel` Composeの引数の `itemNumber` に要素数3.2fを指定することにより、下記の画像のように表示されました🙂

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/39e81f2f-b236-8d43-f9de-f4206c8eeca3.png" width=320 />

また、どうように `itemNumber`　に5.2fと指定して横向きにしてみてもきちんと要素数指定通りに表示されています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/42c90e9e-cac5-bc3e-3481-689a0b4e8a57.png" width="420" />

### 実装ポイント

要素数分だけカルーセル表示をする仕組みは簡単で、 `（LazyRow自体の横幅　/ 要素数）` をして要素のwidthを取得して、それをImageのmodifierに指定しているだけです。

LazyRowの横幅を取得するために、[BoxWithConstraints](https://foso.github.io/Jetpack-Compose-Playground/foundation/layout/boxwithconstraints/)を使います。BoxWithConstraintsを使うことで自身のminWidthとmaxWidthを取得することができるので、LazyRowをBoxWithConstraintsでラップして、maxWidthを見ることでLazyRowの横幅を取得することができます。

また、単純に　`（LazyRow自体の横幅　/ 要素数）`　をしてもおそらくうまくいかないと思います。大抵の場合LazyRowの要素間にスペースや前述したcontentPaddingを設けているためです。そこでそれらのpadding分も考慮して要素のwidthを取得します。

```kotlin
val space = 8.dp
val spacedCount = itemNumber.absoluteValue
val itemWidth = (maxWidth - startPadding - (space * spacedCount)) / itemNumber
```

その処理がここの部分です。LazyRowの横幅から、contentPaddingと要素のスペース分を引いた幅を要素数で割ることで、要素が取るべきwidthが取得できます。

あとはこのwidthを各要素のmodifierに指定します。

### Imageの注意点

~~今回の例でもImageを使っていますが、Imageを使用する際は比率を指定しておく必要がある点に注意してください。今回は `150*150` の正方形サイズなので、 `modifier = Modifier.aspectRatio(1f)` で指定しています。
`150*120` のような画像比率の場合は、　`modifier = Modifier.aspectRatio(150/120f)` と指定することで期待通りに動作します。
これは、画像の比率がわからないと、横幅指定したときに縦幅が決まらないためです。画像のサイズが不定の場合はうまくいかないかもしれないので注意してください（サイズ不定でも色々組み合わせて頑張ればできるかも）~~

URL読み込みで使用しているCoilが、io.coil-kt:coil-compose:2.0.0-rc02にてAPIが変わり、AsyncImageというものを使うようになりました。こっちを使うようにすると比率指定などしなくても、読み込んだ画像の比率で適切に表示されるようになっていました。

修正後のコードはこんな感じです。
ImageからAsyncImageになって、aspectRatioで指定しなくてもよしなに可変して表示できるようになってました。

```kotlin
@Composable
fun ImageCarousel(
    itemNumber: Float,
    modifier: Modifier = Modifier,
    startPadding: Dp = 0.dp,
    endPadding: Dp = 0.dp,
) {
    BoxWithConstraints(
        modifier = modifier,
    ) {
        val space = 8.dp
        val spacedCount = itemNumber.absoluteValue
        val itemWidth = (maxWidth - startPadding - (space * spacedCount)) / itemNumber

        LazyRow(
            contentPadding = PaddingValues(start = startPadding, end = endPadding),
            horizontalArrangement = Arrangement.spacedBy(space),
        ) {
            items(10) {
                Box(
                    modifier = Modifier
                        .width(itemWidth)
                        .wrapContentHeight()
                ) {
                    AsyncImage(
                        model = ImageRequest.Builder(LocalContext.current)
                            .data("https://placehold.jp/3d4070/ffffff/150x120.png")
                            .crossfade(true)
                            .build(),
                        contentDescription = null,
                        contentScale = ContentScale.FillWidth,
                        modifier = Modifier
                            .fillMaxWidth()
                            .background(Color(0xFFEDEDED))
                    )
                }
            }
        }
    }
}
```

### ContentPaddingとSpaceの注意点

contentPaddingのpadding指定と、要素間のスペースのpadding指定のサイズが異なると、最初の表示だけ期待通りの要素数表示になりますが、スクロールしたほうの要素表示に関しては微妙に要素数表示がずれることに注意してください。

つまりどういうことかというと、今回の例ではcontentPaddingとして12dp、アイテム間のspaceとして8dpを指定しています。スクショのように初期の表示としては期待通り3.2f表示になっていますが、これをスクロールしてみると、以降の表示に関しては3.4f表示のようになります。
これは横幅を計算している処理でcontentPaddingを考慮しているためです。

これを常に期待通りのさせるためには、contentPaddingで指定するdpとspaceで指定するｄｐを同じにしてあげることで回避できます。
例で言えば、contentPaddingとして8dp、アイテム間のspaceとしても8dpにすることで、どこで止めても3.2f表示にできます。