Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Compose時代に再検討するロードUIとその設計

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

 Compose時代に再検討するロードUIとその設計

Avatar for wcaokaze

wcaokaze

July 07, 2026

More Decks by wcaokaze

Other Decks in Programming

Transcript

  1. Compose時代のロードUI 6 コンポーネント化が楽 @Composable fun <T> LoadingUi( state: LoadingState<T>, content:

    @Composable (T) -> Unit ) { ⋮ ⋮ } LoadingUi( state = loadingState ) { ロード成功時UI() }
  2. Compose時代のロードUI 7 • 汎用のロード表示の実装は LoadingUiを呼ぶだけで完了する • LoadingUiを改善すればそれを使う 箇所すべてに適用される @Composable fun

    <T> LoadingUi( state: LoadingState<T>, content: @Composable (T) -> Unit ) { ⋮ ⋮ } LoadingUi( state = loadingState ) { ロード成功時UI() }
  3. ロード状態を表すclass 9 ロード中、ロード成功、ロード失敗を 表すclassを用意する @Immutable sealed interface LoadingState<out T> {

    @Immutable data object Loading : LoadingState<Nothing> @Immutable data class Success<out T>( val value: T ) : LoadingState<T> @Immutable data class Error( val cause: Throwable ) : LoadingState<Nothing> }
  4. ロード状態を表すclass 10 使用側のコードのイメージ (ViewModel) var comicLoadingState: LoadingState<Comic> by mutableStateOf(LoadingState.Loading) fun

    loadComic() { viewModelScope.launch { comicLoadingState = LoadingState.Loading try { val comic = repository.fetchComic() comicLoadingState = LoadingState.Success(comic) } catch (e: Exception) { comicLoadingState = LoadingState.Error(e) } } }
  5. LoadingUi 12 成功/ロード中/失敗に合わせて 表示するものを切り替えるだけです @Composable fun <T> LoadingUi( state: LoadingState<T>,

    content: @Composable (T) -> Unit ) { when (state) { is LoadingState.Success -> { content(state.value) } is LoadingState.Loading -> { ロード中UI() } is LoadingState.Error -> { ロード失敗時UI(state.cause) } } }
  6. LoadingUi 13 ロード表示・エラー表示用Composable を差し替えられるようにしておく @Composable fun <T> LoadingUi( state: LoadingState<T>,

    loading: @Composable () -> Unit = { ロード中UI() }, error: @Composable (Throwable) -> Unit = { ロード失敗時UI(it) }, success: @Composable (T) -> Unit ) { when (state) { is LoadingState.Success -> { success(state.value) } is LoadingState.Loading -> { loading() } is LoadingState.Error -> { error(state.cause) } } }
  7. LoadingUi 14 ロード表示・エラー表示用Composable を差し替えられるようにしておく @Composable fun <T> LoadingUi( state: LoadingState<T>,

    loading: @Composable () -> Unit = { LoadingUiDefaults.Loading() }, error: @Composable (Throwable) -> Unit = { LoadingUiDefaults.Error(it) }, success: @Composable (T) -> Unit ) { when (state) { is LoadingState.Success -> { success(state.value) } is LoadingState.Loading -> { loading() } is LoadingState.Error -> { error(state.cause) } } }
  8. 切り替えアニメーション 19 ロード中から完了後のUIに切り替わる ときクロスフェードアニメーションを 追加する AnimatedContent( targetState = state, transitionSpec

    = { fadeIn() togetherWith fadeOut() using SizeTransform() }, contentKey = { it::class } ) { when (it) { is LoadingState.Success -> { success(it.value) } is LoadingState.Loading -> { loading() } is LoadingState.Error -> { error(it.cause) } } }
  9. 切り替えアニメーション 22 ロード中から完了後のUIに切り替わる ときクロスフェードアニメーションを 追加する AnimatedContent( targetState = state, transitionSpec

    = { fadeIn() togetherWith fadeOut() using SizeTransform() }, contentKey = { it::class } ) { when (it) { is LoadingState.Success -> { success(it.value) } is LoadingState.Loading -> { loading() } is LoadingState.Error -> { error(it.cause) } } }
  10. リロード 24 今の設計のままリロード機能を実装 するとこうなるが…? LoadingUi( state = viewModel.coinItemsLoadingState ) {

    val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( state = pullToRefreshState, isRefreshing = viewModel.coinItemsLoadingState is LoadingState.Loading, onRefresh = { viewModel.loadCoinItems() } ) { ロード成功時UI() } }
  11. リロード 25 LoadingUi( state = viewModel.coinItemsLoadingState ) { val pullToRefreshState

    = rememberPullToRefreshState() PullToRefreshBox( state = pullToRefreshState, isRefreshing = viewModel.coinItemsLoadingState is LoadingState.Loading, onRefresh = { viewModel.loadCoinItems() } ) { ロード成功時UI() } }
  12. リロード 26 もう一度ViewModelの実装を見て みましょう var coinItemsLoadingState: LoadingState<ImmutableList<CoinItem>> by mutableStateOf(LoadingState.Loading) fun

    loadCoinItems() { viewModelScope.launch { coinItemsLoadingState = LoadingState.Loading try { val coinItems = repository.fetchCoinItems() coinItemsLoadingState = LoadingState.Success(coinItems) } catch (e: Exception) { coinItemsLoadingState = LoadingState.Error(e) } } }
  13. リロード 27 もう一度ViewModelの実装を見て みましょう var coinItemsLoadingState: LoadingState<ImmutableList<CoinItem>> by mutableStateOf(LoadingState.Loading) var

    isCoinItemsRefreshing by mutableStateOf(false) fun loadCoinItems(isPullRefresh: Boolean) { viewModelScope.launch { if (isPullRefresh) { isCoinItemsRefreshing = true } else { coinItemsLoadingState = LoadingState.Loading } try { val coinItems = repository.fetchCoinItems() coinItemsLoadingState = LoadingState.Success(coinItems) } catch (e: Exception) { coinItemsLoadingState = LoadingState.Error(e) } finally { isCoinItemsRefreshing = false } } }
  14. リロード 28 LoadingState.Loadingにvalue: T?を 追加 • value == nullのときは初回ロード •

    value != nullのときはリロード @Immutable sealed interface LoadingState<out T> { @Immutable data class Loading<out T>( val value: T? ) : LoadingState<T> @Immutable data class Success<out T>( val value: T ) : LoadingState<T> @Immutable data class Error<out T>( val value: T?, val cause: Throwable ) : LoadingState<T> }
  15. リロード 29 LoadingState.Loadingにvalue: T?を 追加 • value == nullのときは初回ロード •

    value != nullのときはリロード @Immutable sealed interface LoadingState<out T> { val value: T? @Immutable data class Loading<out T>( override val value: T? ) : LoadingState<T> @Immutable data class Success<out T>( override val value: T ) : LoadingState<T> @Immutable data class Error<out T>( override val value: T?, val cause: Throwable ) : LoadingState<T> }
  16. リロード 30 value != nullならsuccessとして表示 すればよい AnimatedContent( targetState = state,

    transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform() }, contentKey = { it::class } ) { val value = it.value when { value != null -> { success(value) } it is LoadingState.Loading -> { loading() } it is LoadingState.Error -> { error(it.cause) } } }
  17. リロード 31 • 初期値はvalue = null • loadCoinItems内では value =

    value これにより、初回ロードはLoading(null)、 リロード時は前回取得したvalueを引き継ぐ var coinItemsLoadingState: LoadingState<ImmutableList<CoinItem>> by mutableStateOf(LoadingState.Loading(null)) fun loadCoinItems() { viewModelScope.launch { coinItemsLoadingState = LoadingState.Loading( coinItemsLoadingState.value ) try { val coinItems = repository.fetchCoinItems() coinItemsLoadingState = LoadingState.Success(coinItems) } catch (e: Exception) { coinItemsLoadingState = LoadingState.Error( coinItemsLoadingState.value, e ) } } }
  18. リロード 32 LoadingUi( state = viewModel.coinItemsLoadingState ) { val pullToRefreshState

    = rememberPullToRefreshState() PullToRefreshBox( state = pullToRefreshState, isRefreshing = viewModel.coinItemsLoadingState is LoadingState.Loading, onRefresh = { viewModel.loadCoinItems() } ) { ロード成功時UI() } }
  19. ちなみに 34 コンパイルが通らなくなったじゃん! @Immutable sealed interface LoadingState<out T> { @Immutable

    data object class Loading<out T>( override val value: T? ) : LoadingState<T> } var coinItemsLoadingState: LoadingState<ImmutableList<CoinItems>> by mutableStateOf(LoadingState.Loading)
  20. ちなみに 35 こうしましょう @Immutable sealed interface LoadingState<out T> { @Immutable

    data object class Loading<out T>( override val value: T? ) : LoadingState<T> companion object { val Loading = Loading(null) } } var coinItemsLoadingState: LoadingState<ImmutableList<CoinItems>> by mutableStateOf(LoadingState.Loading)
  21. ちなみに 36 こうしましょう @Immutable sealed interface LoadingState<out T> { @Immutable

    data class Loading<out T>( override val value: T? ) : LoadingState<T> companion object { val Loading = Loading(null) } } var coinItemsLoadingState: LoadingState<ImmutableList<CoinItems>> by mutableStateOf(LoadingState.Loading) var coinItemsLoadingState: LoadingState<ImmutableList<CoinItems>> by mutableStateOf(LoadingState.Loading(null))
  22. エラー後の再試行 39 ボタン押下イベントを引数に追加する object LoadingUiDefaults { @Composable fun Error( cause:

    Throwable, onRetryButtonClick: () -> Unit ) { Column { Text( text = remember(cause) { エラーメッセージ (cause) } ) Button( onClick = onRetryButtonClick ) } } }
  23. エラー後の再試行 40 使用箇所がコンパイルエラーに なるが・・・ @Composable fun <T> LoadingUi( state: LoadingState<T>,

    loading: @Composable () -> Unit = { LoadingUiDefaults.Loading() }, error: @Composable (Throwable) -> Unit = { LoadingUiDefaults.Error(it) }, success: @Composable (T) -> Unit ) { ⋮ ⋮ }
  24. エラー後の再試行 41 LoadingUiにもonRetryButtonClickを追 加 @Composable fun <T> LoadingUi( state: LoadingState<T>,

    onRetryButtonClick: () -> Unit, loading: @Composable () -> Unit = { LoadingUiDefaults.Loading() }, error: @Composable (Throwable) -> Unit = { LoadingUiDefaults.Error(it, onRetryButtonClick) }, success: @Composable (T) -> Unit ) { ⋮ ⋮ }
  25. エラー後の再試行 42 LoadingUiにもonRetryButtonClickを追 加 @Composable fun <T> LoadingUi( state: LoadingState<T>,

    onRetryButtonClick: () -> Unit, loading: @Composable () -> Unit = { LoadingUiDefaults.Loading() }, error: @Composable (Throwable) -> Unit = { LoadingUiDefaults.Error(it, onRetryButtonClick) }, success: @Composable (T) -> Unit ) { ⋮ ⋮ }
  26. エラー後の再試行 44 LoadingUiにもonRetryButtonClickを追 加 LoadingUi( state = viewModel.coinItemsLoadingState, onRetryButtonClick =

    { viewModel.loadCoinItems() }, error = { cause -> ロード失敗時UI( cause = cause, onRetryButtonClick = { viewModel.loadCoinItems() } ) } ) { ⋮ ⋮ }
  27. エラー後の再試行 45 LoadingUiにもonRetryButtonClickを追 加 @Composable fun <T> LoadingUi( state: LoadingState<T>,

    onRetryButtonClick: () -> Unit, loading: @Composable () -> Unit = { LoadingUiDefaults.Loading() }, error: @Composable (Throwable) -> Unit = { LoadingUiDefaults.Error(it, onRetryButtonClick) }, success: @Composable (T) -> Unit ) { ⋮ ⋮ }
  28. エラー後の再試行 46 オーバーロードを用意します • デフォルトのロードUIと デフォルトのエラーUIを使うやつ ◦ loading, errorはない ◦

    代わりにonRetryButtonClickがある • ロードUIとエラーUIを指定する やつ ◦ loading, error共に必須パラメータ ◦ onRetryButtonClickはない @Composable fun <T> LoadingUi( state: LoadingState<T>, onRetryButtonClick: () -> Unit, success: @Composable (T) -> Unit ) { LoadingUi( state = state, loading = { LoadingUiDefaults.Loading() }, error = { LoadingUiDefaults.Error(it, onRetryButtonClick) }, success = success ) } @Composable fun <T> LoadingUi( state: LoadingState<T>, loading: @Composable () -> Unit, error: @Composable (Throwable) -> Unit, success: @Composable (T) -> Unit ) { ⋮ ⋮
  29. エラー後の再試行 48 使い方 LoadingUi( state = viewModel.coinItemsLoadingState, loading = {

    ロード中UI() }, error = { cause -> ロード失敗時UI( cause = cause, onRetryButtonClick = { viewModel.loadCoinItems() } ) } ) { ロード成功時UI() }
  30. エラー後の再試行 49 使い方 LoadingUi( state = viewModel.coinItemsLoadingState, loading = {

    ロード中UI() }, error = { cause -> ロード失敗時UI( cause = cause ) } ) { ロード成功時UI() }
  31. 再試行UI 51 再試行機能は実装できたが・・・? LoadingUi( state = viewModel.coinItemsLoadingState, onRetryButtonClick = {

    viewModel.loadCoinItems() } ) { ロード成功時UI() } fun loadCoinItems() { viewModelScope.launch { coinItemsLoadingState = LoadingState.Loading(...) try { val coinItems = repository.fetchCoinItems() coinItemsLoadingState = LoadingState.Success(coinItems) } catch (e: Exception) { coinItemsLoadingState = LoadingState.Error(...) } } }
  32. 再試行UI 56 @Immutable sealed interface LoadingState<out T> { val value:

    T? @Immutable data class Loading<out T>( override val value: T? ) : LoadingState<T> @Immutable data class Success<out T>( override val value: T ) : LoadingState<T> @Immutable data class Error<out T>( override val value: T?, val cause: Throwable ) : LoadingState<T> }
  33. 再試行UI 57 @Immutable sealed interface LoadingState<out T> { val value:

    T? @Immutable data class Loading<out T>( override val value: T? ) : LoadingState<T> @Immutable data class Success<out T>( override val value: T ) : LoadingState<T> @Immutable data class Error<out T>( override val value: T?, val cause: Throwable, val isRetrying: Boolean ) : LoadingState<T> }
  34. 再試行UI 58 @Immutable sealed interface LoadingState<out T> { val value:

    T? @Immutable data class Loading<out T>( override val value: T? ) : LoadingState<T> @Immutable data class Success<out T>( override val value: T ) : LoadingState<T> @Immutable data class Error<out T>( override val value: T?, val cause: Throwable ) : LoadingState<T> }
  35. 再試行UI 59 @Immutable sealed interface LoadingState<out T> { val value:

    T? @Immutable data class Loading<out T>( override val value: T?, val error: Throwable? ) : LoadingState<T> @Immutable data class Success<out T>( override val value: T ) : LoadingState<T> @Immutable data class Error<out T>( override val value: T?, val cause: Throwable ) : LoadingState<T> }
  36. 再試行UI 60 // エラーかどうかのチェック val isError = coinItemsLoadingState is LoadingState.Error

    || ( coinItemsLoadingState is LoadingState.Loading && coinItemsLoadingState.cause != null )
  37. 再試行UI 61 @Immutable sealed interface LoadingState<out T> { val value:

    T? sealed interface Loading<out T> : LoadingState<T> sealed interface Failure<out T> : LoadingState<T> { val cause: Throwable } data class Initializing<out T>( override val value: T? ) : Loading<T> data class Error<out T>( override val value: T?, override val cause: Throwable ) : Failure<T> data class Retrying<out T>( override val value: T?, override val cause: Throwable ) : Loading<T>, Failure<T> LoadingState Loading Retrying Failure Error Initializing
  38. 再試行UI 63 @Immutable sealed interface LoadingState<out T> { val value:

    T? sealed interface Loading<out T> : LoadingState<T> sealed interface Failure<out T> : LoadingState<T> { val cause: Throwable } data class Initializing<out T>( override val value: T? ) : Loading<T> data class Error<out T>( override val value: T?, override val cause: Throwable ) : Failure<T> data class Retrying<out T>( override val value: T?, override val cause: Throwable ) : Loading<T>, Failure<T> LoadingState Loading Retrying Failure Error Initializing
  39. 再試行UI 64 @Immutable sealed interface LoadingState<out T> { val value:

    T? sealed interface Loading<out T> : LoadingState<T> sealed interface Failure<out T> : LoadingState<T> { val cause: Throwable } data object Initializing : Loading<Nothing> { override val value = null } data class Reloading<out T>( override val value: T ) : Loading<T> data class Retrying<out T>( override val value: T?, val cause: Throwable ) : Loading<T>, Failure<T> LoadingState Loading Retrying Failure Error Initializing Reloading
  40. 再試行UI 65 @Composable fun <T> LoadingUi( state: LoadingState<T>, loading: @Composable

    () -> Unit, error: @Composable (Throwable, isRetrying: Boolean) -> Unit, success: @Composable (T) -> Unit ) { AnimatedContent(...) { val value = it.value when { value != null -> { success(value) } it is LoadingState.Retrying -> { error(it.cause, true) } it is LoadingState.Loading -> { loading() } it is LoadingState.Error -> { error(it.cause, false) } }
  41. 再試行UI 66 object LoadingUiDefaults { @Composable fun Error( cause: Throwable,

    isRetrying: Boolean, onRetryButtonClick: () -> Unit ) { Column { Text( text = remember(cause) { エラーメッセージ (cause) } ) if (isRetrying) { CircularProgressIndicator() } else { Button( onClick = onRetryButtonClick ) } } }
  42. 再試行UI 67 AnimatedContent( targetState = state, transitionSpec = { fadeIn()

    togetherWith fadeOut() using SizeTransform() }, contentKey = { it::class } ) { val value = it.value when { value != null -> { success(value) } it is LoadingState.Retrying -> { error(it.cause, true) } it is LoadingState.Loading -> { loading() } it is LoadingState.Error -> { error(it.cause, false) } } } ⚠
  43. 再試行UI 68 AnimatedContent( targetState = state, transitionSpec = { fadeIn()

    togetherWith fadeOut() using SizeTransform() }, contentKey = { when { it.value != null -> "success" it is LoadingState.Retrying -> "error" it is LoadingState.Loading -> "loading" it is LoadingState.Error -> "error" else -> "" } } ) { val value = it.value when { value != null -> { success(value) } it is LoadingState.Retrying -> { error(it.cause, true) }
  44. ちなみに 69 LoadingがInitializingとReloadingに 変わったが・・・? @Immutable sealed interface LoadingState<out T> {

    sealed interface Loading<out T> : LoadingState<T> data object Initializing : Loading<Nothing> { override val value = null } data class Loading Reloading<out T>( override val value: T ) : Loading<T> }
  45. ちなみに 70 コンパイルが通らなくなったじゃん! @Immutable sealed interface LoadingState<out T> { sealed

    interface Loading<out T> : LoadingState<T> data object Initializing : Loading<Nothing> { override val value = null } data class Loading Reloading<out T>( override val value: T ) : Loading<T> } var coinItemsLoadingState: LoadingState<ImmutableList<CoinItems>> by mutableStateOf(LoadingState.Loading(null))
  46. ちなみに 71 こうしましょう @Immutable sealed interface LoadingState<out T> { sealed

    interface Loading<out T> : LoadingState<T> { companion object { operator fun <T> invoke(value: T?): Loading<T> { return if (value == null) { Initializing } else { Reloading(value) } } } } data object Initializing : Loading<Nothing> { override val value = null } data class Loading Reloading<out T>( override val value: T ) : Loading<T> } var coinItemsLoadingState: LoadingState<ImmutableList<CoinItems>> by mutableStateOf(LoadingState.Loading(null))
  47. ちなみに 72 こうしましょう @Immutable sealed interface LoadingState<out T> { sealed

    interface Loading<out T> : LoadingState<T> { companion object { operator fun <T> invoke(value: T?): Loading<T> { return if (value == null) { Initializing } else { Reloading(value) } } } } data object Initializing : Loading<Nothing> { override val value = null } data class Reloading<out T>( override val value: T ) : Loading<T> } var coinItemsLoadingState: LoadingState<ImmutableList<CoinItems>> by mutableStateOf(LoadingState.Loading(null))
  48. ちなみに2 73 Loadingが3種類に増えたことにより Loadingを使いたいときにどれが適切か 判断する必要がある var coinItemsLoadingState: LoadingState<ImmutableList<CoinItem>> by mutableStateOf(LoadingState.Loading(null))

    private set fun loadCoinItems() { viewModelScope.launch { coinItemsLoadingState = when { coinItemsLoadingState is LoadingState.Loading -> coinItemsLoadingState coinItemsLoadingState is LoadingState.Failure -> LoadingState.Retrying( coinItemsLoadingState.value, coinItemsLoadingState.cause ) coinItemsLoadingState.value != null -> LoadingState.Reloading( coinItemsLoadingState.value ) else -> LoadingState.Initializing }
  49. ちなみに2 74 Loadingが3種類に増えたことにより Loadingを使いたいときにどれが適切か 判断する必要がある fun <T> LoadingState<T>.toLoading(): LoadingState.Loading<T> {

    val value = value return when { this is LoadingState.Loading -> this this is LoadingState.Failure -> LoadingState.Retrying(value, cause) value != null -> LoadingState.Reloading(value) else -> LoadingState.Initializing } }
  50. ちなみに2 75 Loadingが3種類に増えたことにより Loadingを使いたいときにどれが適切か 判断する必要がある fun <T> LoadingState<T>.toLoading(): LoadingState.Loading<T> {

    val value = value return when { this is LoadingState.Loading -> this this is LoadingState.Failure -> LoadingState.Retrying(value, cause) value != null -> LoadingState.Reloading(value) else -> LoadingState.Initializing } } fun loadCoinItems() { viewModelScope.launch { coinItemsLoadingState = coinItemsLoadingState.toLoading() try { val coinItems = repository.fetchCoinItems() coinItemsLoadingState = LoadingState.Success(coinItems) } catch (e: Exception) { coinItemsLoadingState = coinItemsLoadingState.toError(e) } } }
  51. まとめ 78 • ロード中/失敗/成功の切り替え • 切り替え時アニメーション ◦ クロスフェード ◦ サイズの伸縮

    • 成功後のリロード • 失敗後の再試行 LoadingUi( state = viewModel.coinItemsLoadingState, onRetryButtonClick = { viewModel.loadCoinItems() } ) { ロード成功時UI() }