リストとグリッド

多くのアプリでは、アイテムのコレクションを表示する必要があります。このドキュメントでは、Jetpack Compose でこれを効率的に行う方法について説明します。

ユースケースでスクロールを必要としない場合は、次のように、方向に応じて単純な Column または Row を使用して、リストの反復処理によって各アイテムのコンテンツを出力することをおすすめします。

@Composable
fun MessageList(messages: List<Message>) {
    Column {
        messages.forEach { message ->
            MessageRow(message)
        }
    }
}

Column をスクロール可能にするには、verticalScroll() 修飾子を使用します。

Lazy リスト

多数のアイテム(または長さが不明なリスト)を表示する必要がある場合、Column などのレイアウトを使用するとパフォーマンスの問題が発生します。これは、表示されるかどうかにかかわらず、すべてのアイテムについてコンポーズと配置が行われるためです。

Compose には、コンポーネントのビューポートに表示されるアイテムのみをコンポーズして配置するコンポーネントが複数用意されています。これらのコンポーネントには、LazyColumnLazyRow などがあります。

名前が示すように、LazyColumnLazyRow は、アイテムを配置してスクロールする方向が異なります。LazyColumn は縦方向にスクロールするリストを生成し、LazyRow は横方向にスクロールするリストを生成します。

Lazy コンポーネントは、ほとんどの Compose のレイアウトと異なります。Lazy コンポーネントでは、アプリがコンポーザブルを直接出力できるようにするため、@Composable コンテンツ ブロック パラメータを受け入れるのではなく、LazyListScope.() ブロックを提供します。この LazyListScope ブロックは、アプリがアイテムのコンテンツを記述できるようにする DSL を提供します。Lazy コンポーネントはその後、レイアウトとスクロールの位置で必要とされる各アイテムのコンテンツを追加します。

LazyListScope DSL

LazyListScope の DSL には、レイアウト内のアイテムを記述するためのさまざまな関数が用意されています。基本的には、item() では単一のアイテムを追加し、items(Int) では複数のアイテムを追加します。

LazyColumn {
    // Add a single item
    item {
        Text(text = "First item")
    }

    // Add 5 items
    items(5) { index ->
        Text(text = "Item: $index")
    }

    // Add another single item
    item {
        Text(text = "Last item")
    }
}

また、List など、アイテムのコレクションを追加できる拡張関数も多数あります。これらの拡張関数により、上記の Column の例を簡単に移行できます。

/**
 * import androidx.compose.foundation.lazy.items
 */
LazyColumn {
    items(messages) { message ->
        MessageRow(message)
    }
}

また、インデックスを提供する itemsIndexed() と呼ばれる items() 拡張関数のバリアントも用意されています。詳細については、LazyListScope のリファレンスをご覧ください。

Lazy グリッド

LazyVerticalGrid コンポーザブルと LazyHorizontalGrid コンポーザブルは、アイテムのグリッド表示に対するサポートを提供しています。Lazy 垂直グリッドは、複数の列にわたって、上下にスクロール可能なコンテナにアイテムを表示し、Lazy 水平グリッドは横軸でそれと同じように動作します。

グリッドはリストと同じ強力な API 機能を備えており、コンテンツの説明にもほぼ同じ DSL(LazyGridScope.())を使用します。

写真のグリッドが表示されているスマートフォンのスクリーンショット

LazyVerticalGridcolumns パラメータと LazyHorizontalGridrows パラメータは、セルを列または行に配置する方法を制御します。次の例では、GridCells.Adaptive を使用して各列の幅を 128.dp 以上に設定し、グリッド形式でアイテムを表示しています。

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 128.dp)
) {
    items(photos) { photo ->
        PhotoItem(photo)
    }
}

LazyVerticalGrid を使用するとアイテムの幅を指定でき、グリッドに可能な限り多くの列が収まるようになります。残りの幅は、列の数が計算された後、列間で均等に分配されます。このような適応型のサイズ調整方法は、さまざまな画面サイズでアイテムのセットを表示する場合に特に便利です。

