Compose Multiplatform in Action: Managing UI State with StateFlow in CMP
In our previous discussions,
we explored how to create UI using Compose.
However, we haven’t deeply examined how to adjust UI states
and how to update the UI after processing business logic.
Today,
we’ll dive into how to use StateFlow
to demonstrate how to manage and adjust UI states in CMP
and how to dynamically update the UI through business logic processing.
目錄
- Compose Multiplatform 實戰:放輕鬆點,初探CMP
- Compose Multiplatform 實戰:初戰,安裝CMP環境吧
- Compose Multiplatform 實戰:續戰,用Wizard創建CMP專案
- Compose Multiplatform 實戰:在Android、iOS模擬器上跑CMP專案
- Compose Multiplatform 實戰:CMP的專案結構理解與編譯配置
- Compose Multiplatform 實戰:CMP中跨平台Android、iOS程式碼的進入點
- Compose Multiplatform 實戰:在CMP的Compose中用Material Design3 Theme
- Compose Multiplatform 實戰:CMP用Compose實作跨平台畫面
- Compose Multiplatform 實戰:使用 expect 和 actual 實現跨平台程式碼
- Compose Multiplatform 實戰:CMP中實作Compose Navigation頁面切換
- Compose Multiplatform 實戰:CMP中透過StateFlow來管理UI狀態
- Compose Multiplatform 實戰:CMP中實作NavigationBar底部欄
- Compose Multiplatform 實戰:CMP中使用koin來依賴注入Dependency Injection
- Compose Multiplatform 實戰:CMP實作跨平台資料庫SqlDelight
- Compose Multiplatform 實戰:CMP中使用ROOM開發跨平台資料庫 & 疑難雜症
StateFlow
is a state management tool in the Kotlin coroutine library
designed to address the need for managing UI states in Compose.
It’s a Flow-based state container
designed to hold and observe state changes,
particularly suitable for use in Compose
.
-
Holds the latest state
: StateFlow always maintains the latest state value
and automatically notifies all observers
when the state changes. -
Immutable state
: The state in StateFlow is immutable,
meaning that each state change creates a new state instance,
ensuring consistency and predictability of the state. -
Based on Hot Flow
: Even without acollector
,
StateFlow continuously maintains the latest state value.
For example: we can update the UI state with the following code:
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
Then collect it in your Compose UI:
val uiState by viewModel.uiState.collectAsState()
Let’s say we have a completed SettingScreen
that currently only displays a screen
without state changes,
and all the screen data is hardcoded.
@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
}
}
)
}
}
}
}
First, we can implement a data class
to update the title “Choose Transfer Option” in the SettingScreen
:
data class ViewState(
val transferOptionTitle: String,
val isLoading: Boolean = false,
val error: String? = null
... // More content according to your requirements.
)
Next, implement a ViewModel
to manage business logic
and implement UI state emission.
Since StateFlow
states are immutable,
you can use MutableStateFlow
to change them.
To prevent external accidental modifications
,
we typically set MutableStateFlow
as private
.
Additionally, I’ve written a sample loadData()
function to simulate network data requests.
You can notify the UI of changes through _uiState.value = UiState(xxxx)
.
You can adjust according to your needs in practice.
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()
// Simulate data loading
fun loadData() {
viewModelScope.launch {
_uiState.value = UiState(isLoading = true)
try {
delay(1000) // Simulate network request
_uiState.value = UiState(transferOptionTitle = "Loaded Data", isLoading = false)
} catch (e: Exception) {
_uiState.value = UiState(error = e.message, isLoading = false)
}
}
}
}
Next,
we need to pass the ViewModel instance to the Setting Screen.
Since we’ve already used Compose Navigation in previous days,
we can implement the ViewModel directly in our NavGraphBuilder extension.
You can create a ViewModel instance in the following ways:
Using direct creation:
fun NavGraphBuilder.routeSettingScreen(
navController: NavHostController,
) {
composable(ElegantJapaneseScreen.Setting.name) {
val viewModel = SettingViewModel()
SettingScreen(navController, viewModel)
}
}
Or using Koin for dependency injection (we’ll cover this method in detail in later chapters):
fun NavGraphBuilder.routeSettingScreen(
navController: NavHostController,
) {
composable(ElegantJapaneseScreen.Setting.name) {
val viewModel = koinViewModel<SettingViewModel>()
SettingScreen(navController, viewModel)
}
}
Finally,
you just need to collect that state in your Compose UI
and modify your UI through that state.
You can use
val uiState by viewModel.uiState.collectAsState()
to collect
the state changes in the viewmodel’s uiState.
By directly calling uiState values in your code,
you can dynamically set up your screen.
For example:
uiState.isLoading
uiState.transferOptionTitle
uiState.error
(see the code below)
@Composable
fun SettingScreen(navController: NavController, viewModel: SettingViewModel) {
val uiState by viewModel.uiState.collectAsState()
val config = createSettingConfig(navController)
// Trigger logic
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
}
}
)
}
}
}
}
}
Here’s the result of the example above: