22

Currently, by using the default animator android.support.v7.widget.DefaultItemAnimator, here's the outcome I'm having during sorting

DefaultItemAnimator animation video : https://youtu.be/EccI7RUcdbg

public void sortAndNotifyDataSetChanged() {
    int i0 = 0;
    int i1 = models.size() - 1;

    while (i0 < i1) {
        DemoModel o0 = models.get(i0);
        DemoModel o1 = models.get(i1);

        models.set(i0, o1);
        models.set(i1, o0);

        i0++;
        i1--;

        //break;
    }

    // adapter is created via adapter = new RecyclerViewDemoAdapter(models, mRecyclerView, this);
    adapter.notifyDataSetChanged();
}

However, instead of the default animation during sorting (notifyDataSetChanged), I prefer to provide custom animation as follow. Old item will slide out via right side, and new item will slide up.

Expected animation video : https://youtu.be/9aQTyM7K4B0

How I achieve such animation without RecylerView

Few years ago, I achieve this effect by using LinearLayout + View, as that time, we don't have RecyclerView yet.

This is how the animation is being setup

PropertyValuesHolder alpha = PropertyValuesHolder.ofFloat("alpha", 1.0f, 0f);
PropertyValuesHolder translationX = PropertyValuesHolder.ofFloat("translationX", 0f, (float) width);
ObjectAnimator animOut = ObjectAnimator.ofPropertyValuesHolder(this, alpha, translationX);

animOut.setDuration(duration);
animOut.setInterpolator(accelerateInterpolator);
animOut.addListener(new AnimatorListenerAdapter() {
    public void onAnimationEnd(Animator anim) {
        final View view = (View) ((ObjectAnimator) anim).getTarget();

        Message message = (Message)view.getTag(R.id.TAG_MESSAGE_ID);
        if (message == null) {
            return;
        }

        view.setAlpha(0f);
        view.setTranslationX(0);
        NewsListFragment.this.refreshUI(view, message);
        final Animation animation = AnimationUtils.loadAnimation(NewsListFragment.this.getActivity(),
            R.anim.slide_up);
        animation.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {

            }

            @Override
            public void onAnimationEnd(Animation animation) {
                view.setVisibility(View.VISIBLE);
                view.setTag(R.id.TAG_MESSAGE_ID, null);
            }

            @Override
            public void onAnimationRepeat(Animation animation) {

            }
        });
        view.startAnimation(animation);
    }
});

layoutTransition.setAnimator(LayoutTransition.DISAPPEARING, animOut);

this.nowLinearLayout.setLayoutTransition(layoutTransition);

and, this is how the animation is being triggered.

// messageView is view being added earlier in nowLinearLayout
for (int i = 0, ei = messageViews.size(); i < ei; i++) {
    View messageView = messageViews.get(i);
    messageView.setTag(R.id.TAG_MESSAGE_ID, messages.get(i));
    messageView.setVisibility(View.INVISIBLE);
}

I was wondering, how I can achieve the same effect in RecylerView?

pRaNaY
  • 24,642
  • 24
  • 96
  • 146
Cheok Yan Cheng
  • 47,586
  • 132
  • 466
  • 875
  • 1
    Do you wan't to achieve this animation without or with a RecyclerView? – Andreas Wenger Mar 21 '16 at 18:37
  • I want to achieve this animation with a recycler view. – Cheok Yan Cheng Mar 21 '16 at 18:39
  • Should the items always slide out to the right, even if they are still present afterwards? Or should only the items slide out that are not visible any more? – Andreas Wenger Mar 21 '16 at 19:22
  • All items are always present. The only changes is their ordering after sorting. – Cheok Yan Cheng Mar 23 '16 at 03:23
  • Getting exactly what you want is very difficult. you have to create your own `LayoutManager` and overriding `onLayoutChildren` and returning true from `supportsPredictiveItemAnimations()`. So when calling `notifyDataSetChanged` you must lay out recyclerView's children properly to make the animation. –  Mar 27 '16 at 11:35

2 Answers2

10