使用する列の数を正確に把握している場合は、必要な列の数を含む GridCells.Fixed のインスタンスを指定できます。

デザイン上、特定のアイテムに標準以外のディメンションが必要な場合は、グリッドのサポートを使用してアイテムにカスタム列スパンを指定できます。列スパンを指定するには、LazyGridScope DSLitem または items メソッドの span パラメータを使用します。スパンの対象範囲の値の 1 つである maxLineSpan は、列の数が固定されていないため、適応型のサイズ調整を使用する場合に特に便利です。次の例は、行スパン全体を指定する方法を示しています。

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 30.dp)
) {
    item(span = {
        // LazyGridItemSpanScope:
        // maxLineSpan
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
    // ...
}

遅延スタッガード グリッド

LazyVerticalStaggeredGridLazyHorizontalStaggeredGrid は、アイテムのラジー読み込みのずらしたグリッドを作成できるコンポーザブルです。遅延垂直スタッガード グリッドは、複数の列にまたがる垂直方向にスクロール可能なコンテナにアイテムを表示し、個々のアイテムの高さをさまざまに変えることができます。遅延読み込みの水平グリッドでは、幅の異なるアイテムが水平軸に表示される動作は同じです。

次のスニペットは、アイテムごとに 200.dp の幅で LazyVerticalStaggeredGrid を使用する基本的な例です。

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Adaptive(200.dp),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)

図 1. 遅延スタッガードの垂直グリッドの例

固定数の列を設定するには、StaggeredGridCells.Adaptive ではなく StaggeredGridCells.Fixed(columns) を使用します。これにより、使用可能な幅を列(水平グリッドの場合は行)の数で割り、各アイテムがその幅(水平グリッドの場合は高さ)を占めるようになります。

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Fixed(3),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)

Compose で遅延スタッガードの画像グリッド
図 2. 固定列の遅延型のずらした縦型グリッドの例

コンテンツのパディング

場合によっては、コンテンツの端にパディングを追加する必要があります。Lazy コンポーネントを使用して PaddingValuescontentPadding パラメータに渡すことで、パディングをサポートできます。

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
    // ...
}

この例では、コンテンツの左右両端に 16.dp、上下両端に 8.dp のパディングを追加しています。

ただし、パディングが適用されるのは LazyColumn 自体に対してでなく、コンテンツに対してであることに注意してください。上記の例では、最初のアイテムの上部に 8.dp、最後のアイテムの下部に 8.dp、すべてのアイテムの左右に 16.dp のパディングがそれぞれ追加されます。

コンテンツの間隔

アイテム間の間隔を追加するには、Arrangement.spacedBy() を使用します。次の例では、各アイテムの間に 4.dp のスペースを追加しています。

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

LazyRow も同様です。

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

グリッドは、垂直方向と水平方向の両方の配置を受け入れます。

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    items(photos) { item ->
        PhotoItem(item)
    }
}

アイテムのキー

デフォルトでは、各アイテムの状態には、リストまたはグリッド内のアイテムの位置に対応したキーが指定されます。ただし、データセットを変更する場合には問題となることがあります。これは、位置を変更したアイテムについて記憶された状態が実質的に失われるためです。たとえば、LazyColumn 内の LazyRow のシナリオで行内のアイテムの位置を変更すると、ユーザーはその行内でのスクロール位置を失うこととなります。

この問題に対処するため、key パラメータにブロックを指定して、各アイテムに安定した一意のキーを指定します。次のように安定したキーを指定することで、データセットの変更時にアイテムの状態を一貫させることができます。

LazyColumn {
    items(
        items = messages,
        key = { message ->
            // Return a stable + unique key for the item
            message.id
        }
    ) { message ->
        MessageRow(message)
    }
}

