Search code examples
androidkotlinandroid-jetpack-compose

mutableStateListOf does not recompose on updating a single item


I have a data class

data class Item(
    val name: String,
    var isSelected: Boolean,
)

I have a list with items

val itemStateList = mutableStateListOf<Item>()

I have the following LazyColumn

LazyColumn( modifier = Modifier.fillMaxWidth(), state = listState ) {
    
    itemsIndexed(itemList, key = { _, item: Item -> item.hashCode() }) { index, item ->
        
        val backgroundColor =
          if (itemList[index].isSelected)
                MaterialTheme.colorScheme.secondary
            else
                Color.Transparent

        CustomCard(index, background)

     }
}

and the following method in the viewModel that triggers everytime I tap on an item in the list

fun setSelected(index: Int) {
    itemStateList[index] = itemStateList[index].copy(isSelected = true)
}

I know that updating isSelected alone will not trigger a recomposition, but using the copy method above should trigger the recompostion, but it is not. How can I make it work so that when a single item is tapped, only that item is recomposed based on the value of itemList[index].isSelected?


Solution

  • The code you provided should work as intended (I'm actually not absoultely sure about your usage of the LazyColumn's key, though; see below). I guess the problem is somewhere in your composable above the LazyColumn. But instead of trying to fix that you should use another approach entirely. In general, you shouldn't use State objects in the view model, it can lead to some tricky problems, especially with unit tests. You should use a MutableStateFlow instead:

    private val _itemList = MutableStateFlow<List<Item>>(emptyList())
    val itemList = _itemList.asStateFlow()
    
    fun setSelected(indexToSelect: Int) = _itemList.update {
            it.mapIndexed { index, item ->
                if (index == indexToSelect) item.copy(isSelected = true)
                else item
            }
        }
    }
    

    In you composables you can the access it like this:

    val itemList by viewModel.itemList.collectAsStateWithLifecycle()
    

    (You need the gradle dependency androidx.lifecycle:lifecycle-runtime-compose for that)

    Using a Flow will aslo make it easier if you later decide to change the source of the list to something else, like a database for example.

    Now, although this successfully replaces your State with a Flow, there are some other things that need to be addressed. First off, as Tenfour04 already mentioned in the comments, your data class should be immutable and var should be replaced with val.

    Furthermore the LazyColumn seems unnecessariliy complicated. You should change it to this:

    LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) {
        items(itemList) { item ->
            val backgroundColor = if (item.isSelected)
                MaterialTheme.colorScheme.secondary
            else
                Color.Transparent
    
            CustomCard(
                item = item,
                background = backgroundColor,
            ) {
                viewModel.setSelected(item.id)
            }
        }
    }
    

    You don't need the index and you don't need the key. The most important part, however, is the change to the parameters of CustomCard. The entire Item is passed now; after all that card should probably display the name, so the index alone is not of much use. I also added a last parameter that is a function that should be executed when the user clicks on the Card. You didn't provide CustomCard, but now it could look something like this:

    @Composable
    fun CustomCard(
        item: Item,
        background: Color,
        modifier: Modifier = Modifier,
        onClick: () -> Unit,
    ) {
        ElevatedCard(
            onClick = onClick,
            modifier = modifier.fillMaxWidth(),
            colors = CardDefaults.elevatedCardColors().copy(
                containerColor = background,
            ),
        ) {
            Text(item.name)
        }
    }
    

    CustomCard now doesn't need to know about the view model and the setSelected function, that is all hidden by the onClick parameter.

    In the LazyColumn we set the paramter to this:

    { viewModel.setSelected(item.id) }
    

    You might have already realized that this won't compile because there is no property id for an Item. That was on purpose, though, because it is not appropriate to use the index of a list that may change anytime to identify an item. Unless you can guarantee that name is unique you will need a new property that can uniquely identify an Item:

    data class Item(
        val id: Int,
        val name: String,
        val isSelected: Boolean,
    )
    

    Finally you need to update the view model's setSelected function to identify the Item by its id:

    fun setSelected(id: Int) = _itemList.update {
        it.map { item ->
            if (item.id == id) item.copy(isSelected = true)
            else item
        }
    }