読み込み状態の管理と表示

Paging ライブラリは、ページングされたデータの読み込みリクエストの状態を追跡し、LoadState クラスを介して公開します。

個々の LoadState シグナルは、LoadType とデータソース タイプ(PagingSource または RemoteMediator)ごとに提供されます。リスナーから渡される CombinedLoadStates オブジェクトは、これらすべてのシグナルから届いた読み込み状態に関する情報を提供します。この詳細な情報を使用して、適切な読み込みインジケーターをユーザーに表示できます。

読み込み状態

Paging ライブラリは、LoadState オブジェクトを介して、UI で使用される読み込み状態を公開します。LoadState オブジェクトは、現在の読み込み状態に応じて次の 3 つの形態のいずれかを取ります。

  • アクティブな読み込みオペレーションが存在せず、エラーがない場合、LoadState は LoadState.NotLoading オブジェクトになります。このサブクラスには、ページネーションが終わりに達したかどうかを示す endOfPaginationReached プロパティも含まれています。
  • アクティブな読み込みオペレーションが存在する場合、LoadState は LoadState.Loading オブジェクトになります。
  • エラーがある場合、LoadState は LoadState.Error オブジェクトになります。

これらの状態には、LazyPagingItems ラッパーの loadState プロパティを介してアクセスします。この状態は、メイン コンテンツの表示を処理する(全画面更新スピナーなど)か、読み込み中のアイテムを LazyColumn ストリームに直接挿入する(フッター スピナーなど)という 2 つの方法で使用できます。

リスナーを使用して読み込み状態にアクセスする

UI で読み込み状態をモニタリングするには、LazyPagingItems ラッパーによって提供される loadState プロパティを使用します。これにより、CombinedLoadStates オブジェクトが返されます。このオブジェクトを使用すると、更新、追加、先頭追加のイベントの読み込み動作に対応できます。

次の例では、更新(初期)読み込みの現在の状態に応じて、UI に読み込みスピナーまたはエラー メッセージが表示されます。

@Composable
fun UserListScreen(viewModel: UserViewModel) {
  val pagingItems = viewModel.flow.collectAsLazyPagingItems()

  Box(modifier = Modifier.fillMaxSize()) {
    // Show the list content
    LazyColumn {
      items(pagingItems.itemCount) { index ->
        UserItem(pagingItems[index])
      }
    }

    // Handle the loading state
    when (val state = pagingItems.loadState.refresh) {
      is LoadState.Loading -> {
        CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
      }
      is LoadState.Error -> {
        ErrorButton(
          message = state.error.message ?: "Unknown error",
          onClick = { pagingItems.retry() },
          modifier = Modifier.align(Alignment.Center)
        )
      }
      else -> {} // No separate view needed for success/not loading
    }
  }
}

LazyPagingItems の詳細については、大規模なデータセット(ページング)をご覧ください。

リストの先頭または末尾に読み込みインジケーターを表示するには(ヘッダーまたはフッターとして機能)、LazyColumn スコープ内にそれらの状態専用のアイテム ブロックを追加します。

CombinedLoadStates オブジェクトを使用して、ヘッダーの先頭追加状態とフッターの末尾追加状態をモニタリングできます。

次の例では、さらにデータを取得しているときに、リストの下部に進行状況バーまたは再試行ボタンが表示されます。

@Composable
fun UserList(viewModel: UserViewModel) {
  val pagingItems = viewModel.pager.flow.collectAsLazyPagingItems()

  LazyColumn {
    // 1. Header (Prepend state)
    // Useful if you support bidirectional paging or jumping to the middle
    item {
      val prependState = pagingItems.loadState.prepend
      if (prependState is LoadState.Loading) {
        LoadingItem()
      } else if (prependState is LoadState.Error) {
        ErrorItem(
          message = prependState.error.message ?: "Error",
          onClick = { pagingItems.retry() }
        )
      }
    }

    // 2. Main Data
    items(pagingItems.itemCount) { index ->
      UserItem(pagingItems[index])
    }

    // 3. Footer (Append state)
    // Shows when the user scrolls to the bottom and more data is loading
    item {
      val appendState = pagingItems.loadState.append
      if (appendState is LoadState.Loading) {
        LoadingItem()
      } else if (appendState is LoadState.Error) {
        ErrorItem(
          message = appendState.error.message ?: "Error",
          onClick = { pagingItems.retry() }
        )
      }
    }
  }
}

