androidkotlinandroid-jetpack-composesetkotlin-flow

Android Compose MutableStateFlow: Adding to Set does not invoke recomposition


There are many similar questions about this, but usages have been changing; this is the only question I've found that talks specifically about MutableSets, MutableStateFlows, and collectAsState().

The issue is that when I add an element to my MutableSet, the composable does not recompose. The following code is as simple as I can get it and still show the problem.

Viewmodel

class MyViewmodel : ViewModel() {

    private val _infoSet = MutableStateFlow(mutableSetOf<MyInfo>())
    val infoSet = _infoSet.asStateFlow()

    fun add() {
        val newInfo = MyInfo(num = infoSet.value.size + 1)
        _infoSet.value.add(newInfo)
        Log.d(TAG, "infoSet is now ${infoSet.value}")
    }
}

data class MyInfo(
    val name: String = "foo",
    val num: Int = -1
)

MainActivity

class MainActivity : ComponentActivity() {

    private lateinit var myViewmodel: MyViewmodel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        myViewmodel = MyViewmodel()

        setContent {
            val infoSet by myViewmodel.infoSet.collectAsStateWithLifecycle()
            RecompositionTestTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    DrawStuff(
                        modifier = Modifier.padding(innerPadding),
                        infoSet = infoSet,
                        myViewmodel
                    )
                }
            }
        }
    }
}

@Composable
fun DrawStuff(
    modifier: Modifier = Modifier,
    infoSet: Set<MyInfo>,
    viewmodel: MyViewmodel
) {
    Log.d(TAG, "DrawStuff() start")
    LazyColumn(modifier = modifier.safeDrawingPadding()) {
        item { Text("This should display a Set of MyInfo") }
        item {
            Button(
                onClick = {
                    viewmodel.add()
                    Toast.makeText(ctx, "infoSet = $infoSet", Toast.LENGTH_SHORT).show()
                }
            ) { Text("add") }
        }

        // the MyInfo list
        infoSet.forEach { info ->
            Log.d(TAG, "listing the MyInfos $info")
            item {
                Text("${info.name}, ${info.num}")
            }
        }
    }
}

The Log statements show that clicking the button calls MyViewmodel.add(). But logs also clearly show that DrawStuff does not get called. Unexepectedly the Toast message correctly shows information.

Is there something I'm doing wrong here? What do I need to do to get DrawStuff() to display the contents of infoSet when it changes? (Note: I have made tests where infoSet was pre-loaded with data. It draws correctly, but still won't change when items are added.)


Solution

  • As a rule of thumb, never use a mutable object in a StateFlow. You use a MutableSet as the content of the flow. Replace it with an immutable Set and everything will work as intended.

    The reason for that is that the StateFlow never gets notified of the modification of its value's content so it also cannot notify the collector that there is a new value.

    In your view model, just change _infoSet to this:

    private val _infoSet = MutableStateFlow(emptySet<MyInfo>())
    

    Now, you cannot add any new items to this set anymore, so _infoSet.value.add(newInfo) won't compile. The solution is to create a new set (also immutable), consisting of the old values plus the new value. That can easily be done like this:

    _infoSet.update { it + newInfo }
    

    Although _infoSet.value = _infoSet.value + newInfo would also seem to work at first glance, this might actually lead to inconsistent updates because another thread might change _infoSet.value during the calculation. The thread-safe way to update a MutableStateFlow when you need the old value to calcuate the new value is to use the update method as shown above.

    Now the StateFlow can notify its collectors when its value changes, so Compose can trigger a recomposition to update the UI accordingly.


    Edit: I just realized that you access the flow's current value in the creation of newInfo as well. So that also must be moved inside the update lambda to make it consistent:

    _infoSet.update {
        val newInfo = MyInfo(num = it.size + 1)
        it + newInfo
    }