30

I have a pretty standard RecyclerView with a vertical LinearLayoutManager. I keep inserting new items at the top and I'm calling notifyItemInserted(0).

I want the list to stay scrolled to the top; to always display the 0th position.

From my requirement's point of view, the LayoutManager behaves differently based on the number of items.

While all items fit on the screen, it looks and behaves as I expect: The new item always appears on top and shifts everything below it.

Behavior with few items: Addition shifts other items down, first (newest) item is always visible

However, as soon as the no. of items exceeds the RecyclerView's bounds, new items are added above the currently visible one, but the visible items stay in view. The user has to scroll to see the newest item.

Behavior with many items: New items are added above the top boundary, user has to scroll to reveal them

This behavior is totally understandable and fine for many applications, but not for a "live feed", where seeing the most recent thing is more important than "not distracting" the user with auto-scrolls.


I know this question is almost a duplicate of Adding new item to the top of the RecyclerView... but all of the proposed answers are mere workarounds (most of them quite good, admittedly).

I'm looking for a way to actually change this behavior. I want the LayoutManager to act exactly the same, no matter the number of items. I want it to always shift all of the items (just like it does for the first few additions), not to stop shifting items at some point, and compensate by smooth-scrolling the list to the top.

Basically, no smoothScrollToPosition, no RecyclerView.SmoothScroller. Subclassing LinearLayoutManager is fine. I'm already digging through its code, but without any luck so far, so I decided to ask in case someone already dealt with this. Thanks for any ideas!


EDIT: To clarify why I'm dismissing answers from the linked question: Mostly I'm concerned about animation smoothness.

Notice in the first GIF where ItemAnimator is moving other items while adding the new one, both fade-in and move animations have the same duration. But when I'm "moving" the items by smooth scrolling, I cannot easily control the speed of the scroll. Even with default ItemAnimator durations, this doesn't look as good, but in my particular case, I even needed to slow down the ItemAnimator durations, which makes it even worse:

Insert "fixed" with smooth scroll + ItemAnimator durations increased

oli.G
  • 1,300
  • 2
  • 18
  • 24
  • 2
    Unfortunately I don't have the reputation points to add a comment to this thread, so I will clarify in that I am not attempting to answer this question. This is an issue that has no intuitive solution; I have researched for a couple hours now, and the only possible solutions propose handling it yourselves, rather than modifying the LayoutManager so it handles the behavior. Perhaps a bug should be reported to Android - the behavior of linearLayoutManagers should remain consistent across data sizes! – qslabs Sep 04 '18 at 17:59

5 Answers5

18

Although I wrote this answer and this is the accepted solution, I suggest a look at the other later answers to see if they work for you before attempting this.


When an item is added to the top of the RecyclerView and the item can fit onto the screen, the item is attached to a view holder and RecyclerView undergoes an animation phase to move items down to display the new item at the top.

If the new item cannot be displayed without scrolling, a view holder is not created so there is nothing to animate. The only way to get the new item onto the screen when this happens is to scroll which causes the view holder to be created so the view can be laid out on the screen. (There does seem to be an edge case where the view is partially displayed and a view holder is created, but I will ignore this particular instance since it is not germane.)

So, the issue is that two different actions, animation of an added view and scrolling of an added view, must be made to look the same to the user. We could dive into the underlying code and figure out exactly what is going on in terms of view holder creation, animation timing, etc. But, even if we can duplicate the actions, it can break if the underlying code changes. This is what you are resisting.

An alternative is to add a header at position zero of the RecyclerView. You will always see the animation when this header is displayed and new items are added to position 1. If you don't want a header, you can make it zero height and it will not display. The following video shows this technique:

[video]

This is the code for the demo. It simply adds a dummy entry at position 0 of the items. If a dummy entry is not to your liking, there are other ways to approach this. You can search for ways to add headers to RecyclerView.