First of all:

  • This solution assumes that items that are still visible, after the dataset changed, also slide out to the right and later slide in from the bottom again (This is at least what I understood you are asking for)
  • Because of this requirement I couldn't find an easy and nice solution for this problem (At least during the first iteration). The only way I found was to trick the adapter - and fight the framework to do something that it was not intended for. This is why the first part (How it normally works) describes how to achieve nice animations with the RecyclerView the default way. The second part describes the solution how to enforce the slide out/slide in animation for all items after the dataset changed.
  • Later on I found a better solution that doesn't require to trick the adapter with random ids (jump to the bottom for the updated version).

How it normally works

To enable animations you need to tell the RecyclerView how the dataset changed (So that it knows what kind of animations should be run). This can be done in two ways:

1) Simple Version: We need to set adapter.setHasStableIds(true); and providing the ids of your items via public long getItemId(int position) in your Adapter to the RecyclerView. The RecyclerView utilizes these ids to figure out which items were removed/added/moved during the call to adapter.notifyDataSetChanged();

2) Advanced Version: Instead of calling adapter.notifyDataSetChanged(); you can also explicitly state how the dataset changed. The Adapter provides several methods, like adapter.notifyItemChanged(int position),adapter.notifyItemInserted(int position),... to describe the changes in the dataset

The animations that are triggered to reflect the changes in the dataset are managed by the ItemAnimator. The RecyclerView is already equipped with a nice default DefaultItemAnimator. Furthermore it is possible to define custom animation behavior with a custom ItemAnimator.

Strategy to implement the slide out (right), slide in (bottom)

The slide to the right is the animation that should be played if items are removed from the dataset. The slide from bottom animation should be played for items that were added to the dataset. As mentioned at the beginning I assume that it is desired that all elements slide out to the right and slide in from the bottom. Even if they are visible before and after the dataset change. Normally RecyclerView would play to change/move animation for such items that stay visible. However, because we want to utilize the remove/add animation for all items we need to trick the adapter into thinking that there are only new elements after the change and all previously available items were removed. This can be achieved by providing a random id for each item in the adapter:

@Override
public long getItemId(int position) {
    return Math.round(Math.random() * Long.MAX_VALUE);
}

Now we need to provide a custom ItemAnimator that manages the animations for the added/removed items. The structure of the presented SlidingAnimator is very similar to theandroid.support.v7.widget.DefaultItemAnimator that is provided with the RecyclerView. Also Notice this is a prove of concept and should be adjusted before used in any app:

public class SlidingAnimator extends SimpleItemAnimator {
    List<RecyclerView.ViewHolder> pendingAdditions = new ArrayList<>();
    List<RecyclerView.ViewHolder> pendingRemovals = new ArrayList<>();

    @Override
    public void runPendingAnimations() {
        final List<RecyclerView.ViewHolder> additionsTmp = pendingAdditions;
        List<RecyclerView.ViewHolder> removalsTmp = pendingRemovals;
        pendingAdditions = new ArrayList<>();
        pendingRemovals = new ArrayList<>();

        for (RecyclerView.ViewHolder removal : removalsTmp) {
            // run the pending remove animation
            animateRemoveImpl(removal);
        }
        removalsTmp.clear();

        if (!additionsTmp.isEmpty()) {
            Runnable adder = new Runnable() {
                public void run() {
                    for (RecyclerView.ViewHolder addition : additionsTmp) {
                        // run the pending add animation
                        animateAddImpl(addition);
                    }
                    additionsTmp.clear();
                }
            };
            // play the add animation after the remove animation finished
            ViewCompat.postOnAnimationDelayed(additionsTmp.get(0).itemView, adder, getRemoveDuration());
        }
    }

    @Override
    public boolean animateAdd(RecyclerView.ViewHolder holder) {
        pendingAdditions.add(holder);
        // translate the new items vertically so that they later slide in from the bottom
        holder.itemView.setTranslationY(300);
        // also make them invisible
        holder.itemView.setAlpha(0);
        // this requests the execution of runPendingAnimations()
        return true;
    }

    @Override
    public boolean animateRemove(final RecyclerView.ViewHolder holder) {
        pendingRemovals.add(holder);
        // this requests the execution of runPendingAnimations()
        return true;
    }

