Search code examples
androidkotlinandroid-jetpack-composekotlin-stateflow

MutableStateFlow emission criteria explanation


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.


Solution

  • 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)) {
    //....