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.
Related
I've created an adapter (extending ListAdapter with DiffUtil.ItemCallback) for my RecyclerView. It's an ordinary adapter with several itemViewTypes, but it should be smth like cyclic, if API sends flag and dataset size is > 1 (made by overriding getItemCount() to return 1000 when conditions == true).
When I change app locale through app settings, my fragment recreates, data loads asynchronously (reactively, several times in a row, from different requests, depending on several rx fields, which causes data set to be a combination of data on different languages just after locale is changed (in the end all dataset is correctly translated btw) (make it more like synchronous is not possible because of feature specifics)), posting its values to LiveData, which triggers updates of recycler view, the problem appears:
After last data set update some of the views (nearest to currently displayed and currently displayed) appear not to be translated.
Final data set, which is posted to LiveData is translated correctly, it even has correct locale tag in its id. Also after views are recycled and we return back to them - they are also correct.
DiffUtil is computed correctly also (I've tried to return only false in item callbacks and recycler view still didn't update its view holders correctly).
When itemCount == list.size everything works fine.
When adapter is pretending to be cyclic and itemCount == 1000 - no.
Can somebody explain this behaviour and help to figure out how to solve this?
Adapter Code Sample:
private const val TYPE_0 = 0
private const val TYPE_1 = 1
class CyclicAdapter(
val onClickedCallback: (id: String) -> Unit,
val onCloseClickedCallback: (id: String) -> Unit,
) : ListAdapter<IViewData, RecyclerView.ViewHolder>(DataDiffCallback()) {
var isCyclic: Boolean = false
set(value) {
if (field != value) {
field = value
}
}
override fun getItemCount(): Int {
return if (isCyclic) {
AdapterUtils.MAX_ITEMS // 1000
} else {
currentList.size
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
TYPE_0 -> Type0.from(parent)
TYPE_1 -> Type1.from(parent)
else -> throw ClassCastException("View Holder for ${viewType} is not specified")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is Type0 -> {
val item = getItem(
AdapterUtils.actualPosition(
position,
currentList.size
)
) as ViewData.Type0
holder.setData(item, onClickedCallback)
}
is Type1 -> {
val item = getItem(
AdapterUtils.actualPosition(
position,
currentList.size
)
) as ViewData.Type1
holder.setData(item, onClickedCallback, onCloseClickedCallback)
}
}
}
override fun getItemViewType(position: Int): Int {
return when (val item = getItem(AdapterUtils.actualPosition(position, currentList.size))) {
is ViewData.Type0 -> TYPE_0
is ViewData.Type1 -> TYPE_1
else -> throw ClassCastException("View Type for ${item.javaClass} is not specified")
}
}
class Type0 private constructor(itemView: View) :
RecyclerView.ViewHolder(itemView) {
fun setData(
viewData: ViewData.Type0,
onClickedCallback: (id: String) -> Unit
) {
(itemView as Type0View).apply {
acceptData(viewData)
setOnClickedCallback { url ->
onClickedCallback(viewData.id,)
}
}
}
companion object {
fun from(parent: ViewGroup): Type0 {
val view = Type0View(parent.context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
return Type0(view)
}
}
}
class Type1 private constructor(itemView: View) :
RecyclerView.ViewHolder(itemView) {
fun setData(
viewData: ViewData.Type1,
onClickedCallback: (id: String) -> Unit,
onCloseClickedCallback: (id: String) -> Unit
) {
(itemView as Type1View).apply {
acceptData(viewData)
setOnClickedCallback { url ->
onClickedCallback(viewData.id)
}
setOnCloseClickedCallback(onCloseClickedCallback)
}
}
companion object {
fun from(parent: ViewGroup): Type1 {
val view = Type1View(parent.context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
return Type1(view)
}
}
}
}
ViewPager Code Sample:
class CyclicViewPager #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
ICyclicViewPager {
private val cyclicViewPager: ViewPager2
private lateinit var onClickedCallback: (id: String) -> Unit
private lateinit var onCloseClickedCallback: (id: String) -> Unit
private lateinit var adapter: CyclicAdapter
init {
LayoutInflater
.from(context)
.inflate(R.layout.v_cyclic_view_pager, this, true)
cyclicViewPager = findViewById(R.id.cyclic_view_pager)
(cyclicViewPager.getChildAt(0) as RecyclerView).apply {
addItemDecoration(SpacingDecorator().apply {
dpBetweenItems = 12
})
clipToPadding = false
clipChildren = false
overScrollMode = RecyclerView.OVER_SCROLL_NEVER
}
cyclicViewPager.offscreenPageLimit = 3
}
override fun initialize(
onClickedCallback: (id: String) -> Unit,
onCloseClickedCallback: (id: String) -> Unit
) {
this.onClickedCallback = onClickedCallback
this.onCloseClickedCallback = onCloseClickedCallback
adapter = CyclicAdapter(
onClickedCallback,
onCloseClickedCallback,
).apply {
stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
cyclicViewPager.adapter = adapter
}
override fun setState(viewPagerState: CyclicViewPagerState) {
when (viewPagerState.cyclicityState) {
is CyclicViewPagerState.CyclicityState.Enabled -> {
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
cyclicViewPager.post {
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
}
}
}
is CyclicViewPagerState.CyclicityState.Disabled -> {
if (viewPagerState.pages.size == 1 && adapter.isCyclic) {
cyclicViewPager.setCurrentItem(0, false)
adapter.isCyclic = false
}
adapter.submitList(viewPagerState.pages)
}
}
}
}
Adapter Utils Code:
object AdapterUtils {
const val MAX_ITEMS = 1000
fun actualPosition(position: Int, listSize: Int): Int {
return if (listSize == 0) {
0
} else {
(position + listSize) % listSize
}
}
fun getCyclicInitialPosition(listSize: Int): Int {
return if (listSize > 0) {
MAX_ITEMS / 2 - ((MAX_ITEMS / 2) % listSize)
} else {
0
}
}
}
Have tried not to use default itemView variable of RecyclerView (became even worse).
Tried to make diff utils always return false, to check if it calculates diff correctly (yes, correctly)
Tried to add locale tags to ids of data set items (didn't help to solve)
Tried to post empty dataset on locale change before setting new data to it (shame on me, shouldn't even think about it)
Tried do add debounce to rx to make it wait a bit before update (didn't help)
UPD: When I call adapter.notifyDatasetChanged() manually, which is not the preferred way, everything works fine, so the question is why ListAdapter doesn't dispatch notify callbacks properly in my case?
The issue with ListAdapter is that it doesn't clearly state that you need to supply a new list for it to function.
In other words, the documentation says: (and I quote the source code):
/**
* Submits a new list to be diffed, and displayed.
* <p>
* If a list is already being displayed, a diff will be computed on a background thread, which
* will dispatch Adapter.notifyItem events on the main thread.
*
* #param list The new list to be displayed.
*/
public void submitList(#Nullable List<T> list) {
mDiffer.submitList(list);
}
The key word being new list.
However, as you can see there, all the adapter does is defer to the DiffUtil and calls submitList there.
So when you look at the actual source code of the AsyncListDiffer you will notice it does, at the beginning of its code block:
if (newList == mList) {
// nothing to do (Note - still had to inc generation, since may have ongoing work)
if (commitCallback != null) {
commitCallback.run();
}
return;
}
In other words, if the new list (reference) is the same as the old one, regardless of their contents, don't do anything.
This may sound cool but it means that if you have this code, the adapter will not really update:
(pseudo...)
var list1 = mutableListOf(...)
adapter.submitList(list1)
list1.add(...)
adapter.submitList(list1)
The reason is list1 is the same reference your adapter has, so the differ exits prematurely, and doesn't dispatch any changes to the adapter.
Quite obscure, I know.
The solution, as pointed in many SO answers is to create a copy of the list itself.
Most users do
var list1 = mutableListOf(...)
adapter.submitList(list1)
var list2 = list1.toMutableList()
list2.add(...)
adapter.submitList(list2)
The call to toMutableList() creates a new list containing the items of list1 and so the comparison above if (newList == mList) { should now be false and the normal code should execute.
UPDATE
Keep in mind that a lot of developers make the mistake of...
var list = mutableListOf...
adapter.submitList(list)
list.add(xxx)
adapter.submitList(list.toList())
This doesn't work, because the new list you create, is referencing the same objects the adapter has. This means that both lists list and list.toList() are pointing to the same things despite being two instances of an ArrayList.
But the side-effect is that DiffUtil compares the items and they are the same, so no diff is dispatched to the adapter either.
The correct sequence is...
val list = mutableListOf(...)
adapter.submitList(list.toList())
// Make a copy first, so we can alter it as we please without the *current list held by the adapter* from being affected.
var modified = list.toMutableList()
modified.add(...)
adapter.submitList(modified)
After taking a look at your sample in GitHub, I was able to reproduce the issue. With only about 30-40 minutes of playing with it, I can say that I'm not 100% sure what component is not updating.
Things I've noticed.
The onBindViewHolder method is not called when you change the locale (except maybe the 1st time?).
I do not understand why the need to post to the adapter after you've submitted the list in the callback:
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
Why ? This means the user loses their current position.
Why not keep the existing?
I noticed you do cyclicViewPager.offscreenPageLimit = 3 this effectively disables the RecyclerView "logic" for handling changes, and uses instead the usual ViewPager state adapter logic of "prefetching/keeping" N (3 in your case) pages in "advance".
At first I thought this was causing issues, but removing it (which sets it to -1 which is the default and the "use RecyclerView" value, didn't make a big change (though I did notice some changes here and there, as in it would sometimes update the current one -but not the next ones within 2~3 pages).
The documentation says:
Set the number of pages that should be retained to either side of the currently visible page(s). Pages beyond this limit will be recreated from the adapter when needed. Set this to OFFSCREEN_PAGE_LIMIT_DEFAULT to use RecyclerView's caching strategy.
So I would have imagined that the default value would be aided by the ListAdapter and its DiffUtil. Doesn't seem to be the case.
What I did try (among a few other things) was to see if the issue was in the actual adapter (or at least the viewPager dependency on its adapter). I ran out of time (work!) but I noticed that if you do:
override fun setState(viewPagerState: CyclicViewPagerState) {
when (viewPagerState.cyclicityState) {
is CyclicViewPagerState.CyclicityState.Enabled -> {
// call initialize again, to recreate the adapter
initialize(this.onClickedCallback, this.onCloseClickedCallback)
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
// Setting vp item to ... (code omitted for brevity)
}
This works. It's theoretically less efficient as you're recreating the whole adapter, but in your example you're effectively creating an ENTIRE new set of data changing every ID, so in terms of performance, I'd argue this is more efficient as there's no need to recalculate changes and dispatch them, since to the eyes of the Diff Util, all the rows are different. By recreating the adapter, well... the VP has to reinit anyway.
I noticed this worked fine in your example.
I went ahead and added two more things, because the "silly" adapter cannot reliably tell you which position is the current... you can naively save it:
In CyclicViewPager:
var currentPos: Int = 0
init {
...
this.cyclicViewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int)
currentPos = position
}
})
}
And then
is CyclicViewPagerState.CyclicityState.Enabled -> {
initialize(this.onClickedCallback, this.onCloseClickedCallback)
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
if (adapter.currentList.size <= currentPos) {
cyclicViewPager.setCurrentItem(currentPos, false)
} else {
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
}
}
}
This does work, but of course, you're recreating the entire VP adapter again, so it may not be desired.
At this point, I'd either need to spend much more time trying to figure out which part of VP, RV, or its dependencies is not "dispatching" the correct data. My guess would be somewhere around some silly ViewPager optimization combined with Android terribly unreliable View system, not picking a message in the queue; but I may be also terribly wrong ;)
I hope someone smarter and/or with more coffee in their system can find out a simpler solution.
(all in all, I found the sample project relatively easy to navigate, but the design of your data a bit convoluted, but... as it was a sample, it's hard to tell what "real-life" data structures you really have).
I just saw this example class for an Adapter for Recyclerview and I'm a bit confused on how it knows to call onCreateViewHolder, onBindViewHolder, etc just from adding an Item object to a list?
Does it have something to do with the line notifyItemInserted(items.size - 1) ?
Is it that whenever this method is called the onCreateViewHolder method is recalled with for that item or?
Adapter:
class ListAdapter (
private val items: MutableList<Item>
) : RecyclerView.Adapter <ListAdapter.ListViewHolder>() {
class ListViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
return ListViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.list_items, parent, false)
)
}
override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
val currItem = items[position]
holder.itemView.apply {
tv_item.text = currItem.title
cb_item.isChecked = currItem.checked
crossItem(tv_item, currItem.checked)
cb_item.setOnCheckedChangeListener { _, isChecked ->
crossItem(tv_item, isChecked)
currItem.checked = !currItem.checked
items.removeAll { item ->
item.checked
}
notifyDataSetChanged()
}
}
}
override fun getItemCount(): Int {
return items.size
}
private fun crossItem (itemText: TextView, checked: Boolean) {
if (checked){
//dk wtf paint flags is
itemText.paintFlags = itemText.paintFlags or STRIKE_THRU_TEXT_FLAG
}
//else remove
else {
itemText.paintFlags = itemText.paintFlags and STRIKE_THRU_TEXT_FLAG.inv()
}
}
fun addItem (item: Item){
items.add (item)
notifyItemInserted(items.size - 1)
}
}
Item Class:
data class Item (
val title: String,
var checked: Boolean = false
)
{
}
Whenever the Adapter needs to provide a new view for the RecyclerView to draw, it checks if it has an unused ViewHolder in its pool. If it doesn't, it calls onCreateViewHolder() so it can create one. Then it calls onBindViewHolder() for the ViewHolder that came from either source so the contained view can be prepared before being added to the layout.
If you call one of the notify methods, that triggers it to refresh whichever item rows are affected. It will return any removed rows to the ViewHolder pool and then follow the above steps to get the views it needs for new rows. If you use a notify...changed method, it will only need to use onBindViewHolder() for the applicable rows. When you use the nuclear option notifyDataSetChanged(), it returns all items to the pool.
When the RecyclerView is first displayed, or when the layout is resized, those actions will possibly trigger the need to show more rows. When you scroll the list, items that scroll off the screen are returned to the ViewHolder pool, and when new items scroll into view, ViewHolders need to be created or acquired from the pool as explained above.
By the way, this is going to look ugly because it refreshes the whole list even though only some items are removed:
items.removeAll { item ->
item.checked
}
notifyDataSetChanged()
I recommend this instead so you get a nice transition:
for (i in items.indices.reversed()) {
if (items[i].checked) {
items.removeAt(i)
notifyItemRemoved(i)
}
}
I iterate in reverse so the indices that are removed are stable as you iterate and remove items.
override fun getItemCount(): Int {
return items.size
}
This function is the key, it knows how many to create and how many to bind by knowing how many there are in total. The amount of ViewHolders created is more based on how many Views can fit on the screen at one time.
This gets more complex when you have different view types, as it will sometimes has to create more ViewHolders than what was required from the start as view types change.
The notify... functions just let the Adapter know it needs to "re-look" at the List.
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.
I have a list that gets updated from a Room Database. I receive the updated data from Room as a new list and I then pass it to ListAdapter's submitList to get animations for the changes.
list.observe(viewLifecycleOwner, { updatedList ->
listAdapter.submitList(updatedList)
})
Now, I want to add a drag and drop functionality for the same RecyclerView. I tried to implement it using ItemTouchHelper. However, the notifyItemMoved() is not working as ListAdapter updates its content through the submitList().
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
val list = itemListAdapter.currentList.toMutableList()
Collections.swap(list, from, to)
// does not work for ListAdapter
// itemListAdapter.notifyItemMoved(from, to)
itemListAdapter.submitList(list)
return false
}
The drag and drop now works fine but only when dragged slowly, when the dragging gets fast enough, I get different and inconsistent results.
What could be the reason for this? What is the best way that I can achieve a drag and drop functionality for my RecyclerView which uses ListAdapter?
So I made a quick test (this whole thing doesn't fit in a comment so I'm writing an answer)
My Activity contains the adapter, RV, and observes a viewModel. When the ViewModel pushes the initial list from the repo via LiveData, I save a local copy of the list in mutable form (just for the purpose of this test) so I can quickly mutate it on the fly.
This is my "onMove" implementation:
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
list[from] = list[to].also { list[to] = list[from] }
adapter.submitList(list)
return true
I also added this log to verify something:
Log.d("###", "onMove from: $from (${list[from].id}) to: $to (${list[to].id})")
And I noticed it.. works. But because I'm returning true (you seem to be returning false).
Now... unfortunately, if you drag fast up and down, this causes the list to eventually become shuffled:
E.g.: Let's suppose there are 10 items from 0-9.
You want to grab item 0 and put it between item 1 and 2.
You start Dragging item 0 at position 0, and move it a bit down so now it's between 1 and 2, the new item position in the onMove method is 1 (so far, you're still dragging). Now if you slowly drag further (to position 2), the onMove method is from 1 to 2, NOT from 0 to 2. This is because I returned "true" so every onMove is a "finished operation". This is fine, since the operations are slow and the ListAdapter has time to update and calculate stuff.
But when you drag fast, the operations go out of sync before the adapter has time to catch up.
If you return false instead (like you do) then you get various other effects:
The RecyclerView Animations don't play (while you drag) since the viewHolders haven't been "moved" yet. (you returned false)
The onMove method is then spammed every time you move your finger over a viewHolder, since the framework wants to perform this move again... but the list is already modified...
So you'd get something like (similar example above, 10 items, moving the item 0)> let's say each item has an ID that corresponds to its position+1 (in the initial state, so item at position 0 has id 1, item at position 1 has id 2, etc.)
This is what the log shows while I slowly drag item 0 "down":
(format is `from: position(id of item from) to: position(id of item to)
onMove from: 0 (1) to: 1 (2) // Initial drag of first item down to 2nd item.
onMove from: 0 (2) to: 1 (1) // now the list is inverted, notice the IDs.
onMove from: 0 (1) to: 1 (2) // Back to square one.
onMove from: 0 (2) to: 1 (1) // and undo-again...
I just cut it there, but you can see how it's bouncing all over the place back and forth. I believe this is because you return false but modify the list behind the scenes, so it's getting confused. on one side of the equation the "data" says one thing, (and so does the diff util), but on the other, the adapter is oblivious to this change, at least "yet" until the computations are done, which, as you guessed, when you drag super fast, is not enough time.
Unfortunately, I don't have a good answer (today) as to what would the best approach be. Perhaps, not relying on the ListAdapter's behavior and implementing a normal adapter, where you have better list/source control of the data and when to call submitList and when to simply notifyItemChanged or moved between two positions may be a better alternative for this use-case.
Apologies for the useless answer.
I ended up implementing a new adapter and use it instead of ListAdapter, as mentioned on Martin Marconcini's answer. I added two separate functions. One for receiving updates from Room database (replacement for submitList from ListAdapter) and another for every position change from drag
MyListAdapter.kt
class MyListAdapter(list: ArrayList<Item>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// save instance instead of creating a new one every submit
// list to save some allocation time. Thanks to Martin Marconcini
private val diffCallback = DiffCallback(list, ArrayList())
fun submitList(updatedList: List<Item>) {
diffCallback.newList = updatedList
val diffResult = DiffUtil.calculateDiff(diffCallback)
list.clear()
list.addAll(updatedList)
diffResult.dispatchUpdatesTo(this)
}
fun itemMoved(from: Int, to: Int) {
Collections.swap(list, from, to)
notifyItemMoved(from, to)
}
}
DiffCallback.kt
class DiffCallback(
val oldList: List<Item>,
var newList: List<Item>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList[oldItemPosition]
val newItem = newList[newItemPosition]
return compareContents(oldItem, newItem)
}
}
Call itemMoved every position change:
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
itemListAdapter.itemMoved(from, to)
// Update database as well if needed
return true
}
When receiving updates from Room database:
You may also want to check if currently dragging using onSelectedChanged if you are also updating your database every position change to prevent unnecessary calls to submitList
list.observe(viewLifecycleOwner, { updatedList ->
listAdapter.submitList(updatedList)
})
I've tried danartillaga's answer and got a ConcurrentModificationException for the list variable. I use LiveData in the code and it looks like the data was changed during invalidation of the list.
I've tried to keep the ListAdapter implementation and concluded to the following solution:
class MyListAdapter : ListAdapter<Item, RecyclerView.ViewHolder>(MyDiffUtil) {
var modifiableList = mutableListOf<Item>()
private set
fun moveItem(from: Int, to: Int) {
Collections.swap(modifiableList, to, from)
notifyItemMoved(from, to)
}
override fun submitList(list: List<CourseData>?) {
modifiableList = list.orEmpty().toMutableList()
super.submitList(modifiableList)
}
}
and the onMove code from ItemTouchHelper.SimpleCallback:
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
val adapter = recyclerView.adapter as CoursesDownloadedAdapter
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
val list = adapter.modifiableList
// Change your DB here
adapter.moveItem(from, to)
return true
}
The magic here is saving the modifiableList inside the adapter. ListAdapter stores a link to the list from submitList call, so we can change it externally. During the Drag&Drop the list is changed with Collections.swap and RecyclerView is updated with notifyItemMoved with no DiffCallback calls. But the data inside ListAdapter was changed and the next submitList call will use the updated list to calculate the difference.
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.