在 RecyclerView 的零位置插入项目 - 总是保持滚动到顶部

30

我有一个标准的RecyclerView,使用垂直的LinearLayoutManager。我在顶部不断插入新项目,并调用notifyItemInserted(0)

我希望列表保持滚动到顶部;始终显示第0个位置。

从我的需求角度来看,LayoutManager基于项目数量表现不同。

当所有项目都适合屏幕时,它的外观和行为与我的期望相同: 新项目始终出现在顶部并将下面的所有内容向下移动

少量项目的行为:新增项目向下移动其他项目,第一个(最新)项目始终可见

但是,一旦项目数超过了RecyclerView的范围,新项将添加到当前可见项目的上方,但可见项目保持在视图中。用户必须滚动才能查看最新的项目。

大量项目的行为:新项目添加到顶部边界之上,用户必须滚动才能看到它们

这种行为对于许多应用程序来说是可以理解和接受的,但对于“实时提要”而言,查看最新的内容比通过自动滚动“不分心”更重要。
我知道这个问题几乎是 添加新项到 RecyclerView 的顶部 的副本... 但所有的答案都只是一些变通方法(其中大部分是相当好的)。
我正在寻找一种实际上改变这种行为的方法。我希望 LayoutManager 表现完全相同,无论条目数量如何。我想它总是会移动所有的项目(就像前几次添加一样),而不是在某个点停止移动项目,并通过平滑滚动列表到顶部来补偿。
基本上,没有 smoothScrollToPosition, 没有RecyclerView.SmoothScroller 。子类化 LinearLayoutManager 是可以的。我已经在研究其中的代码,但到目前为止没有任何进展,所以我决定询问是否有人已经处理过这个问题。谢谢任何想法!
编辑: 为了澄清我为什么忽略链接问题的答案: 主要是我关心动画平滑性。

请注意第一个 GIF,当 ItemAnimator 在添加新项时移动其他项时,淡入和移动动画具有相同的持续时间。但是当我通过平滑滚动“移动”项目时,我不能轻松地控制滚动的速度。即使使用默认的ItemAnimator持续时间,这看起来也不太好,但在我的特定情况下,我甚至需要减慢ItemAnimator的持续时间,这使情况变得更糟:

插入 "fixed" 与平滑滚动 + 提高 ItemAnimator 持续时间


2
很不幸,我没有足够的声望点来在此线程中添加评论,所以我想澄清一下,我并不是试图回答这个问题。这是一个没有直观解决方案的问题;我已经研究了几个小时,唯一可能的解决方案是自己处理它,而不是修改LayoutManager以处理行为。也许应该向Android报告一个错误——linearLayoutManagers的行为应该在数据大小上保持一致! - qslabs
5个回答

18

虽然我写了这个答案并且它是被接受的解决方案,但在尝试此方法之前,我建议查看其他后来的答案,看看它们是否适用于您。


当一个项目被添加到RecyclerView的顶部并且该项目可以适合屏幕时,该项目会被附加到一个视图持有者上,并且RecyclerView会经历动画阶段以将项目向下移动以显示新项目在顶部。

如果新项目不能在不滚动的情况下显示,则不会创建视图持有者,因此没有任何内容可供动画。当发生这种情况时,将新项目放在屏幕上的唯一方法是滚动,这会导致创建视图持有者,以便可以在屏幕上布置视图。(似乎存在一种边缘情况,其中视图部分显示并创建视图持有者,但我将忽略此特定实例,因为它与本文无关。)

因此,问题在于必须使两个不同的操作,即添加视图的动画和滚动添加视图,对用户看起来相同。我们可以深入研究底层代码,并找出在视图持有者创建、动画定时等方面发生的确切情况。但是,即使我们可以复制这些操作,如果底层代码发生更改,它也可能会出现问题。这就是您所抵制的。

另一种选择是在RecyclerView的位置0处添加一个标题。当此标题显示并且新项目被添加到位置1时,您将始终看到动画效果。如果您不想要标题,您可以将其高度设置为零,它将不会显示。以下视频展示了这种技术:

[video]

这是演示的代码。它只是在项目的位置0添加了一个虚拟条目。如果虚拟条目不符合您的喜好,还有其他方法可以解决此问题。您可以搜索如何向RecyclerView添加标题。

(如果您使用滚动条,它会表现不良,从演示中您可能已经注意到了。要完全解决此问题,您将不得不接管大量滚动条高度和放置计算。自定义的computeVerticalScrollOffset()用于LinearLayoutManager在适当时将滚动条放置在顶部。 (代码是在录制视频之后引入的。)然而,滚动向下时滚动条会跳动。更好的放置计算将解决此问题。有关不同高度项目上下文中滚动条的更多信息,请参见此Stack Overflow问题。)

MainActivity.java

public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    private TheAdapter mAdapter;
    private final ArrayList<String> mItems = new ArrayList<>();
    private int mItemCount = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
    LinearLayoutManager layoutManager =
        new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) {
            @Override
            public int computeVerticalScrollOffset(RecyclerView.State state) {
                if (findFirstCompletelyVisibleItemPosition() == 0) {
                    // Force scrollbar to top of range. When scrolling down, the scrollbar
                    // will jump since RecyclerView seems to assume the same height for
                    // all items.
                    return 0;
                } else {
                    return super.computeVerticalScrollOffset(state);
                }
            }
        };
        recyclerView.setLayoutManager(layoutManager);

        for (mItemCount = 0; mItemCount < 6; mItemCount++) {
            mItems.add(0, "Item # " + mItemCount);
        }

        // Create a dummy entry that is just a placeholder.
        mItems.add(0, "Dummy item that won't display");
        mAdapter = new TheAdapter(mItems);
        recyclerView.setAdapter(mAdapter);
    }

    @Override
    public void onClick(View view) {
        // Always at to position #1 to let animation occur.
        mItems.add(1, "Item # " + mItemCount++);
        mAdapter.notifyItemInserted(1);
    }
}

