I'm writing a simple todo app using compose and stateflow.
data class Task(
val name: String,
val isCompleted: Boolean,
val date: Long
)
class SomeViewModel : ViewModel() {
private val _tasks = MutableStateFlow<List<Task>>(listOf())
val tasks = _tasks.asStateFlow()
val todo = mutableListOf<Task>(
Task("Mop floor", false, System.currentTimeMillis()),
Task("Buy soap", true, System.currentTimeMillis()+1),
Task("Oranize toolbox", false, System.currentTimeMillis()+2)
)
init {
initialize(todo)
}
fun initialize(todo: List<Task>) {
_tasks.value = todo
}
fun toggleState(task: Task) {
val updatedList = todo.map {
if (it.name == task.name) task.copy(isCompleted = !it.isCompleted)
else it
}
todo.clear()
todo.addAll(updatedList)
_tasks.value = todo
}
My composable was not able to receive an update
class SomeFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
MaterialTheme {
MainContent()
}
}
}
@Composable
private fun MainContent() {
val viewModel: SomeViewModel = viewModel()
Box(modifier = Modifier.fillMaxSize()) {
val tasks = viewModel.tasks.collectAsStateWithLifecycle().value
ToDoList(tasks, viewModel)
}
}
@Composable
private fun ToDoList(todoList: List<Task>, vm: SomeViewModel) {
LazyColumn {
items(todoList) { task ->
ToDoRow(task, vm)
}
}
}
@Composable
private fun ToDoRow(task: Task, vm: SomeViewModel) {
Row(modifier = Modifier.fillMaxWidth().clickable { vm.toggleState(task) }) {
Text(text = task.name, color = if (!task.isCompleted) Color.Red else Color.Green)
Text(text = task.date.toString(), color = if (!task.isCompleted) Color.Red else Color.Green)
}
}
I thought it was because MutableStateFlow only emits state changes, in this case my todo list. Even though toggleState changes an item in todo, my todo reference itself is unchanged.
So I rewrote toggleState
fun toggleState(task: Task) {
val updatedTask = task.copy(isCompleted = !task.isCompleted)
val indexOf = todo.indexOfFirst { it.name == task.name }
todo[indexOf] = updatedTask
val list = mutableListOf<Task>()
list.addAll(todo)
_tasks.value = list
}
Now _tasks is updated with a new reference, and I expect this to be observed by my MainContent composable. But it was not, as tasks again did not propagate a state change. I want to understand the mechanism StateFlow uses to determine a change in state. I don't understand why it is so complicated to updatte state using StateFlow.
Your second toggleState
doesn't work because structural equality between lists checks equality and order of all the elements, like in Java. So when you update the existing MutableList
and then copy all the elements to a new MutableList
, MutableStateFlow
doesn't actually update.
To avoid this you shouldn't use MutableList
for State
at all, see warning about using mutable lists in docs:
Caution: Using mutable objects such as ArrayList or mutableListOf() as state in Compose causes your users to see incorrect or stale data in your app. Mutable objects that are not observable, such as ArrayList or a mutable data class, are not observable by Compose and don't trigger a recomposition when they change. Instead of using non-observable mutable objects, the recommendation is to use an observable data holder such as State<List> and the immutable listOf().
To create a new list in the toggleState
you can use List.map
or available in Compose function List.fastMap
the way @Meet suggested:
_tasks.update { list ->
list.fastMap { task ->
if (task.name == taskName) task.copy(isCompleted = !task.isCompleted) else task
}
}
Unrelated: you shouldn't pass ViewModel
down to your composables, pass only event lambdas:
//...
ToDoList(tasks, viewModel::toggleState)
}
}
@Composable
private fun ToDoList(todoList: List<Task>, toggleTask: (Task) -> Unit) {
LazyColumn {
items(todoList) { task ->
ToDoRow(task) { toggleTask(task) }
}
}
}
@Composable
private fun ToDoRow(task: Task, toggleTask: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth().clickable(onClick = toggleTask)) {
//....