8

Background

In case your RecyclerView gets new items, it is best to use notifyItemRangeInserted, together with unique, stable id for each item, so that it will animate nicely, and without changing what you see too much:

enter image description here

As you can see, the item "0", which is the first on the list, stays on the same spot when I add more items before of it, as if nothing has changed.

The problem

This is a great solution, which will fit for other cases too, when you insert items anywhere else.

However, it doesn't fit all cases. Sometimes, all I get from outside, is : "here's a new list of items, some are new, some are the same, some have updated/removed" .

Because of this, I can't use notifyItemRangeInserted anymore, because I don't have the knowledge of how many were added.

Problem is, if I use notifyDataSetChanged, the scrolling changes, because the amount of items before the current one have changed.

This means that the items that you look at currently will be visually shifted aside:

enter image description here

As you can see now, when I add more items before the first one, they push it down.

I want that the currently viewable items will stay as much as they can, with priority of the one at the top ("0" in this case).

To the user, he won't notice anything above the current items, except for some possible end cases (removed current items and those after, or updated current ones in some way). It would look as if I used notifyItemRangeInserted.

What I've tried

I tried to save the current scroll state or position, and restore it afterward, as shown here, but none of the solutions there had fixed this.

Here's the POC project I've made to try it all:

class MainActivity : AppCompatActivity() {
    val listItems = ArrayList<ListItemData>()
    var idGenerator = 0L
    var dataGenerator = 0

    class ListItemData(val data: Int, val id: Long)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            val inflater = LayoutInflater.from(this@MainActivity)
            override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder {
                return object : RecyclerView.ViewHolder(inflater.inflate(android.R.layout.simple_list_item_1, parent, false)) {}
            }

            override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) {
                val textView = holder!!.itemView as TextView
                val item = listItems[position]
                textView.text = "item: ${item.data}"
            }

            override fun getItemId(position: Int): Long = listItems[position].id

            override fun getItemCount(): Int = listItems.size
        }
        adapter.setHasStableIds(true)
        recyclerView.adapter = adapter
        for (i in 1..30)
            listItems.add(ListItemData(dataGenerator++, idGenerator++))
        addItemsFromTopButton.setOnClickListener {
            for (i in 1..5) {
                listItems.add(0, ListItemData(dataGenerator++, idGenerator++))
            }
            //this is a good insertion, when we know how many items were added
            adapter.notifyItemRangeInserted(0, 5)
            //this is a bad insertion, when we don't know how many items were added
//            adapter.notifyDataSetChanged()
        }


    }
}


<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.user.recyclerviewadditionwithoutscrollingtest.MainActivity">


    <Button
        android:id="@+id/addItemsFromTopButton" android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:layout_marginBottom="8dp" android:layout_marginEnd="8dp" android:layout_marginRight="8dp"
        android:text="add items to top" app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="@+id/recyclerView"/>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView" android:layout_width="0dp" android:layout_height="0dp"
        android:layout_marginBottom="8dp" android:layout_marginEnd="8dp" android:layout_marginStart="8dp"
        android:layout_marginTop="8dp" android:orientation="vertical"
        app:layoutManager="android.support.v7.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"/>
</android.support.constraint.ConstraintLayout>

The question

Is it possible to notify the adapter of various changes, yet let it stay on the exact same place?

Items that are viewed currently would stay if they can, or removed/updated as needed.

Of course, the items' ids will stay unique and stable, but sadly the cells size might be different from one another.


EDIT: I've found a partial solution. It works by getting which view is at the top, get its item (saved it inside the viewHolder) and tries to scroll to it. There are multiple issues with this though:

  1. If the item was removed, I will have to somehow scroll to the next one, and so on. I think in the real app, I can manage to do it. Wonder if there is a better way though.
  2. Currently it goes over the list to get the item, but maybe in the real app I can optimize it.
  3. Since it just scrolls to the item, if puts it at the top edge of the RecyclerView, so if you've scrolled a bit to show it partially, it will move a bit:

enter image description here

Here's the new code :

class MainActivity : AppCompatActivity() {
    val listItems = ArrayList<ListItemData>()
    var idGenerator = 0L
    var dataGenerator = 0

    class ListItemData(val data: Int, val id: Long)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val adapter = object : RecyclerView.Adapter<ViewHolder>() {
            val inflater = LayoutInflater.from(this@MainActivity)
            override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
                return ViewHolder(inflater.inflate(android.R.layout.simple_list_item_1, parent, false))
            }

            override fun onBindViewHolder(holder: ViewHolder, position: Int) {
                val textView = holder.itemView as TextView
                val item = listItems[position]
                textView.text = "item: ${item.data}"
                holder.listItem = item
            }

            override fun getItemId(position: Int): Long = listItems[position].id

