Jetpack Compose #AndroidDevChallenge に挑戦すると、Google Pixel 5 など、1,000 種類以上の賞品のいずれかを獲得できるチャンスがあります。詳細

リスト

多くのアプリでは、アイテムのコレクションを表示する必要があります。このドキュメントでは、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

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(messages) { message ->
            MessageRow(message)
        }
    }
}

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

コンテンツのパディング

場合によっては、コンテンツの端にパディングを追加する必要があります。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),
) {
    // ...
}

アイテム アニメーション

RecyclerView ウィジェットでは自動的にアイテムの変更をアニメーション化できますが、Lazy レイアウトではまだこの機能が提供されていません。つまり、アイテムを変更すると即座に「スナップ」されます。この機能に関する変更を追跡するには、こちらのバグをご覧ください。

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

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

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

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

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

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

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

// TODO: 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)
            }
        }
    }
}

グリッド(試験運用版)

LazyVerticalGrid コンポーザブルは、アイテムのグリッド表示に対する試験運用版のサポートを提供しています。

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

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

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
        cells = GridCells.Adaptive(minSize = 128.dp)
    ) {
        items(photos) { photo ->
            PhotoItem(photo)
        }
    }
}

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

スクロール位置への反応

多くのアプリでは、スクロール位置とアイテム レイアウトの変更に反応してリッスンする必要があります。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 プロパティが用意されています。

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

@OptIn(ExperimentalAnimationApi::class) // AnimatedVisibility
@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 == true }
        .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 であるかどうかをチェックすることで、データの読み込み中にプレースホルダを表示できます。

import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items

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

    LazyColumn {
        items(lazyPagingItems) { message ->
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

アイテムのキー

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

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

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(
            items = messages,
            key = { message ->
                // Return a stable + unique key for the item
                message.id
            }
        ) { message ->
            MessageRow(message)
        }
    }
}