    private void animateAddImpl(final RecyclerView.ViewHolder holder) {
        View view = holder.itemView;
        final ViewPropertyAnimatorCompat anim = ViewCompat.animate(view);
        anim
                // undo the translation we applied in animateAdd
                .translationY(0)
                // undo the alpha we applied in animateAdd
                .alpha(1)
                .setDuration(getAddDuration())
                .setInterpolator(new DecelerateInterpolator())
                .setListener(new ViewPropertyAnimatorListener() {
                    @Override
                    public void onAnimationStart(View view) {
                        dispatchAddStarting(holder);
                    }

                    @Override
                    public void onAnimationEnd(View view) {
                        anim.setListener(null);
                        dispatchAddFinished(holder);
                        // cleanup
                        view.setTranslationY(0);
                        view.setAlpha(1);
                    }

                    @Override
                    public void onAnimationCancel(View view) {
                    }
                }).start();
    }

    private void animateRemoveImpl(final RecyclerView.ViewHolder holder) {
        View view = holder.itemView;
        final ViewPropertyAnimatorCompat anim = ViewCompat.animate(view);
        anim
                // translate horizontally to provide slide out to right
                .translationX(view.getWidth())
                // fade out
                .alpha(0)
                .setDuration(getRemoveDuration())
                .setInterpolator(new AccelerateInterpolator())
                .setListener(new ViewPropertyAnimatorListener() {
                    @Override
                    public void onAnimationStart(View view) {
                        dispatchRemoveStarting(holder);
                    }

                    @Override
                    public void onAnimationEnd(View view) {
                        anim.setListener(null);
                        dispatchRemoveFinished(holder);
                        // cleanup
                        view.setTranslationX(0);
                        view.setAlpha(1);
                    }

                    @Override
                    public void onAnimationCancel(View view) {
                    }
                }).start();
    }


    @Override
    public boolean animateMove(RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) {
        // don't handle animateMove because there should only be add/remove animations
        dispatchMoveFinished(holder);
        return false;
    }
    @Override
    public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, int fromLeft, int fromTop, int toLeft, int toTop) {
        // don't handle animateChange because there should only be add/remove animations
        if (newHolder != null) {
            dispatchChangeFinished(newHolder, false);
        }
        dispatchChangeFinished(oldHolder, true);
        return false;
    }
    @Override
    public void endAnimation(RecyclerView.ViewHolder item) { }
    @Override
    public void endAnimations() { }
    @Override
    public boolean isRunning() { return false; }
}

This is the final result:

enter image description here

Update: While Reading the post again I figured out a better solution

This updated solution doesn't require to trick the adapter with random ids into thinking all items were removed and only new items were added. If we apply the 2) Advanced Version - how to notify the adapter about dataset changes, we can just tell the adapter that all previous items were removed and all the new items were added:

int oldSize = oldItems.size();
oldItems.clear();
// Notify the adapter all previous items were removed
notifyItemRangeRemoved(0, oldSize);

oldItems.addAll(items);
// Notify the adapter all the new items were added
notifyItemRangeInserted(0, items.size());

// don't call notifyDataSetChanged
//notifyDataSetChanged();

The previously presented SlidingAnimator is still necessary to animate the changes.

Andreas Wenger
  • 4,310
  • 1
  • 22
  • 31
  • Sorry for late reply. I will test it within these few days. May I know, is the scroll position being persevered by using remove and re-add again technique? – Cheok Yan Cheng Mar 23 '16 at 03:24
  • Hi Andreas, I had tested your answer. However, the trick which removes items and re-insert them doesn't work really well. They don't preserve the current scroll position. When all items have been removed, the scroll will reset to 0. – Cheok Yan Cheng Mar 24 '16 at 00:06
  • Hi Cheok. The problem about the scroll position is a bug I also encountered with the `RecyclerView`. This is my current fix for this problem http://stackoverflow.com/a/35797621/2481494 . I hope this also fixes it for you – Andreas Wenger Mar 24 '16 at 18:30
  • In your case however it's not a *bug* in `RecyclerView` that it scrolls to the top. Nevertheless the linked workaround should help you to preserve the scrolling position. – Andreas Wenger Mar 25 '16 at 12:52
  • 1
    Great! `1) Simple Version` was more than enough in my case. – Rafael Feb 02 '17 at 08:15
9

Here is one more direction you can look at, if you don't want your scroll to reset on each sort (GITHUB demo project):