            override fun getItemCount(): Int = listItems.size
        }
        adapter.setHasStableIds(true)
        recyclerView.adapter = adapter
        for (i in 1..30)
            listItems.add(ListItemData(dataGenerator++, idGenerator++))
        val layoutManager = recyclerView.layoutManager as LinearLayoutManager
        addItemsFromTopButton.setOnClickListener {
            for (i in 1..5) {
                listItems.add(0, ListItemData(dataGenerator++, idGenerator++))
            }
            val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
            val holder = recyclerView.findViewHolderForAdapterPosition(firstVisibleItemPosition) as ViewHolder
            adapter.notifyDataSetChanged()
            val listItemToGoTo = holder.listItem
            for (i in 0..listItems.size) {
                val cur = listItems[i]
                if (listItemToGoTo === cur) {
                    layoutManager.scrollToPositionWithOffset(i, 0)
                    break
                }
            }
            //TODO think what to do if the item wasn't found
        }


    }

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        var listItem: ListItemData? = null
    }
}
android developer
  • 114,585
  • 152
  • 739
  • 1,270
  • Can you please share the full code? I am unble to understand the whole concept – Vikram Singh Feb 14 '19 at 00:24
  • @VikramSingh Concept of what? You understand the question I asked? The code above is of the question, not the answer... The real solution is in the answer, here: https://stackoverflow.com/a/47522246/878126 – android developer Feb 14 '19 at 07:54
  • I mean, diffutil is still adding duplicate elements if they have same id – Vikram Singh Feb 14 '19 at 08:12
  • @VikramSingh So you probably implemented it in a wrong way. Maybe check this out: https://medium.com/@iammert/using-diffutil-in-android-recyclerview-bdca8e4fbb00 – android developer Feb 14 '19 at 11:47

1 Answers1

9

I would solve this problem using the DiffUtil api. DiffUtil is meant to take in a "before" and "after" list (that can be as similar or as different as you want) and will compute for you the various insertions, removals, etc that you would need to notify the adapter of.

The biggest, and nearly only, challenge in using DiffUtil is in defining your DiffUtil.Callback to use. For your proof-of-concept app, I think things will be quite easy. Please excuse the Java code; I know you posted originally in Kotlin but I'm not nearly as comfortable with Kotlin as I am with Java.

Here's a callback that I think works with your app:

private static class MyCallback extends DiffUtil.Callback {

    private List<ListItemData> oldItems;
    private List<ListItemData> newItems;

    @Override
    public int getOldListSize() {
        return oldItems.size();
    }

    @Override
    public int getNewListSize() {
        return newItems.size();
    }

    @Override
    public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
        return oldItems.get(oldItemPosition).id == newItems.get(newItemPosition).id;
    }

    @Override
    public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
        return oldItems.get(oldItemPosition).data == newItems.get(newItemPosition).data;
    }
}

And here's how you'd use it in your app (in java/kotlin pseudocode):

addItemsFromTopButton.setOnClickListener {
    MyCallback callback = new MyCallback();
    callback.oldItems = new ArrayList<>(listItems);

    // modify listItems however you want... add, delete, shuffle, etc

    callback.newItems = new ArrayList<>(listItems);
    DiffUtil.calculateDiff(callback).dispatchUpdatesTo(adapter);
}

I made my own little app to test this out: each button press would add 20 items, shuffle the list, and then delete 10 items. Here's what I observed:

  • When the first visible item in the "before" list also existed in the "after" list...
    • When there were enough items after it to fill the screen, it stayed in place.
    • When there were not, the RecyclerView scrolled to the bottom
  • When the first visible item in the "before" list did not also exist int he "after" list, the RecyclerView would try to keep whichever item that did exist in both "before" + "after" and was closest to the first visible position in the "before" list in the same position, following the same rules as above.
Ben P.
  • 52,661
  • 6
  • 95
  • 123
  • This is a very nice find. How did you find it? Works great! Also, I think you should pass `true` to the `calculateDiff` second parameter, so that it will detect movement of items. About Kotlin vs Java, I will always love Java. :) – android developer Nov 28 '17 at 07:54
  • Say, since you know about RecyclerView, could you try to check on this question too: https://stackoverflow.com/q/47282276/878126 . I'm trying to have 2 RecyclerViews that are synced with the first item they show, as you scroll. Or this one: https://stackoverflow.com/q/47514072/878126 . Here I try to snap the RecyclerView like a ViewPager, but every X items instead. – android developer Nov 28 '17 at 07:57
  • @androiddeveloper I'm not sure when/where I first heard of `DiffUtil`... it's something that's been in the back of my mind for a while. As for the second param, I _believe_ (thought I'm not 100%) that it is `true` by default and you only have to pass it if you want to _disable_ movement detection to improve performance. – Ben P. Nov 28 '17 at 14:55
  • You are correct. It is `true` by default. Just checked in code. Odd they don't mention it in docs. I guess it makes more sense to have it enabled, but I just wanted to be sure. Can you check my other questions of `RecyclerView` ? I even have a new one today: https://stackoverflow.com/q/47534838/878126 – android developer Nov 28 '17 at 15:27
  • 1
    @BenP. how to actually prevent to scroll to the bottom? – K.Os Dec 04 '17 at 12:19
  • @okset I don't think that's possible. The RecyclerView scrolls to the bottom in those cases because there's nothing else to show. In other words, if the item that used to be in the first visible position is now the last item in the list, to keep that item pinned to the top the RecyclerView would have to show most of the screen as empty, which would require some sort of padding or blank rows below it. – Ben P. Dec 04 '17 at 15:02
  • 1
    @BenP. So is there a way to disable all of those automatic scrolling at all? – K.Os Dec 04 '17 at 15:52
  • Adding calculateDiff(..., false) doesn't keep the scroll position – Damia Fuentes Jun 28 '22 at 18:21