I am using the MVVM model and have come across a problem with Stateflow that I cannot find a solution. Perhaps I am approaching the problem in the wrong way.
Each of my Models responds to a specific input from various network inputs. I use Stateflows to pass the changes along to the Viewmodel, which keeps the list of Models and in turn passes the info to the View which displays the changes.
/** Model that holds an Int and increments every second to simulate network updates */
class MySimpleModel(
startValue: Int,
coroutineScope: CoroutineScope
) {
private val _x = MutableStateFlow(startValue)
val x = _x.asStateFlow()
init {
coroutineScope.launch {
while (true) {
delay(1000)
_x.update { it + 1 } // increment every second
Log.d("MySimpleModel", "x -> ${x.value}")
}
}
}
}
This Viewmodel holds a list of Models. The number of Models is dynamic and can increase or decrease at any time. This is emulated by starting with no Models, waiting two seconds, and then adding two Models. Additional Models can be added by calling add()
.
And this seems be the rub. The data of the models do not update every second (even though the Log statement in MySimpleModel
indicates that it has updated).
However, when a new model is added, the data does update. But just that one time; the second-by-second incrementation is ignored.
class MySimpleViewmodel : ViewModel() {
private val _modelList = MutableStateFlow<List<MySimpleModel>>(listOf())
val modelList = _modelList.asStateFlow()
init {
// make a list after 2 seconds
viewModelScope.launch {
delay(2000)
_modelList.update {
listOf(
MySimpleModel(3, viewModelScope),
MySimpleModel(78, viewModelScope)
)
}
}
}
fun add(dataToAdd: MySimpleModel) {
_modelList.update {
it + dataToAdd
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val viewmodel = MySimpleViewmodel()
val modelList = viewmodel.modelList.collectAsStateWithLifecycle()
FlowTest4listOfFlowsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
SimpleMainScreen(
modifier = Modifier.padding(innerPadding),
viewmodel = viewmodel,
modelList.value
)
}
}
}
}
}
@Composable
private fun SimpleMainScreen(
modifier: Modifier,
viewmodel: MySimpleViewmodel,
modelList: List<MySimpleModel>
) {
Column(
modifier = modifier
) {
// buttons
Row {
Button(
onClick = { viewmodel.add(MySimpleModel(6, viewmodel.viewModelScope)) }
) { Text("add") }
}
// the data
modelList.forEach { model ->
Text(text = "${model.x.value}")
}
}
}
Any help about what I'm doing wrong will be greatly appreciated.
You never converted the MySimpleModel.x
flows into State objects, so you only get what their value
property contains when you happen to access it. Instead, you want to observe the flows for changes.
Before applying collectAsStateWithLifecycle()
, please be aware that models should only be simple data structures (usually data classes). Your MySimpleModel
seems to be more like a data source, since it generates values on its own.
But data sources should never be accessed directly by composables. Instead, their values should be transformed by the view model into UI state. The view model's modelList
should therefore be of type StateFlow<List<Int>>
.
To collect the flows from the list of MySimpleModels into a single flow you can do the following:
val modelList: StateFlow<List<Int>> = _modelList
.flatMapLatest { models ->
combine(
flows = models.map(MySimpleModel::x),
transform = Array<Int>::toList,
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5.seconds),
initialValue = emptyList(),
)
flatMapLatest
switches the _modelList
flow for another flow. That flow is created by combine
ing all MySimpleModels' flows into a single flow, effectively transforming a List<Flow<Int>>
into a Flow<List<Int>>
.
The resulting flow isn't a StateFlow anymore, so you need to make it one by calling stateIn()
.
That's it, you just need to change SimpleMainScreen so the parameter modelList
is of type List<Int>
now and everything should work as expected.
But while we're at it:
You still use MySimpleModel in your composable to create a new object. That should also be encapsulated by the view model. Your composable should only pass the startValue:
fun add(startValue: Int) {
_modelList.update {
it + MySimpleModel(startValue, viewModelScope)
}
}
Also, for the same reason that your composables shouldn't directly work with data sources like your (mis-named) MySimpleModel, they should also not pass complex objects around like view models. Instead, change SimpleMainScreen to this:
@Composable
fun SimpleMainScreen(
modifier: Modifier,
add: (Int) -> Unit,
modelList: List<Int>,
) {
// ...
Button(
onClick = { add(6) }
) { Text("add") }
// ...
}
You only needed the view model to call the add
function. Instead of passing the entire view model, you just need to pass that function now:
SimpleMainScreen(
modifier = Modifier.padding(innerPadding),
add = viewmodel::add,
modelList = modelList.value,
)