キーを指定することで、Compose が並べ替えを正しく処理できるようになります。たとえば、アイテムに記憶された状態が含まれている場合、キーを設定すると、位置が変更されたときに、Compose がこの状態をアイテムと一緒に移動できるようになります。

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = remember {
            Random.nextInt()
        }
    }
}

ただし、アイテムのキーとして使用できる型については制限が 1 つあります。キーの型は、アクティビティの再作成時に状態を保持する Android のメカニズム Bundle でサポートされている必要があります。Bundle は、プリミティブ型、列挙型、Parcelable などをサポートしています。

LazyColumn {
    items(books, key = {
        // primitives, enums, Parcelable, etc.
    }) {
        // ...
    }
}

アクティビティが再作成されるとき、またはアイテムからスクロールして離れてから戻るときに、アイテム コンポーザブル内の rememberSaveable を復元できるように、キーは Bundle でサポートされている必要があります。

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = rememberSaveable {
            Random.nextInt()
        }
    }
}

アイテム アニメーション

RecyclerView ウィジェットでは自動的にアイテムの変更をアニメーション化できますが、Lazy レイアウトにもアイテムの並べ替えで同じ機能を使用できます。API はシンプルで、animateItem 修飾子をアイテムのコンテンツに設定するだけです。

LazyColumn {
    // It is important to provide a key to each item to ensure animateItem() works as expected.
    items(books, key = { it.id }) {
        Row(Modifier.animateItem()) {
            // ...
        }
    }
}

必要に応じて、カスタムのアニメーション仕様を指定することもできます。

LazyColumn {
    items(books, key = { it.id }) {
        Row(
            Modifier.animateItem(
                fadeInSpec = tween(durationMillis = 250),
                fadeOutSpec = tween(durationMillis = 100),
                placementSpec = spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy)
            )
        ) {
            // ...
        }
    }
}

移動した要素の新しい位置を見つけられるように、アイテムにキーを指定するようにしてください。

固定ヘッダー(試験運用版)

「固定ヘッダー」パターンは、グループ化されたデータのリストを表示する際に役立ちます。次の例では、「連絡先リスト」が、各連絡先のイニシャルごとにグループ化されています。

スマートフォンで連絡先リストを上下にスクロールしている動画

LazyColumn で固定ヘッダーを実現するには、試験運用版の stickyHeader() 関数を使用して、次のようにヘッダーのコンテンツを指定します。

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader {
            Header()
        }

        items(items) { item ->
            ItemRow(item)
        }
    }
}

上記の「連絡先リスト」の例のように、複数のヘッダーを持つリストを作成するには、次のようにします。

// This ideally would be done in the ViewModel
val grouped = contacts.groupBy { it.firstName[0] }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}

スクロール位置への反応

多くのアプリでは、スクロール位置とアイテム レイアウトの変更に反応してリッスンする必要があります。Lazy コンポーネントは、LazyListState をホイストすることでこのユースケースをサポートします。

@Composable
fun MessageList(messages: List<Message>) {
    // Remember our own LazyListState
    val listState = rememberLazyListState()

    // Provide it to LazyColumn
    LazyColumn(state = listState) {
        // ...
    }
}

シンプルなユースケースでは、通常、アプリで最初に表示されるアイテムに関する情報だけが必要です。このため、LazyListState には、firstVisibleItemIndex プロパティと firstVisibleItemScrollOffset プロパティが用意されています。

次の例では、ユーザーが最初のアイテムより下にスクロールしたかどうかに基づいてボタンを表示または非表示にしています。

@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

他の UI コンポーザブルを更新する必要があるときは、コンポジションで状態を直接読み取るのが便利ですが、イベントを同じコンポジションで処理する必要がない場合もあります。この一般的な例として、ユーザーが特定の地点より下にスクロールしたときにアナリティクス イベントを送信する場合があります。これを効率的に処理するには、snapshotFlow() を使用します。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

また、LazyListState は、現在表示されているすべてのアイテムとそれらの画面上の境界に関する情報を、layoutInfo プロパティを介して提供します。詳細については、LazyListLayoutInfo クラスをご覧ください。

