Android Leanback: How to update nested rows item in RowsSupportFragment - android

Hey Guys
I'm working on androidTV application using leanback library.
I should show list of categories that each category has it's own list of contents. For this approach leanback offered RowsSupportFragment that you can show this type of UI inside that.
Here I am using Room + LiveData + Retrofit + Glide to perform and implement the screen, but the issue is here, the api will not pass content cover images directly, so developer should download each content cover image, store it and then show covert over the content.
Every thing is working but at the first time, If there is no cover image for content, I will download the cover and store it, but content will not be triggered to get and show image. Using notifyItemRangeChanged and methods like this will blink and reset the list row so this is not a good solution.
This is my diffUtils that I'm using, one for category list, one for each contents list.
private val diffCallback = object : DiffCallback<CardListRow>() {
override fun areItemsTheSame(oldItem: CardListRow, newItem: CardListRow): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: CardListRow, newItem: CardListRow): Boolean {
return oldItem.cardRow.contents?.size == newItem.cardRow.contents?.size
}
}
private val contentDiffCallback = object : DiffCallback<ContentModel>() {
override fun areItemsTheSame(oldItem: ContentModel, newItem: ContentModel): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ContentModel, newItem: ContentModel): Boolean {
return oldItem.hashCode() == newItem.hashCode()
}
}
As I said, for storage I'm using room, retrieving data as LiveData and observing them in my fragment and so on. I have not posted all the codes for summarization.
If you have any idea or similar source code, I would appreciate it. Thanks
Edit: Fri Dec 2 --- add some more details
This is my live-data observer that holds and observe main list on categories and datas
private fun initViewModel() {
categoriesViewModel.getCategoriesWithContent().observe(viewLifecycleOwner) { result ->
val categoryModelList = MergedContentMapper().toCategoryModelList(result)
initData(categoryModelList)
}
}
And this is the row creation scenario using ArrayObjectAdapter
private fun initData(categoryModelList: List<CategoryModel>) {
showLoading(false)
createRows(categoryModelList)
}
private fun createRows(categoryModelList: List<CategoryModel>) {
val rowItemsList: MutableList<CardListRow> = arrayListOf()
// create adapter for the whole fragment. It displays Rows.
categoryModelList.forEach { categoryModel ->
// create adapter for each row that can display CardView using CardPresenter
val cardListRow = createCardRow(categoryModel)
// add card list rows into list
rowItemsList.add(cardListRow)
}
// set item with diff util
rowsAdapter.setItems(rowItemsList, diffCallback)
}
private fun createCardRow(categoryModel: CategoryModel): CardListRow {
val contentList = categoryModel.contents ?: emptyList()
val cardListRowsAdapter = ArrayObjectAdapter(CardPresenterSelector(context, this))
cardListRowsAdapter.setItems(contentList, contentDiffCallback)
val headerItem = HeaderItem(categoryModel.title)
return CardListRow(headerItem, cardListRowsAdapter, categoryModel)
}

Your code looks correct, but it's missing the part where you tell the Presenter what changed on your items so it can change only that piece of data and doesn't need to re-bind the entire content avoiding the blink.
After your DiffCallback detects that the items are the same but the content has changed it will call its getChangePayload() function to gather details about the changes to pass them to the Presenter. To achieve that you need to do the following changes:
First, you need to override the DiffCallback.getChangePayload() function to something like this:
override fun getChangePayload(oldItem: ListRow, newItem: ListRow): Any {
return when {
oldItem.headerItem.name != newItem.headerItem.name -> "change_title"
else -> "change_items"
}
}
With that your ListRowPresenter will receive the information of what changed in the ListRowPresenter.onBindViewHolder() overload that receives a payload list (returned by your DiffCallback) like so:
override fun onBindViewHolder(
viewHolder: Presenter.ViewHolder?,
item: Any?,
payloads: MutableList<Any>?
) {
when {
payloads == null || payloads.isEmpty() -> {
// Your DiffCallback did not returned any information about what changed.
super.onBindViewHolder(viewHolder, item, payloads)
}
"change_title" in payloads -> getRowViewHolder(viewHolder)?.let {
headerPresenter.onBindViewHolder(it.headerViewHolder, item)
}
"change_items" in payloads -> {
val newItems = ((item as? ListRow)?.adapter as? ArrayObjectAdapter)?.unmodifiableList<Item>()
when (val listRowAdapter = (getRowViewHolder(viewHolder).row as? ListRow)?.adapter) {
is ArrayObjectAdapter -> listRowAdapter.setItems(newItems, null)
else -> super.onBindViewHolder(viewHolder, item, payloads)
}
}
else -> {
// If you don't know what changed just delegate to the super.
super.onBindViewHolder(viewHolder, item, payloads)
}
}
}
Customize the implementation of DiffCallback.getChangePayload() to your needs. You can return a list of changes in this function and treat all of them in your ListRowPresenter.onBindViewHolder().
I recently wrote a blog post with samples that might help.