Use some kind of RecyclerView.ItemAnimator, but instead of rewriting animateAdd() and animateRemove() functions, you can implement animateChange() and animateChangeImpl(). After sort you can call adapter.notifyItemRangeChanged(0, mItems.size()); to triger animation. So code to trigger animation will look pretty simple:

for (int i = 0, j = mItems.size() - 1; i < j; i++, j--)
    Collections.swap(mItems, i, j);

adapter.notifyItemRangeChanged(0, mItems.size());

For animation code you can use android.support.v7.widget.DefaultItemAnimator, but this class has private animateChangeImpl() so you will have to copy-pasted code and changed this method or use reflection. Or you can create your own ItemAnimator class like @Andreas Wenger did in his example of SlidingAnimator. The point here is to implement animateChangeImpl Simmilar to your code there are 2 animations:

1) Slide old view to the right

private void animateChangeImpl(final ChangeInfo changeInfo) {
    final RecyclerView.ViewHolder oldHolder = changeInfo.oldHolder;
    final View view = oldHolder == null ? null : oldHolder.itemView;
    final RecyclerView.ViewHolder newHolder = changeInfo.newHolder;
    final View newView = newHolder != null ? newHolder.itemView : null;

    if (view == null) return;
    mChangeAnimations.add(oldHolder);

    final ViewPropertyAnimatorCompat animOut = ViewCompat.animate(view)
            .setDuration(getChangeDuration())
            .setInterpolator(interpolator)
            .translationX(view.getRootView().getWidth())
            .alpha(0);

    animOut.setListener(new VpaListenerAdapter() {
        @Override
        public void onAnimationStart(View view) {
            dispatchChangeStarting(oldHolder, true);
        }

        @Override
        public void onAnimationEnd(View view) {
            animOut.setListener(null);
            ViewCompat.setAlpha(view, 1);
            ViewCompat.setTranslationX(view, 0);
            dispatchChangeFinished(oldHolder, true);
            mChangeAnimations.remove(oldHolder);

            dispatchFinishedWhenDone();

            // starting 2-nd (Slide Up) animation
            if (newView != null)
                animateChangeInImpl(newHolder, newView);
        }
    }).start();
}

2) Slide up new view

private void animateChangeInImpl(final RecyclerView.ViewHolder newHolder,
                                 final View newView) {

    // setting starting pre-animation params for view
    ViewCompat.setTranslationY(newView, newView.getHeight());
    ViewCompat.setAlpha(newView, 0);

    mChangeAnimations.add(newHolder);

    final ViewPropertyAnimatorCompat animIn = ViewCompat.animate(newView)
            .setDuration(getChangeDuration())
            .translationY(0)
            .alpha(1);

    animIn.setListener(new VpaListenerAdapter() {
        @Override
        public void onAnimationStart(View view) {
            dispatchChangeStarting(newHolder, false);
        }

        @Override
        public void onAnimationEnd(View view) {
            animIn.setListener(null);
            ViewCompat.setAlpha(newView, 1);
            ViewCompat.setTranslationY(newView, 0);
            dispatchChangeFinished(newHolder, false);
            mChangeAnimations.remove(newHolder);
            dispatchFinishedWhenDone();
        }
    }).start();
}

Here is demo image with working scroll and kinda similar animation https://i.gyazo.com/04f4b767ea61569c00d3b4a4a86795ce.gif https://i.gyazo.com/57a52b8477a361c383d44664392db0be.gif

Edit:

To speed up RecyclerView preformance, instead of adapter.notifyItemRangeChanged(0, mItems.size()); you probably would want to use something like:

LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
int firstVisible = layoutManager.findFirstVisibleItemPosition();
int lastVisible = layoutManager.findLastVisibleItemPosition();
int itemsChanged = lastVisible - firstVisible + 1; 
// + 1 because we start count items from 0

