How to provide custom animation during sorting (notifyDataSetChanged) on RecyclerView

Cheok Yan Cheng picture Cheok Yan Cheng · Mar 19, 2016 · Viewed 9k times · Source

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?

Answer

Andreas Wenger picture Andreas Wenger · Mar 21, 2016

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.