Related

Kotlin ListAdapter reset RecyclerView after submitList

I'm working on android apps using MVVM, and Data Binding. I'm using ListAdapter for my RecyclerView Adapter. The case is, when I submit new data to the adapter using submitList, it reset RecyclerView scroll position. It blink at first and just reset it's position to the top.
My Binding Adapter
#BindingAdapter("listTemplate", "hirarki")
fun bindListTemplate(recyclerView: RecyclerView, data: List<Template>?, hirarki: Int) {
var adapter = recyclerView.adapter as TemplateChiefAdapter
adapter.submitList(data)
}
TemplateFragment where I resubmit my data
navController.currentBackStackEntry?.savedStateHandle?.getLiveData<Boolean>("shouldUpdate")
?.observe(viewLifecycleOwner, {
if (it) {
viewModel.fetchdata()
navController.currentBackStackEntry?.savedStateHandle?.remove<Boolean>("shouldUpdate")
}
})
This piece of code will update LiveData in my ViewModel, so the DataBinding will detect its change and re-submitList the data to the adapter
My List Adapter
class TemplateChiefAdapter(val onClickListener: OnClickListener) : ListAdapter<Template, TemplateChiefAdapter.TemplateChiefViewHolder>(DiffCallback) {
class TemplateChiefViewHolder(private var binding: ItemTemplateChiefBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(template: Template) {
binding.template = template
binding.executePendingBindings()
}
}
companion object DiffCallback : DiffUtil.ItemCallback<Template>() {
override fun areItemsTheSame(oldItem: Template, newItem: Template): Boolean {
return oldItem === newItem
}
override fun areContentsTheSame(oldItem: Template, newItem: Template): Boolean {
return oldItem.id_template == newItem.id_template
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TemplateChiefViewHolder {
return TemplateChiefViewHolder(ItemTemplateChiefBinding.inflate(LayoutInflater.from(parent.context)))
}
override fun onBindViewHolder(holder: TemplateChiefViewHolder, position: Int) {
val template = getItem(position)
holder.itemView.setOnClickListener {
onClickListener.onClick(template)
}
holder.bind(template)
}
class OnClickListener(val listener: (template: Template) -> Unit) {
fun onClick(template: Template) = listener(template)
}
}
How can I keep the recycler scroll position after submitList called?
I didn't examine in ultra detail all your code, but the DiffUtil Callback caught my attention.
areItemsTheSame is an optimization from the DiffUtil class to determine if the items changed position. If the didn't, then the contents can be checked, and re-bound to their new data if it changed. If the positions changed, then the item may need to be animated elsewhere or well.. as you can imagine it becomes more complicated from there.
The idea of that method is to compare if the items are the same or not, not to compare the entire item. I would use an id (or anything that can help you identify uniqueness in your items). You are using the === operator and I don't know the rest of your architecture, but comparing by reference may not be accurate if, for instance, your data layer transforms and copies these objects around (something you can't/shouldn't tell/care for in your adapter).
For instance, instead of
return oldItem === newItem
You could do
return oldItem.someId === newItem.someId
This would ensure that even if your items are the same but were copied/recreated/etc., you'd still identify them as such despite them being a different reference.
Then, in areContentsTheSame you are expected to check all the contents that you consider instrumental in deciding if onBind must be called on your specific viewHolder because the contents are different. So I would have expected something more like:
oldItem.something == newItem.something
&& oldItem.xxx == newItem.xxx
&& oldItem.yyy == newItem.yyy
(but maybe with DataBinding you don't need this, I wouldn't know).
All that being said, I have 0.1 experience with DataBinding (and personally for me that was enough), so if this is related in anyway how the data binding library behaves, I can't help you any more. :/
From a RecyclerView's point of view, the rest of the code looks adequate.

live data observer triggered when another variable

sorry for my English
I just started learning android and I'm trying to make a library app that uses Room db, recView and fragments
in the app there are 3 lists, all books, already read and Wishlist - the already read and Wishlist are just books that its property isAlreadyRead is true
in the viewModel i created 3 lists members, one for each list
--Book class--
#Entity(tableName = "books_table")
data class Book(
#PrimaryKey(autoGenerate = true)
val id: Int = 1,
val name: String,
val author: String,
val desc: String,
val imgUrl: String,
#ColumnInfo(defaultValue = "0")
val isAlreadyRead: Boolean = false,
#ColumnInfo(defaultValue = "0")
val isWishlist: Boolean = false
)
--repository--
val allBooks = bookDao.getAllBooks().asLiveData()
val alreadyReadBooks = bookDao.getAlreadyReadBooks().asLiveData()
val wishlistBooks = bookDao.getWishlistBooks().asLiveData()
--viewModel--
val allBooks = repository.allBooks
val alreadyReadBooks = repository.alreadyReadBooks
val wishlistBooks = repository.wishlistBooks
(the repository and viewModel is just the important section of the code, if you need the whole code i will update the question)
the problem mainly occurs when i delete an object, i added the ability to delete using swipe with ItemTouchHelper.SimpleCallback
private val itemTouchHelperCallback =
object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
if (viewHolder !is BookRecViewAdapter.BookViewHolder) {
return
}
bookViewModel.removeFromWishlistBooks(viewHolder.bookId)
adapter.notifyDataSetChanged()
}
}
when i delete in the already books, the bookViewModel.alreadyReadBooks observer is being called, but i noticed that also the bookViewModel.allBooks observer is being called, here is the code of the observers
--observer code inside onViewCreated of the fragments--
bookViewModel.allBooks.observe(requireActivity(), Observer { allBooks ->
Log.d("observer:", "show all books observer")
// Update the cached copy of the allBooks in the adapter.
allBooks?.let { adapter.books = it }
if (allBooks.isNotEmpty()) {
binding.listEmptyTextView.visibility = View.INVISIBLE
} else {
binding.listEmptyTextView.visibility = View.VISIBLE
}
})
(the code for the already read fragment and the wishist fragment is the same just that i called bookViewModel.alreadyReadBooks)
it crashes the app because in the observer i also want to update the visibility of a textView which just say that the list if empty (so there will be no white screen with no information), but binding is null because the fragment is in the background (i think that is the reason)
if you need any more info or code say, first time writing in stack overflow
thanks in advance
BTW: if you have any advice for me i will be happy to hear
My guess is that you are using requireActivity() instead of viewLifecycleOwner when you observe the bookViewModel. If I didn't guess right, you should show us the code that you have in your Fragment class.