adapter.notifyItemRangeChanged(firstVisible, itemsChanged);
varren
  • 14,551
  • 2
  • 41
  • 72
  • Your approach is good, but I see only one minor issue, that could be lead to slow performance or possible crash, and it's when you tell the adapter: adapter.notifyItemRangeChanged(0, mItems.size()); You're setting up a huge range, and since the range is too big; the RecyclerView won't be able to reuse any of those views. Getting the MAX elements could be useful for small dataset in your adapter. My recommendation to handle large range, is notifying to the adapter what item has changes, or if you want to get all of them, you need to change the default cache size. – Carlos Mar 25 '16 at 14:42
  • @Carlos oh yep thx, you are 100% right. I had this in my git repo initial commit, but forgot to add in end code after some cleenup https://github.com/varren/RecyclerViewScrollAnimation/blob/ed37b9b8f2541bed80459d8724518ef2b3cabee8/app/src/main/java/com/varren/animationdemo/MainActivity.java Will edit post now – varren Mar 25 '16 at 15:18
  • Thanks @Verren for your quick update, that helps a lot our dev community :) – Carlos Mar 25 '16 at 15:26
  • I prefer this solution (using notify change) compared with notify delete + notify add. As "change" event is more appropriated, for sorting action. I had tested this solution. It works so far for my case. – Cheok Yan Cheng Mar 28 '16 at 02:43
  • Please notice `change` is not the appropriate action if you reorder your data items. The documentation for `notifyItemChanged` states: "This is an item change event, not a structural change event. It indicates that any reflection of the data at position is out of date and should be updated. The item at position retains the same identity." [1] `notifyItemMoved` is the appropriate action to report to the `Adapter`. [1] http://developer.android.com/reference/android/support/v7/widget/RecyclerView.Adapter.html#notifyItemMoved(int, int) – Andreas Wenger Mar 29 '16 at 14:54
  • Furthermore restricting the `notifyItemRangeChanged` to just the visible items is a dangerous optimization. You should always notify the `Adapter` about all the changes that occurred in the dataset. `RecyclerView` for example sometimes already prepares views for additional items, that are not visible yet, to improve the scrolling performance. "By default, LinearLayoutManager lays out 1 extra page of items while smooth scrolling and 0 otherwise." https://developer.android.com/reference/android/support/v7/widget/LinearLayoutManager.html#getExtraLayoutSpace – Andreas Wenger Mar 29 '16 at 15:09
  • @AndreasWenger You have some good points there, but what `structural change` means? Yes the code above will break if we have more then 1 type of ViewHolder, but we have only 1 type so it is pretty safe to say that all elements changed their value and there was 0 structural changes on sorting. The item at position retains the same identity (And identity is of our dataset object type. They all have String identity in my example). Adapter doesn't knows even about our objects existance. He cares only about views and all of them are of the same type in our case. – varren Mar 29 '16 at 19:43
  • @AndreasWenger About optimisation: It definitely speeds things up and i don't think that we should notify adapter about changes of items if they are not on the screen right now. I looked at source code one more time and from what i can see for case where nothing is moved, added or removed: What notify...() does is: When called, it looks at what items are displayed on the screen at the moment of its call and invalidates child views for visible positions.(It is pretty much the standart way) – varren Mar 29 '16 at 19:44
  • @varren , you can take a look how the documentation defines a `structural change` http://developer.android.com/reference/android/support/v7/widget/RecyclerView.Adapter.html#notifyDataSetChanged%28%29. In my understanding this targets logical data items that are displayed in the `RecyclerView` and not the resulting View Types. This is why i think `notifyItemChanged` should only be used if the representation of one of the logical data items in the `RecyclerView` was changed. – Andreas Wenger Mar 30 '16 at 08:23
  • @varren Can you point me to the source you looked at for the `notify...()` behavior? If the `RecyclerView` already limits the invalidation of children to the visible items I don't understand why you also need to do this manually. – Andreas Wenger Mar 30 '16 at 08:26
  • I realize this solution doesn't work, if we have `setHasStableIds` and has proper implementation for `getItemId`. Those thingy are required, if we also need "add" and "remove" animation to work as well. – Cheok Yan Cheng Apr 02 '16 at 00:19
  • @CheokYanCheng Hey, sry for the delay. Caught a fever (I'll edit this post after i get better). For now i made changes in git repo for more generic case with `setHasStableIds` Same approach, different `ItemAnimator`. https://github.com/varren/RecyclerViewScrollAnimation/commits/master If you have stable Id then add/remove but not change animations will be triggered even for `notifyItemRangeChanged()`. So you have to implement all types of animations. https://youtu.be/5Ujdn7sEEgU demo for the latest git commit – varren Apr 04 '16 at 17:40