Jetpack Compose: No recomposition happening, when updating list element contents
Asked Answered
H

2

10

I am experimenting with Android's Jetpack Compose.
For simple use cases everything is working as expected,
but I am having some trouble with missing recompositions for a more advanced case.

My Model:

I am simulating a Storage system for ingredients, where

  • an ingredient consists of a name and an optional icon:
data class Ingredient(val name: String, @DrawableRes val iconResource: Int? = null)
  • a StorageItem consists of an ingredient and a stock (amount of this ingredient in storage):
data class StorageItem(val ingredient: Ingredient, var stock: Int)

My Composables:

My composables for the StorageUi are supposed to list all storage items
and display icon and name for the ingredient, as well as the stock.
For this post, I stripped it of all irrelevant modifiers and formatting to simplify readability.
(Note that I overloaded my StorageScreen composable with a second version without view model
for easier testing and in order to facilitate the Preview functionality in Android Studio.)

    @Composable
    fun StorageScreen(viewModel: StorageViewModel) {
        StorageScreen(
            navController = navController,
            storageItems = viewModel.storageItems,
            onIngredientPurchased = viewModel::purchaseIngredient
        )
    }

    @Composable
    fun StorageScreen(storageItems: List<StorageItem>, onIngredientPurchased: (StorageItem) -> Unit) {
        Column {
            TitleBar(...)
            IngredientsList(storageItems, onIngredientPurchased)
        }
    }

    @Composable
    private fun IngredientsList(storageItems: List<StorageItem>, onIngredientPurchased: (StorageItem) -> Unit) {
        LazyColumn {
            items(storageItems) { storageItem ->
                IngredientCard(storageItem, onIngredientPurchased)
            }
        }
    }

    @Composable
    private fun IngredientCard(storageItem: StorageItem, onIngredientPurchased: (StorageItem) -> Unit) {
        Card(
            Modifier.clickable { onIngredientPurchased(storageItem) }
        ) {
            Row {
                ImageIcon(...)

                Text(storageItem.ingredient.name)

                Text("${storageItem.stock}x")
            }
        }
    }

My View Model:

In my ViewModel, I

  • create a mutable state list (initialization with data not shown here)
  • provide the event handler that increases the stock, if the user taps an ingredient card
    class StorageViewModel : ViewModel() {

        var storageItems = mutableStateListOf<StorageItem>()
            private set

        fun purchaseIngredient(storageItem: StorageItem) {
            storageItem.stock += 1
        }

    }

The Problem: No recomposition takes place when changing the stock of an ingredient

I tried changing the event handler to simply remove the tapped item from the list:

        fun purchaseIngredient(storageItem: StorageItem) {
            storageItems.remove(storageItem)
        }

And voilà, the UI recomposes and the tapped ingredient is gone.

What I learned:

  • mutableStateListOf() does observe changes to the list (add, remove, reorder)
  • mutableStateListOf() does NOT observe changes to elements within the list (ingredient name/icon/stock changes)

What I would like to learn from you guys:

  • How would you go about solving this issue?
  • What can I do to achieve a recomposition, if any element within the list changes its state?
Homolographic answered 25/10, 2021 at 15:53 Comment(3)
Not sure how to fit this together. Should my IngredientsList now take a: storageItems: State<List<StorageItem>> ? Do I now use items(storageItems.value) in my IngredientsList? Tried that, does not work either, but probably I didn't do exactly what you intended to convey.Homolographic
I just answered a similar question here: https://mcmap.net/q/441775/-android-jetpack-compose-mutablestatelistof-not-doing-recompositionMooring
I checked out your other post. Thank you very much for your solution. The usage of a random value in order to update LiveData doesn't feel particularily clean to me, though. For now, I think I will stick to he mutableStateList until the perfect solution is found :D dirty workaround for now: storageItems.add(StorageItem(Ingredient("", 0), 0)) and storageItems.removeLast()Homolographic
C
5
  1. You can either remove the item and re-add it with the new value, this will cause recomposition to the list as the change is structural. The con is that depending on your list implementation this might not be O(1).

  2. You can use Compose State to hold and mutate this state. This will cause recomposition as youre writing to snapshot state.

These two options are discussed here when working with the checkbox: https://developer.android.com/codelabs/jetpack-compose-state#11

"This is because what Compose is tracking for the MutableList are changes related to adding and removing elements. This is why deleting works. But it's unaware of changes in the row item values (checkedState in our case), unless you tell it to track them too."

Cycad answered 29/6, 2023 at 15:41 Comment(1)
Thank you I read that earlier but forgot. You saved my day.Cease
P
3

What can I do to achieve a recomposition, if any element within the list changes its state?

Recomposition will happen only when you change the list itself. You can do it this way.

class StorageViewModel : ViewModel() {

     var storageItems by mutableStateOf(emptyList<StorageItem>())
        private set

     fun purchaseIngredient(storageItem: StorageItem) {
        storageItems = storageItems.map { item ->
            if(item == storageItem)
                item.copy(stock = item.stock + 1)
            else
                item
        }
     }
}

Since this is a very common operation, you can create an extension function to make it look a little nicer.

fun <T> List<T>.updateElement(predicate: (T) -> Boolean, transform: (T) -> T): List<T> {
    return map { if (predicate(it)) transform(it) else it }
}

fun purchaseIngredient(storageItem: StorageItem) {
    storageItems = storageItems.updateElement({it == storageItem}) {
        it.copy(stock = it.stock + 1)
    }
}

Parfleche answered 26/10, 2021 at 5:50 Comment(3)
This sounds promising, thank you very much! If I understand correctly, this however will make it impossible for me to add storage items on the fly, since storageItems is now immutable?Homolographic
To add an item you can do storageItems += newItem. See this example.Parfleche
I don't know if this is applicable to all scenarios but I'm doing pretty much the same thing. I'm changing element in list. That seems to trigger the recomposition. Fe: myList[index] = myList[index].copy( ... )Enslave

© 2022 - 2024 — McMap. All rights reserved.