TheAdapter.java

class TheAdapter extends RecyclerView.Adapter<TheAdapter.ItemHolder> {
    private ArrayList<String> mData;

    public TheAdapter(ArrayList<String> data) {
        mData = data;
    }

    @Override
    public ItemHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View view;

        if (viewType == 0) {
            // Create a zero-height view that will sit at the top of the RecyclerView to force
            // animations when items are added below it.
            view = new Space(parent.getContext());
            view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0));
        } else {
            view = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.list_item, parent, false);
        }
        return new ItemHolder(view);
    }

    @Override
    public void onBindViewHolder(final ItemHolder holder, int position) {
        if (position == 0) {
            return;
        }
        holder.mTextView.setText(mData.get(position));
    }

    @Override
    public int getItemViewType(int position) {
        return (position == 0) ? 0 : 1;
    }

    @Override
    public int getItemCount() {
        return mData.size();
    }

    public static class ItemHolder extends RecyclerView.ViewHolder {
        private TextView mTextView;

        public ItemHolder(View itemView) {
            super(itemView);
            mTextView = (TextView) itemView.findViewById(R.id.textView);
        }
    }
}

activity_main.xml

<android.support.constraint.ConstraintLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scrollbars="vertical"
        app:layout_constraintBottom_toTopOf="@+id/button"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:text="Button"
        android:onClick="onClick"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />
</android.support.constraint.ConstraintLayout>

list_item.xml

<LinearLayout 
    android:id="@+id/list_item"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="16dp"
    android:orientation="horizontal">

    <View
        android:id="@+id/box"
        android:layout_width="50dp"
        android:layout_height="50dp"
        android:layout_marginStart="16dp"
        android:background="@android:color/holo_green_light"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:textSize="24sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@id/box"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="TextView" />

</LinearLayout>

谢谢你的回答!虽然这还是有点hacky,但它很清晰简单,而且似乎100%有效,所以谁在乎呢 :) 现在我可以定义一个抽象适配器来处理这个零高度的标题(甚至可能移动索引),然后忘记它。 - oli.G
然而,滚动条存在一个问题,它似乎永远不会在顶部。你能想到解决方法吗?在我的情况下,我没有显示滚动条,所以这对我来说不是一个很大的问题,但出于完整性和封闭性的考虑,我还是很好奇 :) - oli.G
1
@oli.G 致敬那些提供了更少 hacky 但仍然清晰简单的替代方案的人。 :-) 根据我的经验,RecyclerView 滚动条和高度不同的项并不兼容。我在答案中添加了一些注释和一些代码来解决这个问题。 - Cheticamp
为什么在 findFirstCompletelyVisibleItemPosition() == 1 中使用数字 1?难道它不认为虚拟的零高度项是可见的吗? - androidguy
太棒了,我一直在尝试使用RecyclerView.AdapterDataObserver和内部标志来保持滚动条在插入时保持在顶部,如果它之前已经在顶部。然而,这是一个更加优雅的解决方案。 - Andre Romano
显示剩余2条评论

10
我还展示了实时物品流,并且当新物品被添加时,它会在第一个物品之前。但是在回收视图中,我需要向上滚动才能看到新项目。
为解决该问题,请在适配器中添加 RecyclerView.AdapterDataObserver() 并覆盖 onItemRangeInserted()。 添加新数据时,请检查数据是否位于位置0,且回收视图位于顶部(如果您正在列表中滚动,则不要自动滚动到第一个位置)。
例如:
adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
    @Override
    public void onItemRangeInserted(int positionStart, int itemCount) {
        super.onItemRangeInserted(positionStart, itemCount);
        if (positionStart == 0 && positionStart == layoutManager.findFirstCompletelyVisibleItemPosition()) {
            layoutManager.scrollToPosition(0);
        }
    }
});

这个解决方案对我很有效,可以适用于不同类型的适配器,比如 ListAdapterPagedListAdapter。 我最初想使用被接受的解决方案的类似实现,其中添加一个傻瓜第一项,但在 PagedListAdapter 中是不可能的,因为列表是不可变的。


1
有点像这个想法的优雅和简洁,我刚刚添加了它,到目前为止它运作良好 :) 我认为我们可以用它来制作一种混合 - 类似于ScrollGuard<T: RecyclerView>(view: T),它将提取布局和适配器,检查它们并安装钩子。 - sergeych

3
这对我有用:

这对我有用:

val atTop = !recycler.canScrollVertically(-1)

adapter.addToFront(item)
adapter.notifyItemInserted(0)

if (atTop) {
    recycler.scrollToPosition(0)
}

0
我遇到的唯一解决方案是通过在 LinearLayoutManager 上调用 setReverseLayout()setStackFromEnd() 来反转回收站的布局。
这可能听起来有些愚蠢,但 RecyclerView 处理将项目添加到列表末尾的方式正好可以应用于将项目添加到列表开头。唯一的缺点是您必须反转列表并从末尾开始添加项目。

-4
使用 adapter.notifyDataSetChanged() 代替 adater.notifyItemInserted(0)。如果当前滚动位置为一(旧零),这将滚动 recylerView 到零位置。

2
没错,但据我所记,滚动是没有动画的。而且NotifyDataSetChanged()会刷新整个列表,导致每个可见(和一些相邻的)项都要重新绘制,如果只插入了一个项目而没有其他变化,这样做非常低效。 - oli.G
不要忘记(但你有点在意)的是,所有东西都可见闪烁,看起来很奇怪。 - androidguy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接