スクロール位置の制御

アプリでスクロール位置に反応するだけでなく、その制御まで行えるとさらに便利です。LazyListState は、スクロール位置に即座にスナップする scrollToItem() 関数と、アニメーションを使用したスクロール(スムーズ スクロールとも呼ばれます)を行う animateScrollToItem() 関数によってこれをサポートします。

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    // Remember a CoroutineScope to be able to launch
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) {
        // ...
    }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                // Animate scroll to the first item
                listState.animateScrollToItem(index = 0)
            }
        }
    )
}

大規模なデータセット(ページング)

Paging ライブラリを使用すると、アプリでアイテムの多数のリストをサポートし、必要に応じてリストの小さなチャンクを読み込んで表示できます。Paging 3.0 以降では、androidx.paging:paging-compose ライブラリにより Compose サポートを提供しています。

ページングされたコンテンツのリストを表示するには、collectAsLazyPagingItems() 拡張関数を使用してから、返される LazyPagingItemsLazyColumnitems() に渡します。ビュー内の Paging サポートと同様に、itemnull であるかどうかをチェックすることで、データの読み込み中にプレースホルダを表示できます。

@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(
            lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it.id }
        ) { index ->
            val message = lazyPagingItems[index]
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

Lazy レイアウトを使用するためのヒント

Lazy レイアウトが意図したとおりに動作するようにするには、考慮すべきヒントがいくつかあります。

0 ピクセルサイズのアイテムを使用しない

たとえば、画像などのデータを非同期的に取得し、後からリストのアイテムを埋めるような場合です。アイテムの高さが 0 ピクセルであり、すべてをビューポートに収めることができるため、Lazy レイアウトでは最初の測定ですべてのアイテムがコンポーズされます。アイテムが読み込まれ、高さが拡張されると、実際にはビューポートに収まらないため、Lazy レイアウトでは最初に不必要にコンポーズされた他のアイテムがすべて破棄されます。これを回避するには、アイテムにデフォルトのサイズを設定して、Lazy レイアウトでビューポートに実際に収まるアイテムの数を正しく計算できるようにする必要があります。

@Composable
fun Item(imageUrl: String) {
    AsyncImage(
        model = rememberAsyncImagePainter(model = imageUrl),
        modifier = Modifier.size(30.dp),
        contentDescription = null
        // ...
    )
}

データが非同期で読み込まれた後のアイテムのおおよそのサイズがわかっている場合は、プレースホルダを追加するなどして、読み込みの前後でアイテムのサイズが同じになるようにすることをおすすめします。これにより、正しいスクロール位置を維持できます。

同じ方向にスクロールできるコンポーネントをネストしない

これは、事前定義されたサイズがないスクロール可能な子を、同じ方向の別のスクロール可能な親内にネストする場合にのみ適用されます。たとえば、垂直方向にスクロールできる Column の親の内部に、固定の高さを持たない子 LazyColumn をネストしようとする場合などです。

// throws IllegalStateException
Column(
    modifier = Modifier.verticalScroll(state)
) {
    LazyColumn {
        // ...
    }
}

代わりに、すべてのコンポーザブルを 1 つの親 LazyColumn でラップし、その DSL を使用して異なるタイプのコンテンツを渡すことで、同じ結果を得ることができます。これにより、単一のアイテムと複数のリストアイテムをすべて 1 か所に出力できます。

LazyColumn {
    item {
        Header()
    }
    items(data) { item ->
        PhotoItem(item)
    }
    item {
        Footer()
    }
}

異なる方向のレイアウト(たとえば、スクロール可能な親 Row と子 LazyColumn)をネストするケースは許可されています。

Row(
    modifier = Modifier.horizontalScroll(scrollState)
) {
    LazyColumn {
        // ...
    }
}

また、同じ方向のレイアウトを使用していても、ネストされた子に固定のサイズを設定するケースも許可されています。

Column(
    modifier = Modifier.verticalScroll(scrollState)
) {
    LazyColumn(
        modifier = Modifier.height(200.dp)
    ) {
        // ...
    }
}

1 つのアイテムに複数の要素を配置する場合は気をつける

この例では、2 番目のアイテムラムダが 1 つのブロックで 2 つのアイテムを出力しています。

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Item(2)
    }
    item { Item(3) }
    // ...
}