@Composable
fun LoadingItem() {
  Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
    CircularProgressIndicator()
  }
}

@Composable
fun ErrorItem(message: String, onClick: () -> Unit) {
  Column(
    modifier = Modifier.fillMaxWidth().padding(16.dp),
    horizontalAlignment = Alignment.CenterHorizontally
  ) {
    Text(text = message, color = Color.Red)
    Button(onClick = onClick) { Text("Retry") }
  }
}

その他の読み込み状態の情報にアクセスする

前述の例のように、pagingItems.loadState.refresh を呼び出すと便利です。ただし、ローカル データベース(PagingSource)からの読み込みとネットワーク(RemoteMediator)からの読み込みの違いが曖昧になります。これにより、キャッシュに保存されたデータがすぐに利用可能な場合でも、UI に読み込みスピナーが短時間表示されることがあります。

ローカル データベースが空で、ネットワーク同期がアクティブな場合にのみ読み込みスピナーを表示するなど、正確に制御するには、コンポーザブル内で source プロパティと mediator プロパティに直接アクセスします。

val loadState = pagingItems.loadState

val isSyncing = loadState.mediator?.refresh is LoadState.Loading

val isLocalEmpty = loadState.source.refresh is LoadState.NotLoading &&
                   pagingItems.itemSnapshotList.items.isEmpty()

if (isSyncing && isLocalEmpty) {
    FullScreenLoading()
} else {
    UserList(pagingItems)

    if (isSyncing) {
        TopOverlaySpinner()
    }
}

スクロール中に prepend または append が変更されるたびに pagingItems.loadState が新しい CombinedLoadStates オブジェクトを出力するため、derivedStateOf の更新チェックを remember(pagingItems) でラップします。これにより、スクロール エッジの更新によって、全画面更新インジケーターのみを切り替える親スコープが再コンポーズされるのを防ぐことができます。

val showFullScreenLoading by remember(pagingItems) {
  derivedStateOf {
    // Avoid recomposing on prepend or append updates while scrolling, and
    // avoid covering existing items during a background refresh.
    pagingItems.loadState.source.refresh is LoadState.Loading &&
      pagingItems.itemCount == 0
  }
}

if (showFullScreenLoading) {
  Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
  ) {
    CircularProgressIndicator()
  }
}

読み込み状態の変化に対応する

読み込み状態の変化に基づいて、1 回限りの副作用をトリガーする必要がある場合があります。たとえば、リストの一番上までスクロールしたり、更新が完了または失敗したときに Snackbar を表示したりする場合です。

LaunchedEffect 内で snapshotFlow を使用して、状態の変化をストリームとして監視します。これにより、filter、distinctUntilChanged、distinctUntilChangedBy などの標準の Flow 演算子を適用して、特定のイベントを分離できます。

val listState = rememberLazyListState()

LaunchedEffect(pagingItems) {
  // 1. Convert the state to a Flow
  snapshotFlow { pagingItems.loadState.refresh }
    // 2. Filter for the specific event (Refresh completed successfully)
    .distinctUntilChanged()
    .filter { it is LoadState.NotLoading }
    .collect {
      // 3. Trigger the side effect
      listState.animateScrollToItem(0)
    }
}

同様に、loadState で distinctUntilChangedBy を使用すると、refresh 状態が LoadState.Error に移行した場合にのみエラー Snackbar を表示できます。

val snackbarHostState = remember { SnackbarHostState() }

// Key on pagingItems rather than loadState so state updates do not restart the
// coroutine and reset distinctUntilChangedBy.
LaunchedEffect(pagingItems) {
  snapshotFlow { pagingItems.loadState }
    // Ignore prepend and append updates so scrolling does not re-trigger the
    // refresh error Snackbar.
    .distinctUntilChangedBy { it.refresh }
    .filter { it.refresh is LoadState.Error }
    .collect { loadState ->
      val error = (loadState.refresh as LoadState.Error).error
      val result = snackbarHostState.showSnackbar(
        message = "Data failed to load: ${error.localizedMessage ?: "Unknown error"}",
        actionLabel = "Retry"
      )
      if (result == SnackbarResult.ActionPerformed) {
        pagingItems.retry()
      }
    }
}

参考情報

Paging ライブラリと読み込み状態について詳しくは、以下のリソースをご覧ください。

ドキュメント

コンテンツを閲覧する