Drag & Dropping the first item of the RecyclerView moves several random positions

Currently, I have a RecyclerView implementing the new ListAdapter, using submitList to differ elements and proceed to update the UI automatically.
Lately i had to implement drag & drop to the list using the well known ItemTouchHelper. Here is my implementation, pretty straight forward:
class DraggableItemTouchHelper(private val adapter: DestinationsAdapter) : ItemTouchHelper.Callback() {
private val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
private val swipeFlags = 0
override fun isLongPressDragEnabled() = false
override fun isItemViewSwipeEnabled() = false
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
return makeMovementFlags(dragFlags, swipeFlags)
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val oldPos = viewHolder.adapterPosition
val newPos = target.adapterPosition
adapter.swap(oldPos, newPos)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}
}
this is my swap function inside the adapter:
fun swap(from: Int, to: Int) {
submitList(ArrayList(currentList).also {
it[from] = currentList[to]
it[to] = currentList[from]
})
}
Everything works well EXCEPT when moving the FIRST item of the list. Sometimes it behaves OK, but most of the time (like 90%), it snaps several positions even when moving it slightly above the second item (to move 1st item on 2nd position for example). The new position seems random and i couldn't figure out the issue.
As a guide, i used the https://github.com/material-components/material-components-android example to implement Drag&Drop and for their (simple) list&layout works well. My list is a bit complex since it's inside a viewpager, using Navigation component and having many other views constrained together in that screen, but i don't think this should be related.
At this point i don't even know how to search on the web for this issue anymore.
The closest solution I found for this might be https://issuetracker.google.com/issues/37018279 but after implementing and having the same behaviour, I am thinking it's because I use ListAdapter which differs and updates the list asynchronously, when the solution uses RecyclerView.Adapter which uses notifyItemMoved and other similar methods.
Switching to RecyclerView.Adapter is not a solution.
This seems to be a bug in AsyncListDiffer, which is used under the hood by ListAdapter. My solution lets you manually diff changes when you need to. However, it's rather hacky, uses reflection, and may not work with future appcompat versions (The version I've tested it with is 1.3.0).
Since mDiffer is private in ListAdapter and you need to work directly with it, you'll have to create your own ListAdapter implementation(you can just copy the original source). And then add the following method:
fun setListWithoutDiffing(list: List<T>) {
setOf("mList", "mReadOnlyList").forEach { fieldName ->
val field = mDiffer::class.java.getDeclaredField(fieldName)
field.isAccessible = true
field.set(mDiffer, list)
}
}
This method silently changes the current list in the underlying AsyncListDiffer without triggering any diffing, as submitList() would.
The resulting file should look like this:
package com.example.yourapp
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
abstract class ListAdapter<T, VH : RecyclerView.ViewHolder?> : RecyclerView.Adapter<VH> {
private val mDiffer: AsyncListDiffer<T>
private val mListener =
ListListener<T> { previousList, currentList -> onCurrentListChanged(previousList, currentList) }
protected constructor(diffCallback: DiffUtil.ItemCallback<T>) {
mDiffer = AsyncListDiffer(
AdapterListUpdateCallback(this),
AsyncDifferConfig.Builder(diffCallback).build()
).apply {
addListListener(mListener)
}
}
protected constructor(config: AsyncDifferConfig<T>) {
mDiffer = AsyncListDiffer(AdapterListUpdateCallback(this), config).apply {
addListListener(mListener)
}
}
fun setListWithoutDiffing(list: List<T>) {
setOf("mList", "mReadOnlyList").forEach { fieldName ->
val field = mDiffer::class.java.getDeclaredField(fieldName)
field.isAccessible = true
field.set(mDiffer, list)
}
}
open fun submitList(list: List<T>?) {
mDiffer.submitList(list)
}
fun submitList(list: List<T>?, commitCallback: Runnable?) {
mDiffer.submitList(list, commitCallback)
}
protected fun getItem(position: Int): T {
return mDiffer.currentList[position]
}
override fun getItemCount(): Int {
return mDiffer.currentList.size
}
val currentList: List<T>
get() = mDiffer.currentList
open fun onCurrentListChanged(previousList: List<T>, currentList: List<T>) {}
}
Now you need to change your adapter implementation to inherit from your custom ListAdapter rather than androidx.recyclerview.widget.ListAdapter.
Finally you'll need to change your adapter's swap() method implementation to use the setListWithoutDiffing() and notifyItemMoved() methods:
fun swap(from: Int, to: Int) {
setListWithoutDiffing(ArrayList(currentList).also {
it[from] = currentList[to]
it[to] = currentList[from]
})
notifyItemMoved(from, to)
}
An alternative solution would be to create a custom AsyncListDiffer version that lets you do the same without reflection, but this way seems easier. I will also file a feature request for supporting manual diffing out of the box and update the question with a Google Issue Tracker link.
I kept a copy of the items in my adapter, modified the copy, and used notifyItemMoved to update the UI as the user was dragging. I only save the updated items/order AFTER the user finishes dragging. This works for me because 1) I had a fixed length list of 9 items; 2) I was able to use clearView to know when the drag ended.
ListAdapter - kotlin:
var myItems: MutableList<MyItem> = mutableListOf()
fun onMove(fromPosition: Int, toPosition: Int): Boolean {
if (fromPosition < toPosition) {
for (i in fromPosition until toPosition) {
Collections.swap(myItems, i, i + 1)
}
} else {
for (i in fromPosition downTo toPosition + 1) {
Collections.swap(myItems, i, i - 1)
}
}
notifyItemMoved(fromPosition, toPosition)
return true
}
ItemTouchHelper.Callback() - kotlin:
// my items are only ever selected during drag, so when selection clears, drag has ended
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
// clear drag style after item moved
viewHolder.itemView.requestLayout()
// trigger callback after item moved
val itemViewHolder = viewHolder as MyItemViewHolder
itemViewHolder.onItemMovedCallback(adapter.myItems)
}
MyItemViewHolder - kotlin
fun onItemMovedCallback(reorderedItems: List<MyItem>) {
// user has finished drag
// save new item order to database or submit list properly to adapter
}
I also had an itemOrder field on MyItem. I updated that field using the index of the re-ordered items when I saved it to the DB. I could probably update each items itemOrder field when I swap the items, but it seemed pointless (I just calculate the new order after the drag is finished).
I'm using LiveData from my database. I found the views "flickered" after the final database save because I changed the itemOrder on all the items and moved the items around in the adapter list. If this happens to you and you don't like it, just temporarily disable the recycler view item animator (I achieved this by setting it to null after the drag and restoring it after the list is updated in the RecyclerView/Adapter).
This worked for me and my specific case. Let me know if you need more details.