Lazy レイアウトはこれを期待どおりに処理し、異なるアイテムであるかのように要素を 1 つずつ順に配置します。ただし、この処理にはいくつか問題があります。

複数の要素が 1 つのアイテムの一部として出力されると、1 つのエンティティとして処理されます。つまり、個別にコンポーズすることはできなくなります。1 つの要素が画面に表示されたら、そのアイテムに対応するすべての要素をコンポーズして測定する必要があります。過剰に使用した場合は、これによりパフォーマンスが低下する可能性があります。すべての要素を 1 つのアイテムに配置する極端なケースでは、Lazy レイアウトを使用する意味がなくなります。パフォーマンスの問題があるだけでなく、1 つのアイテムにより多くの要素を追加すると、scrollToItem()animateScrollToItem() にも影響が及びます。

ただし、リスト内に分割線を入れるなど、1 つのアイテムに複数の要素を配置する有効なユースケースがあります。分割線は独立した要素とはみなされないため、分割線でスクロール インデックスを変更することはありません。また、分割線は小さいため、パフォーマンスに影響はありません。分割線は、前のアイテムが表示されるときに表示される必要があるため、前のアイテムに含めることができます。

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Divider()
    }
    item { Item(2) }
    // ...
}

カスタム配置の使用を検討する

通常、Lazy リストには多数のアイテムが含まれ、スクロール コンテナのサイズよりも大きくなります。ただし、リストにアイテムがあまり含まれていない場合は、デザインで、ビューポートでの配置方法について具体的な要件を設定できます。

これを行うには、カスタムの垂直 Arrangement を使用して LazyColumn に渡します。以下の例では、TopWithFooter オブジェクトは arrange メソッドを実装するだけです。まず、アイテムを 1 つずつ順に配置します。次に、使用される高さの合計がビューポートの高さよりも低い場合は、フッターが下部に配置されます。

object TopWithFooter : Arrangement.Vertical {
    override fun Density.arrange(
        totalSize: Int,
        sizes: IntArray,
        outPositions: IntArray
    ) {
        var y = 0
        sizes.forEachIndexed { index, size ->
            outPositions[index] = y
            y += size
        }
        if (y < totalSize) {
            val lastIndex = outPositions.lastIndex
            outPositions[lastIndex] = totalSize - sizes.last()
        }
    }
}

contentType の追加を検討する

Compose 1.2 以降では、Lazy レイアウトのパフォーマンスを最大限に高めるため、contentType をリストまたはグリッドに追加することを検討してください。これを追加すると、複数の異なるタイプのアイテムからなるリストやグリッドをコンポーズする場合に、レイアウトのアイテムごとにコンテンツ タイプを指定できます。

LazyColumn {
    items(elements, contentType = { it.type }) {
        // ...
    }
}

contentType を指定した場合、Compose が Composition を再利用できるのは、同じタイプのアイテム間のみとなります。類似した構造のアイテムをコンポーズする場合に再利用すると効率性が高まるため、コンテンツ タイプを指定すると、Compose はタイプ A のアイテムをタイプ B のまったく異なるアイテムの上にコンポーズしようとしなくなります。このため、コンポジションの再利用のメリットと Lazy レイアウトのパフォーマンスを最大限に高めることができます。

パフォーマンスの測定

リリースモードで実行され、R8 の最適化が有効になっている場合のみ、Lazy レイアウトのパフォーマンスを確実に測定できます。デバッグビルドでは、Lazy レイアウトのスクロールが遅く見えることがあります。詳細については、Compose のパフォーマンスをご覧ください。