I'm creating an app that, among other things, enables the user to add a Consumer, and then remove him later. The consumers are shown in cards with a remove button in the end.
Adding a consumer works fine. However, when I try to remove a consumer, the one removed in the app screen is always the last one. I know this is not a logic implementation mistake, because I stopped the Debugger right before the items() call, and in any recomposition the list holding the consumers has the correct consumer removed! The following image shows the result after clicking the Remove button from the "B" card (the card removed is "C"!):
Look what the debugger shows right before the recomposition takes place:
The relevant code is below.
The ViewModel and Model (relevant part) definitions:
class ConsumidoresViewModel : ViewModel() {
var lista = mutableStateListOf<Consumidor>()
fun add(consumidor: Consumidor){
lista += consumidor
}
fun remove(consumidor: Consumidor){
lista.remove(consumidor)
}
}
data class Consumidor(var nome: String)
...
The main composable, called directly from .onCreate():
fun UsersView() {
var consumidores: ConsumidoresViewModel = viewModel()
...
LazyColumn() {
items(items = consumidores.lista) { consumidor ->
CardNome(consumidor, consumidores)
}
}
The fucntion call of the Remove button:
IconButton(onClick = { consumidorViewModel.remove(consumidor) }) { ... }
I can't figure out what I'm doing wrong. I'm fairily new with Android Programming / Compose, but I have been programming for decades (not professionaly). Can someone point me to a direction? It probably has something to do with my Sates / View Model implementation, but I can't find out what, as the SnapshotStateList on the debugger clearly shows "A" and "C" cards present, and "B" gone!
Based on the official docs.
By default, each item's state is keyed against the position of the item in the list or grid. However, this can cause issues if the data set changes, since items which change position effectively lose any remembered state. If you imagine the scenario of LazyRow within a LazyColumn, if the row changes item position, the user would then lose their scroll position within the row.
So it's usually a good set up when your data class has a unique property like an id if you plan to manipulate a collection of it (like your removal operation), you can then use it as a key = {...} for the LazyColumn so it knows not to use the index as a unique identifier for its item elements, and that could be the reason why your'e having a wrong display of items after removing an element from the list.
LazyColumn() {
items(items = consumidorList, key = { it.id }) { consumidorItem ->
...
}
}
Update:
Linking my another answer for a movableContentOf{...} sample.
Related
Just FYI, I'm not exactly looking for a 'fix' but for an explanation and a discussion that might help understand a little bit more how seemingly silly things like these work.
I was working on this bigger project when I realized that somewhere, a certain list wasn't being updated correctly. Looking a little closer, the items, were correctly being modified, and if you 'scrolled away' and back, the item's information would be displayed correctly.
I stumbled upon this article:
ListAdapter not updating item in RecyclerView
But the difference here, is that in fact, DiffUtils was being called, but somehow the newItem and oldItem were the same! I understand that the library assumes you are using Room or any other ORM which offers a new async list every time it gets updated, but here's the thing. If I submit the list "naively" DiffUtils is not even called. But, if I submit the list as list.toMutableList() like some suggest then, DiffUtils IS called, but somehow the items, new and old, are already the same, hence, nothing gets updated at that moment (verified this by placing breakpoints inside areContentsTheSame).
I leave you here the relevant snippets and a link to a test project I created just so I could encapsulate the behavior and test it separately from everything else.
The Fragment - just calling the submitList
viewModel.items.observe(viewLifecycleOwner) {
adapter.submitList(it.toMutableList())
}
ViewModel
private val _items = MutableLiveData<List<SimpleItem>>()
val items: LiveData<List<SimpleItem>>
get() = _items
init {
_items.value = ItemsRepo.getItems()
}
fun onItemClick(itemId: Int) {
ItemsRepo.addItemCount(itemId)
_items.value = ItemsRepo.getItems()
}
The "Repo" I create some data
object ItemsRepo {
private var items = mutableListOf(
SimpleItem(1),
SimpleItem(2),
SimpleItem(3),
SimpleItem(4),
SimpleItem(5)
)
fun getItems(): List<SimpleItem> {
return items
}
fun addItemCount(itemId: Int) {
items.find { it.itemId == itemId }?.let {
it.itemClickCount += 1
}
}
The GitHub repo:
https://github.com/ellasaro/ListAdapterTest
Cheers!
Don't use mutable data classes or mutable lists with DiffUtil. It can lead to all kinds of problems. DiffUtil relies on comparing two lists, so if one of them is mutable and has been changed, it can't compare old and new successfully because there's no record of the previous state.
I didn't take the time to narrow down your exact issue, but I bet if you change your Repo's getItems() to return items.toList() (so mutating the Repo doesn't mutate downstream lists), and change SimpleItem to be an immutable class, your problems will go away.
Making SimpleItem immutable will be a little bit of hassle, unfortunately. The click listener instead of mutating the item will have to report back to the repo the id of the item that changed, and the repo must manually swap it out, and then you refresh the list.
It will be cleaner if your Repo returns a Flow of lists that automatically emits when changes are reported to it. Then your ViewModel doesn't have to both report changes and then remember to manually query the list state again.
I would use toList() and not toMutableList(). A mutable list communicates that you plan to mutate the list instead of just readding it, which you must never do with a list being passed to a DiffUtil.
Declaring the itemClickCount property as val, and getting the list as an immutable list from the Repo object did the trick as Tenfour04 suggested.
As an additional observation, if I keep the itemClickCount property as var but replace the element altogether and re-submit the updated list, it works correctly. So the problem seems to be modifying the object's mutable property directly in the Repo's list. Using .toList() in getList() didn't help in that case.
How to update, delete or add item in a LazyColumn in Jetpack Compose ?
Which is easily achieve by adapter.notifydatasetchange or diffutils in recyclerview
Just do exactly that on the collection you are passing to items function. This is simple as that. If your class is stable (just use data class) you are fine, it will just work. Welcome to Compose magic.
If you want the best performance on update/delete/add then expose a stable key in the items function, see here for more details: https://developer.android.com/jetpack/compose/lists#item-keys
Example:
#Composable
fun MessageList(messages: List<Message>) {
LazyColumn {
items(
items = messages,
key = { message ->
// Return a stable + unique key for the item
message.id
}
) { message ->
// Display entry here
MessageRow(message)
}
}
}
Here, if you provide the key lambda, the compose will know that this entry is the same entry - just with different content. If you do not provide this lambda - index in list will be used as key. So any addition other than at the end of the list would trigger a lot of recompositions. So it is more or less like a diff utils. You only have to provide this, because content equality is handled by compose implicitly - via equals of the Message object.
So if you want to remove one message from the list - remove it and pass new list to MessageList. Compose will handle the rest for you
I know that in Jetpack Compose you have to change the state of the passed in data in order to trigger a recomposition of the UI to update the UI with any changes. I have also read the documentation about Jetpack Compose state and ViewModels here. But that's a very simple example and does not cover the use case below.
Below is a conceptual scenario where I want to update the state of the list, by updating just one item's state that I wish to be reflected in the Jetpack Compose rendered part. I know I must assign a new list as data, which should trigger the recomposition and below I am using toMutableList() to try to achieve this. But this does not work. When I run this kind of code, recomposition does not happen and the single item's state is not updated in the list.
Could someone please explain to me why this does not work and how should I approach this?
I already know of mutableStateListOf(), but how should I approach this if I want to keep my view models compatible with other non-Jetpack Compose parts of my app, and thus I only want to use LiveData in my view models?
class Model : ViewModel() {
private val _items = MutableLiveData(listOf<Something>())
val items: LiveData<String> = _items
fun update(item: Something) {
_items.value = _items.value!!.toMutableList().map {
if (it == item) {
// Update item. But it's not reflected in Jetpack Compose
}
}
}
}
#Composable
fun ListComponent(model: Model) {
val items by model.items.observeAsState(emptyList())
LazyColumn {
items(items) { item ->
...
}
}
}
I think it's because you are mutating array instead of copying it. Compose needs stable equality when talking about recomposition avoidance, here i believe it can only use reference. Try copying array and then mutating the new one. I believe if you do map without toMutableList() it will create a copy and do exactly what you want
Can someone explain me what's the main purpose of the 'key' parameter inside items/itemsIndexed function of LazyListScope? What do we get or don't get if we specify that parameter? I'm not sure that I understand the official docs related to this parameter:
key - a factory of stable and unique keys representing the item. Using
the same key for multiple items in the list is not allowed. Type of
the key should be saveable via Bundle on Android. If null is passed
the position in the list will represent the key. When you specify the
key the scroll position will be maintained based on the key, which
means if you add/remove items before the current visible item the item
with the given key will be kept as the first visible one.
I think the best answer is provided by the official doc:
By default, each item's state is keyed against the position of the item in the list. However, this can cause issues if the data set changes, since items which change position effectively lose any remembered state. If you imagine the scenario of LazyRow within a LazyColumn, if the row changes item position, the user would then lose their scroll position within the row.
To combat this, you can provide a stable and unique key for each item, providing a block to the key parameter. Providing a stable key enables item state to be consistent across data-set changes:
#Composable
fun MessageList(messages: List<Message>) {
LazyColumn {
items(
items = messages,
key = { message ->
// Return a stable + unique key for the item
message.id
}
) { message ->
MessageRow(message)
}
}
}
you can use this way
#Composable
fun A(list: MutableList<Model>) {
Column {
LazyColumn {
items(
count = list.size,
key = {
list[it].id
}, itemContent = { index ->
Text(text = list[index].text)
}
)
}
}
}
Say that, I'm building a custom compose layout and populating that list as below
val list = remember { dataList.toMutableStateList()}
MyCustomLayout{
list.forEach { item ->
key(item){
listItemCompose( data = item,
onChange = { index1,index2 -> Collections.swap(list, index1,index2)})
}
}
This code is working fine and the screen gets recomposed whenever onChange lambda function is called, but when it comes to any small change in any item's property, it does not recompose, to elaborate that let's change the above lambda functions to do the following
{index1,index2 -> list[index1].propertyName = true}
Having that lambda changing list item's property won't trigger the screen to recompose. I don't know whether this is a bug in jetpack compose or I'm just following the wrong approach to tackle this issue and I would like to know the right way to do it from Android Developers Team. That's what makes me ask if there is a way to force-recomposing the whole screen.
You can't force a composable function to recompose, this is all handled by the compose framework itself, there are optimizations to determine when something has changed that would invalidate the composable and to trigger a recomposition, of only those elements that are affected by the change.
The problem with your approach is that you are not using immutable classes to represent your state. If your state changes, instead of mutating some deep variable in your state class you should create a new instance of your state class (using Kotin's data class), that way (by virtue of using the equals in the class that gets autogenerated) the composable will be notified of a state change and trigger a recomposition.
Compose works best when you use UDF (Unidirectional Data Flow) and immutable classes to represent the state.
This is no different than, say, using a LiveData<List<Foo>> from the view system and mutating the Foos in the list, the observable for this LiveData would not be notified, you would have to assign a new list to the LiveData object. The same principle applies to compose state.
you can recreate an entire composition using this
val text = remember { mutableStateOf("foo") }
key(text.value) {
YourComposableFun(
onClick = {
text.value = "bar"
}
) {
}
}
call this
currentComposer.composition.recompose()