ListAdapter fetches the right list but does not update the values

I have the following fuction -
private fun fetchGroupData(callback: (groupModelList: List<GroupModel>) -> Unit) {
val groupModelList = mutableListOf<GroupModel>()
groupViewmodel.getAllGroupEntities().observeOnce(requireActivity(), Observer { groupEntityList ->
groupEntityList.forEach { groupEntity ->
/*
We iterate though all of the available groups,
for each group we get all of it's groupMembers models
*/
val groupName = groupEntity.groupName
val groupId = groupEntity.id
taskViewmodel.getGroupTaskCounter(groupId).observeOnce(requireActivity(), Observer { groupTaskCount ->
/*
For each group we observe it's task counter
*/
groupViewmodel.getGroupMembersForGroupId(groupId).observeOnce(requireActivity(), Observer { groupMembers ->
/*
For each group, we iterate through all of the groupMembers and for each of them we use it's userId
to fetch the user model, getting it's full name and adding it to a list of group users full name.
*/
val groupUsersFullNames = mutableListOf<String>()
groupMembers.forEach { groupMember ->
val memberId = groupMember.userId
groupViewmodel.getGroupParticipantForUserId(memberId).observeOnce(requireActivity(), Observer { groupUser ->
groupUsersFullNames.add(groupUser.fullName)
/*
When the groupUsersFullNames size matches the groupMembers size, we can add a model to our list.
*/
if (groupUsersFullNames.size == groupMembers.size)
groupModelList.add(GroupModel(groupId, groupName, groupTaskCount, groupUsersFullNames))
/*
When the new list matches the size of the group list in the DB we call the callback.
*/
if (groupModelList.size == groupEntityList.size)
callback(groupModelList)
})
}
})
})
}
})
}
That is being used by the following function -
private fun initAdapter() {
fetchGroupData { groupModelList ->
if (groupModelList.isEmpty()) {
binding.groupsListNoGroupsMessageTitle.setAsVisible()
binding.groupsListNoGroupsMessageDescription.setAsVisible()
return#fetchGroupData
}
binding.groupsListNoGroupsMessageTitle.setAsGone()
binding.groupsListNoGroupsMessageDescription.setAsGone()
val newList = mutableListOf<GroupModel>()
newList.addAll(groupModelList)
adapter.submitList(groupModelList)
Log.d("submitList", "submitList")
binding.groupsListRecyclerview.setAdapterWithItemDecoration(requireContext(), adapter)
}
}
These 2 functions represent group list fetch from my local DB into a RecyclerView.
In order to be notified when a new group has been created, I am holding a shared ViewModel object with a boolean indicating if a new group has been created.
In the same Fragment that these 2 functions ^ are written, I am observing this Boolean, and if the value is true I trigger a re-fetch for the entire list -
private fun observeSharedInformation() {
sharedInformationViewModel.value.groupCreatedFlag.observe(requireActivity(), Observer { hasGroupBeenCreated ->
if (!hasGroupBeenCreated) return#Observer
sharedInformationViewModel.value.groupCreatedFlag.value = false
Log.d("submitList", "groupCreatedFlag")
initAdapter()
})
}
At some point in my code in a different Fragment that also has an instance of my shared ViewModel, I trigger a value change for my Boolean LiveData -
sharedInformationViewModel.value.groupCreatedFlag.value = true
Which in turn triggers the observer, and does a re-fetch for my group list.
The issue I am facing is that when re-fetching for a new list (because a new group has been added) I do get the current information and everything should work 100% fine, but the new data - the newly created group - does not appear.
The newly added data appears in the list under 2 circumstances -
I restart the app
The function is triggered again - what happens now is that I see the list with the previous newly added group, but the newest group to be added does not appear.
There is one exception to this issue - if the group list is empty, the first group to be added does indeed appear when I submit the list with one group.
What is it that I am missing?
Edit -
Here is my adapter.
I am using a custom call called DefaultAdapterDiffUtilCallback, which expects a model that implements an interface that defines the unique ID for each model so I can compare new and old models.
class GroupsListAdapter(
private val context: Context,
private val onClick: (model : GroupModel) -> Unit
) : ListAdapter<GroupModel, GroupsListViewHolder>(DefaultAdapterDiffUtilCallback<GroupModel>()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupsListViewHolder {
val binding = GroupsListViewHolderBinding.inflate(LayoutInflater.from(context), parent, false)
return GroupsListViewHolder(binding)
}
override fun onBindViewHolder(holder: GroupsListViewHolder, position: Int) {
holder.bind(getItem(position), onClick)
}
override fun submitList(list: List<GroupModel>?) {
super.submitList(list?.let { ArrayList(it) })
}
}
/**
* Default DiffUtil callback for lists adapters.
* The adapter utilizes the fact that all models in the app implement the "ModelWithId" interfaces, so
* it uses it in order to compare the unique ID of each model for `areItemsTheSame` function.
* As for areContentsTheSame we utilize the fact that Kotlin Data Class implements for us the equals between
* all fields, so use the equals() method to compare one object to another.
*/
class DefaultAdapterDiffUtilCallback<T : ModelWithId> : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T) =
oldItem.fetchId() == newItem.fetchId()
#SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: T, newItem: T) =
oldItem == newItem
}
/**
* An interface to determine for each model in the app what is the unique ID for it.
* This is used for comparing the unique ID for each model for abstracting the DiffUtil Callback
* and creating a default general one rather than a new class for each new adapter.
*/
interface ModelWithId {
fun fetchId(): String
}
data class GroupModel(val id: String, val groupName: String, var tasksCounter: Int, val usersFullNames: List<String>) : ModelWithId {
override fun fetchId(): String = id
}
edit 2.0 -
my observeOnce() extension -
fun <T> LiveData<T>.observeOnce(lifecycleOwner: LifecycleOwner, observer: Observer<T>) {
observe(lifecycleOwner, object : Observer<T> {
override fun onChanged(t: T?) {
observer.onChanged(t)
removeObserver(this)
}
})
}
Are you using the "new" ListAdapter?
import androidx.recyclerview.widget.ListAdapter
In this case, I can think of an answer to your problem. But since I do not know more about your exact implementation it is based on my assumptions and you have to verify if it applies or not.
For this ListAdapter you have to implement areItemsTheSame and areContentsTheSame methods.
I've once had a similar problem. I was submitting the list but it just didn't update the list in the view.
I could resolve this issue by checking carefully how I was comparing if the contents are the same or not.
For your comparison function, consider the following:
override fun areContentsTheSame(oldItem: GroupModel, newItem: GroupModel): Boolean {
// assuming GroupModel is a class
// this comparison is most likely not getting the result you want
val groupModelsAreMatching = oldItem == newItem // don't do this
// for data classes it usually gives the expected result
val exampleDataClassesMatch = oldItem.dataClass == newItem.dataClass
// But: the properties that need to be compared need to be declared in the primary constructor
// and not in the function body
// compare all relevant custom properties
val groupIdMatches = oldItem.groupId == newItem.groupId
val groupNameMatches = oldItem.groupName == newItem.groupName
val groupTaskCountMatches = oldItem.groupTaskCount == newItem.groupTaskCount
val groupUsersFullNamesMatches = oldItem.groupUsersFullNames == newItem.groupUsersFullNames
return groupIdMatches && groupNameMatches && groupTaskCountMatches && groupUsersFullNamesMatches
}
And of course you need to make sure that areItemsTheSame. Here you only need to compare the groupIds.
Did you do it like this already?
I figured out the problem.
And it has nothing to do with my fetch logics.
The issue is the following -
When creating a group, I am adding a new Fragment to the backstack and popping it off when completed.
When deleting a group, I am navigating forward to the main Fragment of mine while using popUpTo and popUpToInclusive - that works fine.
I needed to use the navigation rather than popping backwards the stack in order to see the new list.
This took me 3 days of work to figure out. jeez

