在未知数量的 RecyclerView 数据更改(插入、删除、更新)时,如何在视觉上保持在相同的滚动位置? [英] How to visually stay on same scroll position, upon unknown number of RecyclerView data changes (insertion, deletion, updates)?

查看:23
本文介绍了在未知数量的 RecyclerView 数据更改(插入、删除、更新)时,如何在视觉上保持在相同的滚动位置?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

背景

如果您的 RecyclerView 获得新项目,最好使用 notifyItemRangeInserted,并为每个项目加上唯一、稳定的 id,这样它就会有很好的动画效果,而不会改变你看到的太多:

如您所见,列表中的第一个项目0"在我在它之前添加更多项目时保持在同一位置,就好像什么都没有改变一样.

问题

这是一个很好的解决方案,当您在其他任何地方插入项目时,它也适用于其他情况.

然而,它并不适合所有情况.有时,我从外面得到的只是:这是一个新的项目列表,有些是新的,有些是相同的,有些已更新/删除".

因此,我不能再使用 notifyItemRangeInserted,因为我不知道添加了多少.

问题是,如果我使用 notifyDataSetChanged,滚动会发生变化,因为当前项之前的项目数量发生了变化.

这意味着您当前查看的项目将在视觉上移到一边:

正如您现在看到的,当我在第一个项目之前添加更多项目时,他们会将其推下.

我希望当前可见的项目尽可能多地保留,优先级位于顶部(在本例中为0").

对于用户来说,他不会注意到当前项目之上的任何内容,除了一些可能的最终情况(删除当前项目和之后的项目,或以某种方式更新当前项目).看起来好像我使用了 notifyItemRangeInserted.

我的尝试

我尝试保存当前滚动状态或位置,然后恢复它,如

这是新代码:

class MainActivity : AppCompatActivity() {val listItems = ArrayList()var idGenerator = 0L无功数据生成器 = 0类 ListItemData(val 数据: Int, val id: Long)覆盖 fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main)val 适配器 = 对象:RecyclerView.Adapter() {val inflater = LayoutInflater.from(this@MainActivity)覆盖乐趣 onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {返回 ViewHolder(inflater.inflate(android.R.layout.simple_list_item_1, parent, false))}覆盖乐趣 onBindViewHolder(holder: ViewHolder, position: Int) {val textView = holder.itemView 作为 TextViewval item = listItems[位置]textView.text = "item: ${item.data}"holder.listItem = 项目}覆盖 fun getItemId(position: Int): Long = listItems[position].id覆盖 fun getItemCount(): Int = listItems.size}适配器.setHasStableIds(真)recyclerView.adapter = 适配器对于(我在 1..30)listItems.add(ListItemData(dataGenerator++, idGenerator++))val layoutManager = recyclerView.layoutManager 作为 LinearLayoutManageraddItemsFromTopButton.setOnClickListener {for (i in 1..5) {listItems.add(0, ListItemData(dataGenerator++, idGenerator++))}val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()val holder = recyclerView.findViewHolderForAdapterPosition(firstVisibleItemPosition) 作为 ViewHolder适配器.notifyDataSetChanged()val listItemToGoTo = holder.listItemfor (i in 0..listItems.size) {val cur = listItems[i]如果(listItemToGoTo === cur){layoutManager.scrollToPositionWithOffset(i, 0)休息}}//TODO 想想如果没有找到该项目该怎么办}}类 ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {var listItem:ListItemData?= 空}}

解决方案

我会使用 DiffUtil api.DiffUtil 旨在接收之前"和之后"列表(可以根据您的需要进行相似或不同),并将为您计算各种插入、删除等需要通知适配器.

使用 DiffUtil 时最大的,几乎唯一的挑战是定义要使用的 DiffUtil.Callback.对于您的概念验证应用程序,我认为事情会很容易.请原谅Java代码;我知道您最初是在 Kotlin 中发帖的,但我对 Kotlin 的使用不如对 Java 的熟悉.

这是我认为适用于您的应用的回调:

私有静态类 MyCallback extends DiffUtil.Callback {私有列表旧物品;私有列表新东西;@覆盖公共 int getOldListSize() {返回 oldItems.size();}@覆盖公共 int getNewListSize() {返回 newItems.size();}@覆盖公共布尔 areItemsTheSame(int oldItemPosition,int newItemPosition){返回 oldItems.get(oldItemPosition).id == newItems.get(newItemPosition).id;}@覆盖公共布尔 areContentsTheSame(int oldItemPosition,int newItemPosition){返回 oldItems.get(oldItemPosition).data == newItems.get(newItemPosition).data;}}

以下是您在应用中使用它的方式(在 java/kotlin 伪代码中):

addItemsFromTopButton.setOnClickListener {MyCallback 回调 = new MyCallback();callback.oldItems = new ArrayList<>(listItems);//根据需要修改列表项... 添加、删除、随机播放等callback.newItems = new ArrayList<>(listItems);DiffUtil.calculateDiff(callback).dispatchUpdatesTo(adapter);}

我制作了自己的小应用程序来测试:每按一次按钮会添加 20 个项目,随机排列列表,然后删除 10 个项目.这是我观察到的:

  • 当之前"列表中的第一个可见项目也存在于之后"列表中时...
    • 当它之后有足够的项目填满屏幕时,它会留在原地.
    • 当没有时,RecyclerView 滚动到底部
  • 当之前"列表中的第一个可见项目在之后"列表中也不存在时,RecyclerView 将尝试保留确实存在于之前"+"after" 并且最接近before"列表中相同位置的第一个可见位置,遵循与上述相同的规则.

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:

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:

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:

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
    }
}

解决方案

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.

这篇关于在未知数量的 RecyclerView 数据更改(插入、删除、更新)时,如何在视觉上保持在相同的滚动位置?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