Compose Multiplatform 実践:CMPでStateFlowを使用したUI状態管理

はじめに

これまでの記事で
Composeを使ってUIを作成する方法について説明してきました
しかし、UI状態の調整方法やビジネスロジック処理後にUI画面を変更する方法については
まだ詳しく説明していませんでした

今日は
StateFlowの使用方法について詳しく説明し
CMP内でUI状態を管理・調整する方法
そしてビジネスロジックの処理を通じてUIを動的に更新する方法を紹介します

StateFlowとは?

StateFlowはKotlinコルーチンライブラリの状態管理ツールで
Compose内のUI状態管理のニーズを解決するためのものです
これはFlowベースの状態コンテナで
状態の保持と変化の観察のために設計されており
特にComposeでの使用に適しています

StateFlowの特徴
  1. 最新の状態を保持:StateFlowは常に最新の状態値を保持し
    状態が変化した場合
    自動的にすべてのオブザーバーに通知します

  2. 状態は不変:StateFlowの状態は不変です
    つまり、状態が変わるたびに新しい状態インスタンスが作成され
    状態の一貫性と予測可能性が確保されます

  3. ホットフロー(Hot Flow)ベースコレクター(Collect)がなくても
    StateFlowは常に最新の状態値を維持し続けます

例えば:以下のコードでUI状態を更新できます

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

Compose UIでcollectする

val uiState by viewModel.uiState.collectAsState()          
StateFlowを使用したUI状態管理の実装

すでに実装済みのSettingScreenがあるとします
現在は画面表示のみで
状態変化はなく
画面のデータはハードコードされています

@Composable
fun SettingScreen(navController: NavController) {
    val config = createSettingConfig(navController)

    Scaffold(
        topBar = {
            MainAppBar(config = config)
        },
        containerColor = MaterialTheme.colorScheme.surfaceVariant
    ) { paddingValues ->
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            item {
                Text(
                    "Choose Transfer Option",
                    style = MaterialTheme.typography.bodySmall,
                    modifier = Modifier.padding(start = 30.dp, top = 16.dp, end = 30.dp)
                )
            }

            items(SettingOption.values()) { option ->
                SettingOptionCard(
                    option = option,
                    onClick = {
                        navController.navigate(option.route) {
                            navController.graph.startDestinationRoute?.let {
                                popUpTo(it) {
                                    saveState = true
                                }
                            }
                            launchSingleTop = true
                            restoreState = true
                        }
                    }
                )
            }
        }
    }
}

まず、data classを実装します
目的はSettingScreen内の「Choose Transfer Option」タイトルを更新することです

data class ViewState(
    val transferOptionTitle: String,
    val isLoading: Boolean = false,
    val error: String? = null
    ... // More content that according your requirment.
)

次にViewmodelを実装します
ビジネスロジックを管理し
UI状態の送信を実現します
StateFlowの状態は変更できないため
変更するにはMutableStateFlowを使用します
通常、外部からの誤った変更を防ぐために
MutableStateFlowprivateに設定します

また、ネットワークリクエストをシミュレートするloadData()の例を作成しました
_uiState.value = UiState(xxxx)を使用してUIに変更を通知できます
実際には要件に応じて調整できます

class SettingViewModel(
    private val settingDataStore: SettingDataStore,
    private val dataStore: LearningDataStore,
    private val adManager: AdManager
) : ViewModel(){

    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()
    
    // データロードのシミュレーション
    fun loadData() {
        viewModelScope.launch {
            _uiState.value = UiState(isLoading = true)
            try {
                delay(1000)  // ネットワークリクエストのシミュレーション
                _uiState.value = UiState(transferOptionTitle = "Loaded Data", isLoading = false)
            } catch (e: Exception) {
                _uiState.value = UiState(error = e.message, isLoading = false)
            }
        }
    }
}

続いて
ViewModelインスタンスをSetting Screenに渡す必要があります
すでにCompose Navigationを使用しているので
NavGraphBuilder拡張でViewModelを実装できます

以下の方法でViewModelインスタンスを作成できます:

直接作成する方法:

fun NavGraphBuilder.routeSettingScreen(
    navController: NavHostController,
) {

    composable(ElegantJapaneseScreen.Setting.name) {
        val viewModel = SettingViewModel()
        SettingScreen(navController, viewModel)
    }
}

またはKoinを使用して依存性注入を行う方法(後の章で詳しく説明します)

fun NavGraphBuilder.routeSettingScreen(
    navController: NavHostController,
) {

    composable(ElegantJapaneseScreen.Setting.name) {
        val viewModel = koinViewModel<SettingViewModel>()

        SettingScreen(navController, viewModel)
    }
}

最後に
Compose UIで状態を収集し
その状態を使ってUIを変更するだけです

実際の使用例

val uiState by viewModel.uiState.collectAsState()を使用して
viewmodelの状態変化をcollect
コード内で直接uiStateの値を呼び出すことで
画面を動的に設定できます

例: uiState.isLoading uiState.transferOptionTitle uiState.error (以下のコードを参照)

@Composable
fun SettingScreen(navController: NavController, viewModel: SettingViewModel) {
    val uiState by viewModel.uiState.collectAsState()
    val config = createSettingConfig(navController)

    // ロジックのトリガー
    LaunchedEffect(Unit) {
        viewModel.loadData()
    }
    
    Scaffold(
        topBar = {
            MainAppBar(config = config)
        },
        containerColor = MaterialTheme.colorScheme.surfaceVariant
    ) { paddingValues ->
        if (uiState.isLoading) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        } else if (uiState.error != null) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = "Error: ${uiState.error}",
                    color = MaterialTheme.colorScheme.error
                )
            }
        } else {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                item {
                    Text(
                        text = uiState.transferOptionTitle,
                        style = MaterialTheme.typography.bodySmall,
                        modifier = Modifier.padding(start = 30.dp, top = 16.dp, end = 30.dp)
                    )
                }

                items(SettingOption.values()) { option ->
                    SettingOptionCard(
                        option = option,
                        onClick = {
                            navController.navigate(option.route) {
                                navController.graph.startDestinationRoute?.let {
                                    popUpTo(it) {
                                        saveState = true
                                    }
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    }
}
最終成效

最後看看上面這個例子的結果
GIF

You might also enjoy