Reputation: 54194
How can I get my ItemDecoration
to "update" the item offsets for other views when I remove a given view from my adapter and then call notifyItemRemoved()
?
In my particular situation, I have an ItemDecoration
that provides a small amount of top offset to every item, and a large amount of bottom offset to the last item only. When I remove the last item, the view that becomes the new last item does not have any bottom offset.
Before and after:
If I then scroll the list around, when I return to the bottom of the list, my "new" last item has the correct offsets. It's only an issue for as long as the "new" last item remains visible on the screen.
If I change my notifyItemRemoved()
call to notifyDataSetChanged()
, the new last item will have the correct item offsets applied, but I lose the built-in animation I get from notifyItemRemoved()
.
If I keep using notifyItemRemoved()
and additionally call notifyItemChanged()
on the previous item, then I will get the correct item offsets but the animation is somewhat janky; the second-to-last item (before the removal) seems to fade out and then in again (in this screenshot, the card saying "item 19" was just removed):
I know that I can create a large space at the end of my list by applying bottom padding to my <RecyclerView>
tag and specifying android:clipToPadding="false"
, but this solution is not acceptable for various reasons.
I originally encountered this issue in a much larger app I'm working on, but here's a tiny app that demonstrates the issue.
MainActivity.java:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final MyAdapter adapter = new MyAdapter(20);
Button button = findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
adapter.removeItemAt(adapter.getItemCount() - 1);
}
});
RecyclerView recycler = findViewById(R.id.recycler);
recycler.setLayoutManager(new LinearLayoutManager(this));
recycler.addItemDecoration(new MyItemDecoration());
recycler.setAdapter(adapter);
}
private static class MyItemDecoration extends RecyclerView.ItemDecoration {
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
int viewPosition = parent.getChildViewHolder(view).getLayoutPosition();
int lastPosition = state.getItemCount() - 1;
outRect.top = 20;
outRect.bottom = (viewPosition == lastPosition) ? 200 : 0;
}
}
private static class MyAdapter extends RecyclerView.Adapter<MyViewHolder> {
private final List<String> list;
private MyAdapter(int count) {
this.list = new ArrayList<>();
for (int i = 0; i < count; ++i) {
list.add("" + i);
}
}
@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
View itemView = inflater.inflate(R.layout.item, parent, false);
return new MyViewHolder(itemView);
}
@Override
public void onBindViewHolder(MyViewHolder holder, int position) {
holder.text.setText("item: " + list.get(position));
}
@Override
public int getItemCount() {
return list.size();
}
private void removeItemAt(int position) {
if (list.size() > 0) {
list.remove(position);
notifyItemRemoved(position);
}
}
}
private static class MyViewHolder extends RecyclerView.ViewHolder {
private final TextView text;
public MyViewHolder(View itemView) {
super(itemView);
this.text = itemView.findViewById(R.id.text);
}
}
}
activity_main.xml:
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#eee">
<Button
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="REMOVE LAST ITEM"/>
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
item.xml:
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_margin="8dp"
android:gravity="center"
android:textColor="#000"
android:textSize="16sp"/>
</android.support.v7.widget.CardView>
Upvotes: 27
Views: 14647
Reputation: 11940
Although Ben has given the right solution, that doesn't cover the use case of people who are using ListAdapter
because the use of AsyncListDiffer
inside that class only notifies the exact changes. So for that to happen, I have written my own class.
abstract class BetterListAdapter<T, VH : RecyclerView.ViewHolder>(
private val diffCallback: DiffUtil.ItemCallback<T>
) : RecyclerView.Adapter<VH> {
private lateinit var listDiffer: AsyncListDiffer<T>
val currentList: List<T> get() = listDiffer.currentList
init {
val notifier = ListUpdateAdapterNotifier()
val config = AsyncDifferConfig.Builder(diffCallback).build()
listDiffer = AsyncListDiffer(notifier, config)
listDiffer.addListListener(this::onCurrentListChanged)
}
fun submitList(items: List<T>, commitCallback: Runnable? = null): Unit =
listDiffer.submitList(items, commitCallback)
fun getItem(position: Int): T =
currentList[position]
override fun getItemCount(): Int =
currentList.size
fun onCurrentListChanged(previousList: List<T>, currentList: List<T>) =
Unit
private inner class ListUpdateAdapterNotifier : ListUpdateCallback {
private val lastPosition: Int get() = itemCount - 1
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
if (position > 1) {
notifyItemChanged(position - 1, false)
}
if (position < lastPosition) {
notifyItemChanged(position + count + 1, false)
}
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position, count)
if (position > 1) {
notifyItemChanged(position - 1, false)
}
notifyItemChanged(position, false)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
notifyItemChanged(fromPosition, false)
notifyItemChanged(toPosition, false)
if (fromPosition > 1) {
notifyItemChanged(fromPosition - 1, false)
}
if (fromPosition < lastPosition) {
notifyItemChanged(fromPosition + 1, false)
}
if (toPosition > 1) {
notifyItemChanged(toPosition - 1, false)
}
if (toPosition < lastPosition) {
notifyItemChanged(toPosition + 1, false)
}
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position, count, payload)
}
}
}
Now in your codebase, just extend your adapter from BetterListAdapter
instead of ListAdapter
and it will notify changes in neighbouring views automatically.
Since I have custom spacings implemented as an ItemDecoration
and they differ depending on the neighbouring cells, this will now properly update my spacings.
Thanks for the original answer Ben!
Upvotes: 0
Reputation: 452
ListAdapter solution:
listAdapter.submitList(newList) {
handler.post { // or just "post" if you're inside View
recyclerView.invalidateItemDecorations()
}
}
The key is new submitList(list, commitCallback)
method from latest RecyclerView release. It allows you to call invalidateItemDecorations()
after ListAdapter will apply all changes to RecyclerView.
I also had to add post {}
call to make it work - without it decorations invalidated too early, and nothing happens.
Upvotes: 14
Reputation: 321
This is a too late answer, but I will answer because I have the same problem. Try call RecyclerView.invalidateItemDecorations() before insert/update/remove data.
Upvotes: 14
Reputation: 54194
I solved this by changing my removeItemAt()
method as follows:
private void removeItemAt(int position) {
if (list.size() > 0) {
list.remove(position);
notifyItemRemoved(position);
if (position != 0) {
notifyItemChanged(position - 1, Boolean.FALSE);
}
}
}
The second argument to notifyItemChanged()
is the key. It can be literally any object, but I chose Boolean.FALSE
because it's a "well-known" object and because it conveys a little bit of intent: I don't want animations to run on the item I'm changing.
It turns out that there's a second onBindViewHolder()
method defined in RecyclerView.Adapter
that I'd never seen before. From the source code:
public void onBindViewHolder(VH holder, int position, List<Object> payloads) {
onBindViewHolder(holder, position);
}
Excerpted from that method's documentation:
The
payloads
parameter is a merge list fromnotifyItemChanged(int, Object)
ornotifyItemRangeChanged(int, int, Object)
. If thepayloads
list is not empty, theViewHolder
is currently bound to old data andAdapter
may run an efficient partial update using the payload info. If the payload is empty,Adapter
must run a full bind.
In other words, by passing something (anything) as a payload in notifyItemChanged()
, we're telling the system that we want to perform only a "partial update" (in situations where that's possible).
So, sure, we've instructed the system to perform a partial update... but how does that stop the flickering caused by simply calling notifyItemChanged(position - 1)
? It has to do with the RecyclerView.ItemAnimator
that's attached to all RecyclerView
s by default: DefaultItemAnimator
.
DefaultItemAnimator
's source code includes this method:
@Override
public boolean canReuseUpdatedViewHolder(@NonNull ViewHolder viewHolder,
@NonNull List<Object> payloads) {
return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads);
}
You'll notice that this method also takes a List<Object> payloads
parameter. This is the same list of payloads as used in the other onBindViewHolder()
call mentioned above.
Because we passed a payload argument, this method will return true
. And since the animator is now told that it can reuse the already-existing ViewHolder
for our "changed" item, it doesn't tear it down and create a new one (or reuse a recycled one)... which stops the default fade animation on the changed item from running!
Upvotes: 30