I have a recyclerview that load a list of cards each one with a favorite button. When user touch that button I change the order of my list items setting to first position the new favorite card. That change displays the default animation when NotifyItemMoved is called but I want to display in front the item that is moving up. The default animation displays the item that is moving to botton in front of the rest of the items.
Searching a bit I found that I can implement a custom ItemAnimator and use something like this:
override fun animateMove(
holder: RecyclerView.ViewHolder?,
fromX: Int,
fromY: Int,
toX: Int,
toY: Int
): Boolean {
if ( fromY > toY) {
holder?.itemView?.bringToFront()
}
return super.animateMove(holder, fromX, fromY, toX, toY)
}
It looks that works well but when I scroll the list it crashes with this error: java.lang.RuntimeException: trying to unhide a view that was not hiddenandroidx.constraintlayout.widget.ConstraintLayout
If I remove holder?.itemView?.bringToFront() the default animation runs well again without any crash
The reason why you are getting the "trying to unhide a view that was not hidden" error is because the bringToFront() method of View can interfere with the RecyclerView's internal view recycling mechanism. When a view is recycled, it is hidden and re-used for another item in the list. By calling bringToFront() on a view, you are potentially interfering with this process and causing the view to be incorrectly re-used for a different item.
One way to avoid this issue is to create a custom RecyclerView.ItemAnimator that overrides the animateChange() method to bring the moved view to the front, but does not interfere with the view recycling process. Here's an example implementation:
class CustomItemAnimator : DefaultItemAnimator() {
override fun animateChange(
oldHolder: RecyclerView.ViewHolder?,
newHolder: RecyclerView.ViewHolder?,
preInfo: ItemHolderInfo?,
postInfo: ItemHolderInfo?
): Boolean {
if (oldHolder != null && newHolder != null) {
// Check if the item is being moved up (i.e. to a lower index)
if (oldHolder.adapterPosition > newHolder.adapterPosition) {
// Bring the new holder's item view to the front
newHolder.itemView.bringToFront()
}
}
return super.animateChange(oldHolder, newHolder, preInfo, postInfo)
}
}
This implementation only brings the new holder's item view to the front when the item is being moved up (i.e., to a lower index). This should allow the default RecyclerView view recycling mechanism to work correctly.
To use this custom ItemAnimator, set it on your RecyclerView using the setItemAnimator() method:
recyclerView.setItemAnimator(CustomItemAnimator())
Note: This implementation assumes that each item in the RecyclerView is represented by a single view in the ViewHolder. If you have complex item layouts with multiple views, you may need to modify this implementation to ensure that only the item view is brought to the front.
Related
Im trying to implement a drag and drop in a ConcatAdapter. I did a ConcatAdapter bc I have multiples sections where the items are different, so I just need to drag and drop inside one section.
I did a ConcatAdapter with 2 adapters
I did the ItemTouchHelper for the recycler view -> itemTouchHelper.attachToRecyclerView(binding.recyclerView)
I guess that it's something related with the ItemTouchHelper bc its set on the recycler view and not on the adapter.
Is there any way I can set just the drag and drop for 1 adapter inside the ConcatAdapter?
Inside onMove from ItemTouchHelper.Callback, you can just decide to ignore some of the moves. Use viewHolder (the ViewHolder which is being dragged) and targetViewHolder and check their bindingAdapter. Or if they're instances of the same adapter, you can use a field that has been set on adapter initialization, to determine which adapter they are.
In the example below I have two adapters, both of type TaskAdapter, but second one holds completed tasks. Uncompleted tasks can't be moved between completed ones and completed tasks can't be moved at all. So if targetViewHolder is bound by the adapter flagged as completed, I just return false and ignore the move. Obviously an item has been dragged over that target item but since it's not actually moving, user understands this isn't allowed and will give up. I think it's acceptable and better than having multiple RecyclerViews.
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
targetViewHolder: RecyclerView.ViewHolder
): Boolean {
val targetAdapter = targetViewHolder.bindingAdapter as TaskAdapter
if (targetAdapter.completed) return false
val adapter = viewHolder.bindingAdapter as TaskAdapter
adapter.moveItem(
viewHolder.bindingAdapterPosition,
targetViewHolder.bindingAdapterPosition
)
return true
}
If you want some items can't be dragged in the first place, you can just return 0 from getDragDirs. In this example they are completed tasks.
override fun getDragDirs(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val adapter = viewHolder.bindingAdapter as TaskAdapter
if (adapter.completed) return 0
return super.getDragDirs(recyclerView, viewHolder)
}
I have custom ViewPager and PagerAdapter which is loading items inside instantiateItem. It is working fine if I initialize it for the first time with set list of items.
But as I call refresh on the list and I want to populate Adapter with new (totally different) list, after calling viewPager.adapter?.notifyDataSetChanged(), PagerAdapter stops working properly and those items are blank pages without content and I can swipe through them in UI.
PageScreen is not Fragment. It is just ViewGroup container which is inflating layout and setting values out of specific item. It is similar to ViewHolder + Binder in RecyclerView.
Also instatiateItem() is called only once as i add new list and call notifyDataSetChanged(). At start it is called 3 items, which is amount of PageScreen items in first list.
//init
val pages = mutableListOf<PageScreen>()
pages.add(PageScreen(activity, app, itemJs1, onClick = {onItemClicked(itemJs1.id)}))
pages.add(PageScreen(activity, app, itemJs2, onClick = {onItemClicked(itemJs2.id)}))
pages.add(PageScreen(activity, app, itemJs3, onClick = {onItemClicked(itemJs3.id)}))
swipePager.adapter = CustomPagerAdapter(pages).also { it.notifyDataSetChanged() }
...
//on refresh after API call
pages.clear()
contentList.forEach{item-> pages.add(PageScreen(activity, app, item, onClick = {onItemClicked(item.id)}))}
(swipePager.adapter as? CustomPagerAdapter)?.notifyDataSetChanged()
Also tried this (same result):
//on refresh after API call
val newPages = mutableListOf<PageScreen>()
contentList.forEach{item-> newPages.add(PageScreen(activity, app, item, onClick = {onItemClicked(item.id)}))}
swipePager.adapter = CustomPagerAdapter(newPages).also { it.notifyDataSetChanged() }
Adapter:
class CustomPagerAdapter(private var pageList: MutableList<PageScreen>) : PagerAdapter() {
override fun isViewFromObject(view: View, `object`: Any): Boolean {
return view == `object`
}
override fun getCount(): Int {
return pageList.size
}
override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) {
container.removeView(`object` as View)
}
override fun instantiateItem(container: ViewGroup, position: Int): View {
val layout = pageList[position].getScreen(container)
container.addView(layout)
return layout
}
}
Also I tried to properly refresh items (I expect that this is done internally by PagerAdapter and ViewPager when I call notifyDataSetChanged()) by removing them from ViewPager contentView and calling instantiateItem() for each item. But same result as above. Now every single page was blank. Function below is added to CustomPagerAdapter.
fun refreshItems(vp: ViewPager, data: MutableList<PageScreen>){
pageList.apply {
forEachIndexed{pos, item->
item.screenView?.let { sv->
destroyItem(vp, pos, sv)
}
}
clear()
addAll(data)
forEachIndexed { pos, _ -> instantiateItem(vp, pos) }
}
notifyDataSetChanged()
}
UPDATE:
I managed to "fix" this by setting ViewPager height to fixed value instead WRAP_CONTENT but its not a solution. I want ViewPager with dynamic height, because some of its children can have different height + setting something to static is not good approach in Android. Some phones with square displays could have cropped page then.
What happened is as I replaced all items, those "blank" pages were items with 0px height and 0px width for some unknown reason.
If I replaced ViewPager height to dp value, it "worked". But as I replaced those Views, first item was always blank. but as I scrolled to third one and back to first, item was there for some reason.
Also I don't get that height problem. I have function inside ViewPager which is setting its height based on tallest child in list. It works if list is static, but it is not working now as I refresh that.
Recreating whole ViewPagerAdapter is a solution for this problem.
I called API inside onPageSelected() and remembered position returned to listener function.
Then I recreated whole adapter like this:
swipePager.apply {
adapter = null
adapter = CustomPagerAdapter(newPages)
adapter?.notifyDataSetChanged()
invalidate()
}
and after refresh I scrolled to remembered position like this:
setCurrentItem(position, true)
This solution will not left blank pages, but I had to set static height for my ViewPager, which can be a problem on mdpi screens which sometimes cannot redraw particular dp value from XML layout correctly. Phone with this problem is for example Sony Xperia E5 which has xhdpi screen and part of my ViewPager is cropped from the bottom.
This have to be tuned manually with separate dimens.xml for both mdpi and xhdpi.
I am using a RecyclerView to show list of products in my app, I need to group the product based on aisle. while the data are fetched for the first time in the list, the products are grouped correctly with respect to aisle. When we scroll the view, the aisle group divider is shown for the wrong item and the divider gets restored to correct position once the onBindViewHolder gets refreshed automatically.
MyAdapter.class
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
itemsGrouping(pickItem, pickItemView, holder.adapterPosition)
}
private fun itemsGrouping(pickItem: PickItem, pickItemView: View, adapterPosition: Int) {
//Based on some condition
if(SomeCondition)
itemDivider(pickItemView,true)
else
itemDivider(pickItemView,false)
}
private fun itemDivider(v: View, boolean: Boolean) {
if(boolean) {
v.visibility = View.VISIBLE
} else {
v.visibility = View.GONE
}
}
Well, you should know that the view holders are reused in the RecyclerView, so it's probable not the right idea to try to determine the visibility of the divider in onBindViewHolder. I would recommend using item decorator for dividers. Here's the question and answer for that
How to add dividers and spaces between items in RecyclerView?
The problem is RecyclerView recycles previous views in order to be efficient.
I guess "SomeCondition" contains artifacts which are from previous holders.
So at
itemsGrouping(pickItem, pickItemView, holder.adapterPosition)
you should get pickItem and pickItemView from newly bound holder. You should use like
pickItemView = holder.findViewById(R.id.pickItemView);
Or consider using DataBinding Library
Here is a good example (it's in Kotlin) : DataBoundListAdapter
Once you extend your adapter to DataBoundListAdapter and override bind() method, everything inside bind is executed for every row, so you won't get repeated results.
Note : notice "executePendingBindings()"
Background
I work on an app that has a RecyclerView which you can scroll up and down however you wish.
The data items are loaded from the server, so if you are about to reach the bottom or the top, the app gets new data to show there.
To avoid weird scrolling behavior, and staying on the current item, I use 'DiffUtil.Callback' , overriding 'getOldListSize', 'getNewListSize', 'areItemsTheSame', 'areContentsTheSame'.
I've asked about this here, since all I get from the server is a whole new list of items, and not the difference with the previous list.
The problem
The RecyclerView doesn't have only data to show. There are some special items in it too:
Since Internet connection might be slow, there is a header item and a footer item in this RecyclerView, which just have a special Progress view, to show you've reached the edge and that it will get loaded soon.
The header and footer always exist in the list, and they are not received from the server. It's purely a part of the UI, just to show things are about to be loaded.
Thing is, just like the other items, it needs to be handled by DiffUtil.Callback, so for both areItemsTheSame and areContentsTheSame, I just return true if the old header is the new header, and the old footer is the new footer:
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
when {
oldItem.itemType != newItem.itemType -> return false
oldItem.itemType == ItemType.TYPE_FOOTER || oldItem.itemType == AgendaItem.TYPE_HEADER -> return true
...
}
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
return when {
oldItem.itemType == ItemType.TYPE_FOOTER || oldItem.itemType == ItemType.TYPE_HEADER -> true
...
}
}
}
Seems right? Well it's wrong. If the user is at the top of the list, showing the header, and the list gets updated with new items, the header will stay at the top, meaning the previous items you've seen will get pushed away by the new ones.
Example:
Before: header, 0, 1, 2, 3, footer
After: header, -3, -2, -1, 0, 1, 2, 3, footer
So if you stayed on the header, and the server sent you the new list, you still see the header, and below the new items, without seeing the old ones. It scrolls for you instead of staying on the same position .
Here's a sketch showing the issue. The black rectangle shows the visible part of the list.
As you can see, before loading, the visible part has the header and some items, and after loading it still has the header and some items, but those are new items that pushed away the old ones.
I need the header to be gone on this case, because the real content is below it. Instead of the area of the header, it might show other items (or a part of them) above it, but the visible position of the current items should stay where they are.
This issue only occurs when the header is shown, at the top of the list. In all other cases it works fine, because only normal items are shown at the top of the visible area.
What I've tried
I tried to find how to set DiffUtil.Callback to ignore some items, but I don't think such a thing exists.
I was thinking of some workarounds, but each has its own disadvantages:
A NestedScrollView (or RecyclerView) which will hold the header&footer and the RecyclerView in the middle, but this might cause some scrolling issues, especially due to the fact I already have a complex layout that depends on the RecyclerView (collapsing of views etc...).
Maybe in the layout of the normal items, I could put the layout of the header and footer too (or just the header, because this one is the problematic one). But this is a bad thing for performance as it inflates extra views for nothing. Plus it requires me to toggle hiding and viewing of the new views within.
I could set a new ID for the header each time there is an update from the server, making it as if the previous header is gone, and there is a totally new header at the top of the new list. However, this might be risky in the case of no real updates of the list at the top, because the header will be shown as if it's removed and then re-added.
The questions
Is there a way to solve this without such workarounds?
Is there a way to tell DiffUtil.Callback : "these items (header&footer) are not real items to scroll to, and these items (the real data items) should be" ?
I will try to explain what I see as a solution to your problem:
Step 1: Remove all the code for FOOTER and HEADER views.
Step 2: Add these methods that add and remove dummy model items in adapter based on the user scroll direction:
/**
* Adds loader item in the adapter based on the given boolean.
*/
public void addLoader(boolean isHeader) {
if (!isLoading()) {
ArrayList<Model> dataList = new ArrayList<>(this.oldDataList);
if(isHeader) {
questions.add(0, getProgressModel());
else {
questions.add(getProgressModel());
setData(dataList);
}
}
/**
* Removes loader item from the UI.
*/
public void removeLoader() {
if (isLoading() && !dataList.isEmpty()) {
ArrayList<Model> dataList = new ArrayList<>(this.oldDataList);
dataList.remove(getDummyModel());
setData(questions);
}
}
public MessageDetail getChatItem() {
return new Model(0, 0, 0, "", "", "")); // Here the first value is id which is set as zero.
}
And here is your rest of the adapter logic that you need to decide if the item is a loader item or an actual data item:
#Override
public int getItemViewType(int position) {
return dataList.get(position).getId() == 0 ? StaticConstants.ItemViewTypes.PROGRESS : StaticConstants.ItemViewTypes.CONTENT;
}
According to the view type, you can add a progress bar view holder in your adapter.
Step 3: use these methods in data loading logic:
While making the API call in onScrolled() method of recyclerView, you need to add a loader item before the api call and then remove it after the api call. Use the given adapter methods above. The coded in onScrolled should look a little like this:
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
#Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
if (dy < 0) { //This is top scroll, so add a loader as the header.
recyclerViewAdapter.addLoader(true);
LinearLayoutManager linearLayoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
if (!recyclerViewAdapter.isLoading(true)) {
if (linearLayoutManager.findFirstCompletelyVisibleItemPosition() <= 2) {
callFetchDataApi();
}
}
}
} else {
if (!recyclerViewAdapter.isLoading(false)) {
if (linearLayoutManager.findLastCompletelyVisibleItemPosition() >= linearLayoutManager.getItemCount() - 2) {
callFetchDataApi();
}
}
});
Now after the api call gives you the data you need. Simply remove the added loader from the list like this:
private void onGeneralApiSuccess(ResponseModel responseModel) {
myStreamsDashboardAdapter.removeLoader();
if (responseModel.getStatus().equals(SUCCESS)) {
// Manage your pagination and other data loading logic here.
dataList.addAll(responseModel.getDataList());
recyclerViewAdapter.setData(dataList);
}
}
And lastly, you need to avoid any scroll during data loading operation is add a logic method for that is isLoading() method. which is used in the code of method onScrolled():
public boolean isLoading(boolean isFromHeader) {
if (isFromHeader) {
return dataList.isEmpty() || dataList.get(0).getId() == 0;
} else {
return dataList.isEmpty() || dataList.get(dataList.size() -1).getId() == 0;
}
}
Let me know if you don't understand any of this.
I think for now, the solution I took will suffice. It's a bit weird, but I think it should work:
The header item gets a new id each time the list is different in its first real item. The footer always have the same id, because it's ok for it to move in the current way it works. I don't even need to check that its id is the same. The check of areItemsTheSame is as such for them:
oldItem.agendaItemType == AgendaItem.TYPE_HEADER -> return oldItem.id == newItem.id
oldItem.agendaItemType == AgendaItem.TYPE_FOOTER -> return true
This way, if the header belongs to a new list data, old one will be removed, and new one will be at the top.
It's not the perfect solution, as it doesn't really push the original header to be at the top, and theoretically it makes us "kinda" have 2 headers at the same time (one being removed and one being added) but I think it's good enough.
Also, for some reason, I can't use notifyItemChanged on the header and footer in case only they get updated (internet connection changes its state, so need to change the header&footer alone). Only notifyDataSetChanged works for some reason.
Still, if there is a more official way, could be nice to know.
I am building a component based on RecyclerView, allowing user to reorder items by drag and drop.
Once I am on the DragListener side, I need the position it has in the adapter in order to perform correct move, but I only have access to the view.
So here is what I am doing in the adapter view binding :
#Override
public void onBindViewHolder(ViewHolder viewHolder, int position) {
Track track = mArray.get(position);
viewHolder.itemView.setTag(R.string.TAG_ITEM_POSITION, position);
}
Does it seem correct to you ?
Because if I move an item like this :
public void move(int from, int to){
Track track = mArray.remove(from);
mArray.add(to, track);
notifyItemMoved(from, to);
}
then position tag is not correct anymore, and if I notifyDataSetChanged(), I lose the fancy animation.
Any suggestion ?
There is a way to preserve fancy animations with just notifyDataSetChanged()
You need to make your own GridLayoutManager with overriden supportsPredictiveItemAnimations() method returning true;
You need to mAdapter.setHasStableIds(true)
The part I find tricky is you need to override you adapter's getItemId() method. It should return value that is truly unique and not a direct function of position. Something like mItems.get(position).hashCode()
Worked perfectly fine in my case - beautiful animations for adding, removing and moving items only using notifyDataSetChanged()
No, it is wrong. First of all, you cannot reference to the position passed to the onBindViewHolder after that method returns. RecyclerView will not rebind a view when its position changes (due to items moving etc).
Instead, you can use ViewHolder#getPosition() which will return you the updated position.
If you fix that, your move code should work & provide nice animations.
Calling notifyDataSetChanged will prevent predictive animations so avoid it as long as you can. See documentation for details.
Edit (from comment): to get position from the outside, get child view holder from recyclerview and then get position from the vh. See RecyclerView api for details
1) You'll use notifyItemInserted(position); or notifyItemRemoved(position); instead of notifyDataSetChanged() for animation.
2) You can just manually fix your problem - using
public void move(int from, int to){
Track track = mArray.remove(from);
mArray.add(to, track);
notifyItemMoved(from, to);
ViewHolder fromHolder = (ViewHolder) mRecyclerView.findViewHolderForPosition(from);
ViewHolder toHolder = (ViewHolder) mRecyclerView.findViewHolderForPosition(to);
Tag fromTag = fromHolder.itemView.getTag();
fromHolder.itemView.setTag(toHolder.itemView.getTag());
toHolder.itemView.setTag(fromTag);
}
You should move your method to OnCreateViewHolder, then notifyItemRemoved(index) works properly.
I'm able to maintain the touch animations by adding this to my list item's outer element
<View
android:foreground="?android:attr/selectableItemBackground"
...>
I fixed it with using 'notifyItemChanged(int position);' instead of 'notifyDataSetChanged();'
My adapter shows fancy animations perfectly and without any lags
Edit: I got position from onBindViewHolder's position.
as stated by others above, you can have animation while using notifyDataSetChanged on your adapter, although you need to specifically use stable ids. if your items IDs are strings, you can generate a long id for each string id you have and keep them in a map. for example:
class StringToLongIdMap {
private var stringToLongMap = HashMap<String, Long>()
private var longId: Long = 0
fun getLongId(stringId: String): Long {
if (!stringToLongMap.containsKey(stringId)) {
stringToLongMap[stringId] = longId++
}
return stringToLongMap[stringId] ?: -1
}
}
and then in your adapter:
private var stringToLongIdMap = StringToLongIdMap()
override fun getItemId(position: Int): Long {
val item = items[position]
return stringToLongIdMap.getLongId(item.id)
}
another useful thing to consider, if you are using kotlin data class as items in your adapter, and you don't have an id, you can use the hashCode of the data class itself as stable id (if you are sure that the item properties combination are unique in your data set):
override fun getItemId(position: Int): Long = items[position].hashCode().toLong()