State object from multiple flows - android

I need to have a state in a ViewModel which gets updated by collecting from flows. my State class:
data class DevicesUIState(
val currentGroup: GroupEntity? = null,
val currentGroupSubGroups: List<GroupEntity> = listOf(),
val currentGroupSubDevices: List<DeviceEntity> = listOf(),
val allDevices: List<DeviceEntity> = listOf(),
)
Each property should be set from a flow. The flow setting currentGroup & allDevices property should not wait for each other to emit a result. The flow setting currentGroupSubGroups & currentGroupSubDevices depends on currentGroup being set.
Each flow collects from a room DB
So far my code to set the state properties looks like this
val groupId: Int?
val uiState: StateFlow<DevicesUIState> = groupsRepository.groupFlow(groupId)
.mapLatest {
Timber.e("Mapping currentGroup")
DevicesUIState(currentGroup = it)
}.flatMapLatest { uiState ->
Timber.e("Mapping currentGroupSubGroups")
groupsRepository.subGroupsFlow(uiState.currentGroup?.id).map {
uiState.copy(currentGroupSubGroups = it)
}
}.flatMapLatest { uiState ->
Timber.e("Mapping currentGroupSubDevices")
liteDeviceRepository.subDevicesFlow(uiState.currentGroup?.id).map {
uiState.copy(currentGroupSubDevices = it)
}
}.flatMapLatest { uiState ->
Timber.e("Mapping allDevices")
liteDeviceRepository.allDevicesFlow().map {
uiState.copy(allDevices = it)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DevicesUIState()
)
It's working. But looking at the logged messaged the flows are collected more offend that i expected. So i have a feeling my code is not the correct way of doing things.
Log
I --> GET https://example.com/api/groups
E Mapping currentGroup
E Mapping currentGroupSubGroups
E Mapping currentGroupSubDevices
E Mapping allDevices
I <-- 200 https://example.com/api/groups (393ms, unknown-length body)
E Mapping currentGroup
E Mapping currentGroupSubGroups
E Mapping currentGroupSubDevices
E Mapping allDevices
I --> GET https://example.com/api/devices
I <-- 200 https://example.com/api/devices (85ms, unknown-length body)
E Mapping allDevices
I --> GET https://example.com/api/extraDevicesData
I <-- 200 https://example.com/api/extraDevicesData (258ms, unknown-length body)
E Mapping allDevices

To build this so the flows that aren't dependent on each other can run in parallel, I'd change it as follows.
The group and the two that are dependent on group can be created using flatMapLatest with an inner combine. This allows either the subGroups or the subDevices to change without triggering a restart of other flows.
Then that result and the allDevices flow can also be combined. Likewise, this allows the group and the allDevices to not trigger each other to restart.
Sorry for any syntax errors. I'm not testing this.
val uiState: StateFlow<DevicesUIState> = groupsRepository.groupFlow(groupId)
.flatMapLatest { currentGroup ->
val id = currentGroup.id
groupsRepository.subGroupsFlow(id)
.combine(liteDeviceRepository.subDevicesFlow(id)) { subGroups, subDevices ->
Timber.e("Combining subGroups and subDevices")
DevicesUIState(
currentGroup = currentGroup,
currentGroupSubGroups = subGroups,
currentGroupSubDevices = subDevices
)
}
}.combine(liteDeviceRepository.allDevicesFlow()) { uiState, allDevices ->
Timber.e("Combining groups and allDevices")
uiState.copy(allDevices = allDevices)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DevicesUIState()
)
You might also consider debouncing the two sub flows before combining them if they both frequently emit new values at the same time.

Related

Jetpack Compose: Provide initial value for TextField

I want to achieve the following use case: A payment flow where you start with a screen to enter the amount (AmountScreen) to pay and some other screens to enter other values for the payment. At the end of the flow, a summary screen (SummaryScreen) is shown where you can modify the values inline. For the sake of simplicity we will assume there is only AmountScreen followed by SummaryScreen.
Now the following requirements should be realized:
on AmountScreen you don't loose your input on configuration change
when changing a value in SummaryScreen and go back to AmountScreen (using system back), the input is set to the changed value
AmountScreen and SummaryScreen must not know about the viewModel of the payment flow (PaymentFlowViewModel, see below)
So the general problem is: we have a screen with an initial value for an input field. The initial value can be changed on another (later) screen and when navigating back to the first screen, the initial value should be set to the changed value.
I tried various approaches to achieve this without reverting to Kotlin flows (or LiveData). Is there an approach without flows to achieve this (I am quite new to compose so I might be overlooking something obvious). If flows is the correct approach, would I keep a MutableStateFlow inside the PaymentFlowViewModel for amount instead of a simple string?
Here is the approach I tried (stripped and simplified from the real world example).
General setup:
internal class PaymentFlowViewModel : ViewModel() {
var amount: String = ""
}
#Composable
internal fun NavigationGraph(viewModel: PaymentFlowViewModel = viewModel()) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "AMOUNT_INPUT_SCREEN"
) {
composable("AMOUNT_INPUT_SCREEN") {
AmountInputRoute(
// called when the Continue button is clicked
onAmountConfirmed = {
viewModel.amount = it
navController.navigate("SUMMARY_SCREEN")
},
// apply the entered amount as the initial value for the input text
initialAmount = viewModel.amount
)
}
composable("SUMMARY_SCREEN") {
SummaryRoute(
// called when the amount is changed inline
onAmountChanged = {
viewModel.amount = it
},
// apply the entered amount as the initial value for the input text
amount = viewModel.amount
)
}
}
}
The classes of the AmountScreen look like this:
#Composable
internal fun AmountInputRoute(
initialAmount: String,
onAmountConfirmed: (String) -> Unit
) {
// without the "LaunchedEffect" statement below this fulfils all requirements
// except that the changed value from the SummaryScreen is not applied
val amountInputState: MutableState<String> = rememberSaveable { mutableStateOf(initialAmount) }
// inserting this fulfils the req. that the changed value from SummaryScreen is
// applied, but breaks keeping the entered value on configuration change
LaunchedEffect(Unit) {
amountInputState.value = initialAmount
}
Column {
AmountInputView(
amountInput = amountInputState.value,
onAmountChange = { amountInput ->
amountInputState.value = amountInput
}
)
Button(onClick = { onAmountConfirmed(amountInputState.value) }) {
Text(text = "Continue")
}
}
}
```
I achieved the goal with a quite complicated approach - I would think there are better alternatives out there.
What I tried that did not work: using rememberSaveable passing initialAmount as parameter for inputs. Theoretically rememberSaveable would reinitialize its value when inputs changes, but apparently this does not happen when the composable is only on the back stack and also is not executed when it gets restored from the back stack.
What I implemented that did work:
#Composable
internal fun AmountInputRoute(
initialAmount:String,
onAmountConfirmed: (String) -> Unit
) {
var changedAmount by rememberSaveable {
mutableStateOf<String?>(null)
}
val amountInput by derivedStateOf {
if (changedAmount != null)
changedAmount
else
initialAmount
}
AmountInputView(
amountInput = amountInput,
onContinueClicked = {
onAmountConfirmed(amountInput)
changedAmount = null
},
validAmountChanged = {
changedAmount = it
}
)
}
Any better ideas?

How to ignore/skip the initial value that StateFlow emitted?

In my Android project, I'm using DataStore and StateFlow to store and change the UI state. The problem is, every time the app starts, because of the necessary initial value of StateFlow, the corresponding theme setting will always set to the initial value before set to the value read from DataStore, and this will lead to a visual hit(change from Light to Dark or Dark to light) for users.
The following text shows the detail on achieving that(I use this sample code as reference):
Get the value from DataStore as a Flow and assign it to observeSelectedDarkMode(): Flow<String> function in my AppRepository class.
class AppRepository(
val context: Context
) : AppRepository {
private val dataStore = context.dataStore
override fun observeSelectedDarkMode(): Flow<String> = dataStore.data.map { it[KEY_SELECTED_DARK_MODE] ?: DEFAULT_VALUE_SELECTED_DARK_MODE }
...
}
Convert it to StateFlow using .stateIn() in my SettingsViewModel class.
class SettingsViewModel(
private val appRepository: AppRepository
) : ViewModel() {
val selectedDarkModel = appRepository.observeSelectedDarkMode().stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
AppDataStore.DEFAULT_VALUE_SELECTED_DARK_MODE
)
...
}
Use .collectAsState() in my CampusHelperTheme() fun to observe the change.
#Composable
fun CampusHelperTheme(
appContainer: AppContainer,
content: #Composable () -> Unit
) {
val settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.provideFactory(appContainer.appRepository)
)
val selectedDarkMode by settingsViewModel.selectedDarkModel.collectAsState()
val darkTheme = when (selectedDarkMode) {
"On" -> true
"Off" -> false
else -> isSystemInDarkTheme()
}
val context = LocalContext.current
val colorScheme = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
...
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
It seems that everything is good, but when the selectedDarkMode value is different from its default value AppDataStore.DEFAULT_VALUE_SELECTED_DARK_MODE(Follow System), the app will turn from Dark to Light or from Light to Dark, which would give users a bad experience.
So what I want is let the app skip the initial value of StateFlow and then the app can set to what the user choose once it launch, then there will not have a visual hit to users.
I search on this site, and found this ask: How to use DataStore with StateFlow and Jetpack Compose?, but it has no content about the initial value I am looking for, I also found this ask: Possible to ignore the initial value for a ReactiveObject?, but it is in C# and ReactiveUI, which is not suitable for me.
How should I achieve this?

Android mutableStateOf() value property not changing

I have a data class.
data class ServiceInfoState(
val availableServices : List<Services> = emptyList(),
val loadingState : AvailableServicesState = AvailableServicesState.Loading
)
In my view model I keep the state as such:
private val _uiState = mutableStateOf(ServiceInfoState())
val uiState : State<ServiceInfoState> = _uiState
Later I create a new updated list of services and want to update the state.
_uiState.value = uiState.value.copy(availableServices = updated)
It is my understanding that this will create a new shallow copy of uiState.value, update the available services list, and assign this to the private property _uiState.value. This change to the value property should cause the UI which is watching the publicly exposed version uiState to recompose. This does not happen.
I then check the code like this:
println("List Equality: ${_uiState.value.availableServices === updated}")
val before = _uiState.value
_uiState.value = uiState.value.copy(availableServices = updated)
val after = _uiState.value
println("State Equality: ${before === after}")
Which prints out:
List Equality: false
State Equality: true
I agree that the list is not equal. That was my intention. To have a distinct list just in case. What surprises me is that the value property is equal before and after the copy. Doesn't copy return a new shallow reference? Is this not how this update should occur?
So I see why I am not getting a recomposition. Compose does not see the value property change because it did not change. How do I get it to change?
You can use SnapshotMutationPolicy to control how the result of mutableStateOf report and merge changes to the state object. Take a look at my answer here:
https://stackoverflow.com/a/74301717/8389251

Boolean flows sync

I have few StateFlow fields in the ViewModel class. It's add/edit form screen where each StateFlow is validation property for each editable field on the screen.
I would like to write some class FormValidation with StateFlow property for validation state of whole form. Value of this field based on the values of validation state of all fields and emit true when all field is valid and false when any field is invalid.
Something like this:
class FormValidation(initValue: Boolean, vararg fieldIsValid: StateFlow<Boolean>) {
private val _isValid = MutableStateFlow(initValue)
val isValid: StateFlow<Boolean> = _isValid
init {
// todo: how to combine, subscribe and sync values of all fieldIsValid flows?
}
}
I know how to do it with LiveData<Boolean> and MediatorLiveData but i can't understand how to make it with flows.
Solution based on the answer of #tenfour04
class BooleanFlowMediator(scope: CoroutineScope, initValue: Boolean, vararg flows: Flow<Boolean>) {
val sync: StateFlow<Boolean> = combine(*flows) { values ->
values.all { it }
}.stateIn(scope, SharingStarted.Eagerly, initValue)
}
Demo code with StateFlow and ViewModel
class SyncViewModel : ViewModel() {
companion object {
private const val DEFAULT_VALUE: Boolean = false
}
private val values: List<List<Boolean>> = listOf(
listOf(false, false, false),
listOf(true, false, false),
listOf(false, true, true),
listOf(true, true, true)
)
private var index: Int = 0
private val _flow1 = MutableStateFlow(DEFAULT_VALUE)
val flow1: StateFlow<Boolean> = _flow1
private val _flow2 = MutableStateFlow(DEFAULT_VALUE)
val flow2: StateFlow<Boolean> = _flow2
private val _flow3 = MutableStateFlow(DEFAULT_VALUE)
val flow3: StateFlow<Boolean> = _flow3
val mediator = BooleanFlowMediator(viewModelScope, DEFAULT_VALUE,
flow1, flow2, flow3)
fun generateValues() {
val idx = (index + 1).mod(values.size).also { index = it }
val row = values[idx]
_flow1.value = row[0]
_flow2.value = row[1]
_flow3.value = row[2]
}
}
I think you can do this using combine. It returns a new Flow that emits each time any of the source Flows emits, using the latest values of each in a lambda to determine its emitted value.
There are also overloads of combine for up to five input Flows of different types, and one for an arbitrary number of Flows of the same type, which is what we want here.
Since Flow operators return basic cold Flows, but if you want to have a StateFlow so you can determine the initial value, you need to use stateIn to convert it back to a StateFlow with an initial value. And for that you'll need a CoroutineScope for it to run the flow in. I'll leave it to you to determine the best scope to use. Maybe it should be passed in from an owning class (like passing viewModelScope to it if the class instance is "owned" by the ViewModel). If you're not using a passed in scope, you will have to manually cancel the scope when this class instance is done with, or else the flow will leak.
I didn't test this code, but I think this should do it.
class FormValidation(initValue: Boolean, vararg fieldIsValid: StateFlow<Boolean>) {
private val scope = MainScope()
val isValid: StateFlow<Boolean> =
combine(*fieldIsValid) { values -> values.all { it } }
.stateIn(scope, SharingStarted.Eagerly, initValue)
}
However, if you don't need to synchronously inspect the most recent value of the Flow (StateFlow.value), then you don't need a StateFlow at all, and you can just expose a cold Flow. The instant the cold Flow is collected, it will start collecting its source StateFlows, so it will immediately emit its first value based on the current values of all the sources.
class FormValidation(initValue: Boolean, vararg fieldIsValid: StateFlow<Boolean>) {
val isValid: Flow<Boolean> = when {
fieldIsValid.isEmpty() -> flowOf(initValue) // ensure at least one value emitted
else -> combine(*fieldIsValid) { values -> values.all { it } }
.distinctUntilChanged()
}
}

How to combine the result of 2 LiveData fields into another LiveData field of a different type

I have a ViewModel class with 2 livedata observables (eg a and b) that are both of a nullable type and
I want to add a new boolean observable (eg c) that is true whenever both a and b are not null, and false otherwise.
I was recommended to use a LiveData Transformation to achieve this but I'm not quite sure how that would work. With a map transformation, I can only ever transform between a single observer, but I can't add multiple sources.
Then that lead me to looking at representing c as a MediatorLiveData and add a and b as sources but then that relies on the fact that they are all of the same type, so I'm not sure if I can use that either.
What's the idiomatic way to accomplish this?
the recommended approach for kotlin is StateFlow. It is like a liveData but more kotlin idiomatic.
this is an example of combining a String flow with an Int flow into a Boolean flow
class ExampleViewModel() : ViewModel() {
private val _a = MutableStateFlow<Int>(2)
val a = _a.asLiveData()
private val _b = MutableStateFlow<String>("example")
val b = _b.asLiveData()
// this will emit a value at each update of either _a or _b
val c = _a.combine(_b) { a, b -> a > b.length }.asLiveData()
// this will emit a value at each update of _a
val d = _a.zip(_b) { a, b -> a > b.length }.asLiveData()
// this will emit a value at each update of _b
val e = _b.zip(_a) { b, a -> a > b.length }.asLiveData()
// this is the same as d
val f = _a.map { it > _b.value.length }.asLiveData()
}
Learn more here

Categories

Resources