(If you do use a scrollbar, it will misbehave as you can probably tell from the demo. To fix this 100%, you will have to take over a lot of the scrollbar height and placement computation. The custom computeVerticalScrollOffset() for the LinearLayoutManager takes care of placing the scrollbar at the top when appropriate. (Code was introduced after video taken.) The scrollbar, however, jumps when scrolling down. A better placement computation would take care of this problem. See this Stack Overflow question for more information on scrollbars in the context of varying height items.)

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>
Cheticamp
  • 61,413
  • 10
  • 78
  • 131
  • Thanks for the answer! This is still a bit hacky, but it's clear and simple, and seems 100% effective, so who cares :) Now I can define an abstract Adapter which takes care of this zero-height header (and maybe even shifts indices) and forget all about it. – oli.G May 14 '18 at 10:51
  • However, there's a problem with the scroll bar, as it never seems to be at the top. Can you think of a way around it? In my case, I'm not displaying the scroll bar, so it's not a huge issue for me, but I'm curious for the sake of completeness and closure :) – oli.G May 14 '18 at 10:54
  • 1
    @oli.G Kudos to the person who has an alternate solution that is less hacky but still clear and simple. :-) In my experience, RecyclerView scroll bars and varying height items do not play well together. I added some comments about that and a little code in the answer to address it. – Cheticamp May 14 '18 at 12:08
  • Why do you use 1 in `findFirstCompletelyVisibleItemPosition() == 1`? Does it not consider the dummy zero height item to be visible? – androidguy Oct 25 '18 at 02:18
  • Good catch, @androidguy! I have updated the code in the answer to reflect `findFirstCompletelyVisibleItemPosition() == 0`. – Cheticamp Oct 25 '18 at 11:19
  • Adapter's method getItemCount() should return mData.size() + 1, and onBindViewHolder() should call mData.get(position+1), isn't it? Otherwise we will not see the first element. – Gogi Bobina Jun 17 '19 at 08:38
  • Brilliant solution, I was messing with `RecyclerView.AdapterDataObserver` and internal flags to keep the scroll on the top on insert, if it was already previously on the top. This however is a much more elegant solution. – Andre Romano Jul 24 '19 at 00:32
10

I'm also displaying a live feed of items, and when a new item is added, it's before the first item. But in the recycler view, I need to scroll up to see the new item.

To solve the problem, add to the Adapter a RecyclerView.AdapterDataObserver() and override the onItemRangeInserted(). When new data is added, check if the data is on at position 0, and the recycler view was on top (You don't want to autoscroll to first position if you were scrolling in the list).

Exemple :

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

This solution worked fine for me, with different type of Adapter, like ListAdapter and PagedListAdapter. I firstly wanted to use a similar implementation of the accepted solution, where you add a dumb first item, but in PagedListAdapter it's impossible as list are immutable.

Loïc Dumas
  • 1,036
  • 10
  • 18
  • 1
    Sort of like the idea for its elegance and laconicism, just added it and so far it works :) I think we can make kind ot mixin with it - sort of ScrollGuard(view: T) which will extract layout and adapter, check them and install the hook – sergeych Oct 05 '20 at 16:17
3

This worked for me:

val atTop = !recycler.canScrollVertically(-1)

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

if (atTop) {
    recycler.scrollToPosition(0)
}
Justin Meiners
  • 10,754
  • 6
  • 50
  • 92
0

The only solution that worked for me was to reverse the recycler's layout by calling setReverseLayout() and setStackFromEnd() on its LinearLayoutManager.

This might sound stupid, but the way RecyclerView handles adding items at the end of the list is what you need at the top. The only downsize of this is that you'd have to reverse your list and start adding items to the end instead.

Gnzlt
  • 4,383
  • 2
  • 22
  • 24
-4

Use adapter.notifyDataSetChanged() instead of adater.notifyItemInserted(0). This will scroll recylerView to zero position if current scroll position is one(old zero).

  • 2
    That's true, but as far as I remember, the scroll happens without animation. Also NotifyDataSetChanged() refreshes the entire list, causing redraw of every visible (and some adjacent) items, which is quite inefficient in case only one item got inserted and nothing else changed. – oli.G Oct 01 '18 at 12:05
  • Not to mention (but you were kind of getting at) is everything visible *flickers*, which looks weird. – androidguy Oct 25 '18 at 01:50