KC Blog

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

7 min read
CrossPlatform#CMP#Kotlin

はじめに

これまでの記事で

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