how can drag item in RecyclerView work with paging library together?

My app has a RecyclerView which support drag items to change their order.
My app use ViewModel, Lifecycle, Room before adding paging library. And code to handle drag is easy.
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
val oPosition = viewHolder.adapterPosition
val tPosition = target.adapterPosition
Collections.swap(adapter?.data ,oPosition,tPosition)
adapter?.notifyItemMoved(oPosition,tPosition)
//save to db
return true
}
However, after I use paging library,
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
val oPosition = viewHolder.adapterPosition
val tPosition = target.adapterPosition
Collections.swap(adapter.currentList,oPosition,tPosition)
adapter.notifyItemMoved(oPosition,tPosition)
return true
}
my app crashed because PagedListAdapter.currentList do not support set.
java.lang.UnsupportedOperationException
at java.util.AbstractList.set(AbstractList.java:132)
at java.util.Collections.swap(Collections.java:539)
at gmail.zebulon988.tasklist.ui.TaskListFragment$MyItemTouchCallback.onMove(TaskListFragment.kt:119).
Then I change the code
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
val oPosition = viewHolder.adapterPosition
val tPosition = target.adapterPosition
Log.d("TAG","onMove:o=$oPosition,t=$tPosition")
val oTask = (viewHolder as VH).task
val tTask = (target as VH).task
if(oTask != null && tTask != null){
val tmp = oTask.order
oTask.order = tTask.order
tTask.order = tmp
tasklistViewModel.insertTask(oTask,tTask)
}
return true
}
This code change the task's order in db directly and the library update the display order by the db change. However, the animation is ugly.
Is there a way to use onMove and paging library together genteelly?
When you use a PagedList with Room you often tie it up so that the updates to the underlying data are reflected automatically via LiveData or Rx, and such an update happening in a background can always mess up your drag and drop. So IMHO you can't make it 100% bulletproof for all situations. Having said that, you can create (I almost said "hack together") a shim that will do what you want. This involves several pieces:
You need to hold the indexes of the items being swapped in your adapter
You need to override getItem() in the adapter and make it "swap" the items for you instead of swapping them using Collections.swap
You need to delay the actual item updating via Room until the item is dropped, at which point you also clear your "swapping in progress" state. Something along these lines:
fun swapItems(fromPosition: Int, toPosition: Int) {
swapInfo = SwapInfo(fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
}
override fun getItem(position: Int): T? {
return swapInfo?.let {
when (position) {
it.fromPosition -> super.getItem(it.toPosition)
it.toPosition -> super.getItem(it.fromPosition)
else -> super.getItem(position)
}
} ?: super.getItem(position)
}
fun clearSwapInfo() {
swapInfo = null
}
This way you will get a smooth dragging experience as long as there are no background updates for your list and you stay within already loaded list of items. It gets much more complicated if you need to be able to drag through a "refill".
You need to heck for moving items in PagedList.
Recyclerview's adapter needs to do two things perfectly if you want to drag items up and down for moving them. First is to swap two items in datalist, second is to notify cells re-render.
re-render is easy, you can use notifyItemMoved to update layout when moving, but PagedList is immutable, you cannot modify it.
And there is an animation bug when the cell ui has already changed but the datasource did not. You cannot override the render logic in inner of recyclerview, but you can heck the result of PagedStorageDiffHelper.computeDiff to fix the animation bug.
At last, dont forget to retrieve the most updated data after the drag and drop.
//ItemTouchHelperAdapter
override fun onItemStartMove() {
//the most the most updated data; mimic pagedlist, but can be modified;
tempList = adapter.currentList?.toMutableList()
toUpdate = mutableListOf()
}
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
val itemFrom = tempList?.get(fromPosition) ?: return false
val itemTo = tempList?.get(toPosition) ?: return false
//change order property for data itself
val order = itemTo.order
itemTo.order = itemFrom.order
itemFrom.order = order
//save them for later update db in batch
toUpdate?.removeAll { it.id == itemFrom.id || it.id == itemTo.id }
toUpdate?.add(itemFrom)
toUpdate?.add(itemTo)
//mimic mutable pagedlist, for get next time get correct items for continuing drag
Collections.swap(tempList!!, fromPosition, toPosition)
//update ui
adapter.notifyItemMoved(fromPosition, toPosition)
return true
}
override fun onItemEndMove() {
tempList = null
if (!toUpdate.isNullOrEmpty()) {
mViewModel.viewModelScope.launch(Dispatchers.IO) {
//heck, fix animation bug because pagedList did not really change.
NoteListAdapter.disableAnimation = true
mViewModel.updateInDB(toUpdate!!)
toUpdate = null
}
}
}
//Fragment
mViewModel.data.observe(this.viewLifecycleOwner, Observer {
adapter.submitList(it)
//delay for fix PagedStorageDiffHelper.computeDiff running in background thread
if (NoteListAdapter.disableAnimation) {
mViewModel.viewModelScope.launch {
delay(500)
adapter.notifyDataSetChanged() //update viewholder's binding data
NoteListAdapter.disableAnimation = false
}
}
})
//PagedListAdapter
companion object {
//heck for drag and drop to move items in PagedList
var disableAnimation = false
private val DiffCallback = object : DiffUtil.ItemCallback<Note>() {
override fun areItemsTheSame(old: Note, aNew: Note): Boolean {
return disableAnimation || old.id == aNew.id
}
override fun areContentsTheSame(old: Note, aNew: Note): Boolean {
return disableAnimation || old == aNew
}
}
}
I had a slightly different problem and #dmapr's answer has finally led me to the solution after hours of debugging.
For me the issue was that the item I just moved suddenly jumped back to its previous position after the db was updated and the call to submitData was made. Basically the drag and drop action is sort of canceled, however the order with all the relevant data is correct in the database, and if I was to call notifyDataSetChanged() I'd see the real list where all items are where they should be. Here's what has worked for me:
class SomePagingAdapter(
private val onItemMoveUpdate: (fromPos: Int, toPos: Int) -> Unit,
) : PagingDataAdapter<Model, SomePagingAdapter.ViewHolder>(diffUtil), ItemMoveCallback {
companion object {
private val diffUtil = /* ... */
}
private var swapInfo: SwapInfo? = null
// viewHolder methods, etc.
// Called in touch helper's onMove
override fun onItemMove(fromPos: Int, toPos: Int) {
notifyItemMoved(fromPos, toPos)
}
// Called in touch helper's clearView() to save the result of this drag and drop
override fun onItemFinishedMove(fromPos: Int, toPos: Int) {
swapInfo = SwapInfo(fromPos, toPos)
onItemMoveUpdate(fromPos, toPos)
}
fun adjustRecentSwapPositions() {
// "Undo" the notifyItemMoved we did before that messed up positions
swapInfo?.let { swap ->
notifyItemMoved(swap.toPos, swap.fromPos)
}
swapInfo = null
}
}
interface ItemMoveCallback {
fun onItemMove(fromPos: Int, toPos: Int)
fun onItemFinishedMove(fromPos: Int, toPos: Int)
}
data class SwapInfo(val fromPos: Int, val toPos: int)
It's important that submitData is suspended and adjustRecentSwapPositions is called immediately after. Watch out for that if you use RxJava.
scope.launch {
flow.collectLatest { pagingData ->
adapter.submitData(pagingData)
adapter.adjustRecentSwapPositions()
}
}
It works great and recycler's animations are fine.

Categories

Resources