7

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?
Mohamed Medhat
  • 761
  • 11
  • 16
Tobias M.
  • 71
  • 1
  • 4
  • Not sure how to fit this together. Should my IngredientsList now take a: `storageItems: State>` ? 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. – Tobias M. Oct 25 '21 at 16:28
  • I just answered a similar question here: https://stackoverflow.com/a/69718724/753632 – Johann Oct 26 '21 at 07:14
  • 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()` – Tobias M. Oct 26 '21 at 17:39

2 Answers2

2

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)
    }
}

Arpit Shukla
  • 9,612
  • 1
  • 14
  • 40
  • 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? – Tobias M. Oct 26 '21 at 17:53
  • 2
    To add an item you can do `storageItems += newItem`. See [this](https://pl.kotl.in/jKbbV60w9) example. – Arpit Shukla Oct 26 '21 at 19:05
  • 1
    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( ... ) – Paweł Galiński Mar 01 '23 at 13:59
0
  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."

Alejandra
  • 576
  • 6
  • 10