Adding item animations to ListView

Saturday, June 1, 2013

Adding item animations to ListView

 

Adding item animations to ListView

Greetings,
 
not so long ago I came up with the idea of adding animation to my ListView scrolling. In many ways it was supposed to be similar to G+ animation but a bit different.
 
I wanted to make new items appear from bottom and a little bit from the right side. Also some time later I have implemented simple translate animation I was inspired by this talkby +Romain Guy  and +Chet Haase and decided to add little skewing to my items as they appear. This way they look more like paper sheets. This required to change the way thinkgs were done a little bit but it was worth it.
 
In the following paragraph I will describe sep by step how to do simple animation with translation and what you have to do to make it distorting items.
 
Below you can see a simple demo video. Notice how items are skewing a bit as they appear. This video was recorded from the emulator, so it may have some stuttering. The animation is perfectly smooth on my gnex though. Also I have slowed it down x3 for the purpose of clarity in video. Normaly you would want it to last no more than 300ms.

 

 

Simple moving animation

Since we do want items to pop every time they appear at the top or bottom of our list, the best place to do it is the getView() method of our adapter:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
    animatePostHc(position, v);
} else {
    animatePreHc(position, v);
}

My main focus for this article is ICS and above, so I will be telling primarily on how to do it in those versions. If you want to do exactly the same on older versions you should either do similar tween animations or use Jake Wharton's awesome library NineOldAndroids, which would allow you to use the same API on all versions.
 
Let's take a look at animatePostHc method:

@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
private void animatePostHc(int position, View v) {
    if (prevPosition < position) {
        v.setTranslationX(animX);
        v.setTranslationY(animY);
    } else {
        v.setTranslationX(-animX);
        v.setTranslationY(-animY);
    }
    v.animate().translationY(0).translationX(0).setDuration(300)
        .setListener(new InnerAnimatorListener(v)).start();
}

Here we determine the direction the list is scrolling and adjust our item initial displacement. Then we do displacement animation to translate the item back to (0; 0).

Let's see what our InnerAnimatorListener does.

static class InnerAnimatorListener implements AnimatorListener {

    private View v;

    private int layerType;

    public InnerAnimatorListener(View v) {
        this.v = v;
    }

    @Override
    public void onAnimationStart(Animator animation) {
        layerType = v.getLayerType();
        v.setLayerType(View.LAYER_TYPE_HARDWARE, null);
    }

    @Override
    public void onAnimationEnd(Animator animation) {
        v.setLayerType(layerType, null);
    }

}

since we want out animation to be smooth and perfect we want to set hardware layer mode to our view. In this case as it animates android will create a separate layer and put entire view as a texture to that layer. You can see it if you enable hardware overdraw mode. You will see that entire view is drawn as a single item as it is being animated. This speedss up rendering by a great deal.

Actually, if you are targeting Jelly Bean and higher, you can do the same this listener does by calling withLayer() method on your ViewPropertyAnimator:

v.animate().withLayer().translationY(0).translationX(0).setDuration(300).setListener(new InnerAnimatorListener(v)).start();

But we don't live in a perfect world, do we?

Let's run our application. Yes, it works. But we can notice that items are eing animated all the time, even when the activity is just started. We want them to animate only as we scrill the list. No problem. Lets' add a boolean flag to our adapter and control it from the scrolling listener:
 
listView.setOnScrollListener(new OnScrollListener() {
@Overridepublicvoid onScrollStateChanged(AbsListView view, int scrollState) {
        adapter.setAnimate(scrollState == SCROLL_STATE_FLING || SCROLL_STATE_TOUCH_SCROLL == scrollState);
    }
    // Omitted...
});

Now it's much better. But still there is a defect. You can notice if you fling your list with a fast enough speed the animation will look terrible. Items just can't keep up with the scrolling of the main view. What we want to do is to disable animations when our list is scrolling fast enough. That's how I did. But I'll describe it a bit later as I want to finish with the animation first an d then do polishing.

Adding skewing to list items

To add a little skewing behavior to out items we would need to create a custom layout. Worry not, we don't need to implement it, we just need to extend whatever is the root layout of your list items and add a little code to it. In my case it was the Relative layout, so I have created the SkewingRelativeLayout class like this:

public class SkewingRelativeLayout extends RelativeLayout {

    private float skewX = 0;

    public SkewingRelativeLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    public SkewingRelativeLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public SkewingRelativeLayout(Context context) {
        super(context);
    }

    @Override
    public void draw(Canvas canvas) {
        if (skewX != 0) {
            canvas.skew(skewX, 0);
        }

        super.draw(canvas);
    }

    public void setSkewX(float skewX) {
        this.skewX = skewX;
        ViewCompat.postInvalidateOnLayout(this);
    }

}

Simple enough: we have added a field skewX, now in our draw method we apply it to distort our canvas.

Now when we have to animate not olnly x and y but also a skew we will need to change our approach to animation a little bit.

@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
private void animatePostHc(int position, View v) {
    float startSkewX = 0.15f;
    float translationX;
    float translationY;

    if (prevPosition < position) {
        translationX = animX;
        translationY = animY;
    } else {
        translationX = -animX;
        translationY = -animY;
    }

    ObjectAnimator skewAnimator = ObjectAnimator.ofFloat(v, "skewX", startSkewX, 0f);
    ObjectAnimator translationXAnimator = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, translationX, 0.0f);
    ObjectAnimator translationYAnimator = ObjectAnimator.ofFloat(v, View.TRANSLATION_Y, translationY, 0.0f);

    AnimatorSet set = new AnimatorSet();
    set.playTogether(skewAnimator, translationXAnimator, translationYAnimator);
    set.setDuration(300);
    set.setInterpolator(decelerator);
    set.addListener(new InnerAnimatorListener(v));
    set.start();
}

as you see, we have replaced ViewPropertyAnimator with three ObjectAnimator instances each responsible for it's own value. To make them work in synchrony we unite them in a simgle AnimatorSet. This will allow all three animations to share a single interpolator. In my case I am using a DeceleratorInterpolator as an instance field of my class.

On thing to notice here is that you can't use layers if you are skewing your list items. This will create ugly artifacts on the sides of a layer. So unfortunately this is the trade off for fancy distortion. But if your list items are not too complex it will still work smooth enough.


Getting rid of fast scrolling defect

After doing some experiments I have decided that in order to get rid of ugly animation behavior during fast scrolling we need to do two things:

  1. Disable item animations if fling is faster than a specific threshold
  2. Cancel all already running animations
Second point is important because speed threshould is a very subtle thing and it's easy to have one view animated and it's neighbour not. This will create ugly overlapping which we don't want.Let's modify our listener to calculate the speed:

 
listView.setOnScrollListener(new OnScrollListener() {

privateint previousFirstVisibleItem = 0;
privatelong previousEventTime = 0;
privatedouble speed = 0;

privateint scrollState;

@Overridepublicvoid onScrollStateChanged(AbsListView view, int scrollState) {
this.scrollState = scrollState;
        adapter.setAnimate(scrollState == SCROLL_STATE_FLING || SCROLL_STATE_TOUCH_SCROLL == scrollState);
    }

@Overridepublicvoid onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if (previousFirstVisibleItem != firstVisibleItem) {
            long currTime = System.currentTimeMillis();
            long timeToScrollOneElement = currTime - previousEventTime;
            speed = ((double) 1 / timeToScrollOneElement) * 1000;

            previousFirstVisibleItem = firstVisibleItem;
            previousEventTime = currTime;

            if (scrollState == SCROLL_STATE_FLING && speed > 16) {
                adapter.setAnimate(false);
                adapter.cancelAnimations();
            } else {
                adapter.setAnimate(true);
            }
        }
    }

});

Now as our scrolling speed exeeds limit we disable the animation and cancel all running animators. Let me just note that the magic value 16 here is somethin I came up with experimentally. This value actually depends on your item dimensions and it's not a good idea to have it hardcoded. But for the demo simplicity purpose I have left it that way.

Let's add the following to our adapter:

 public void cancelAnimations() {
    for (int i = anims.size() - 1; i >= 0; i--) {
        anims.get(i).cancel();
    }
}
 
What's omitted here is that now we are keeping a list of running animators in adapter. Each animator set has a listener which will remove animator from this list as soon as the animation finishes:

private class InnerAnimatorListenerimplements AnimatorListener {

    View view;

    public InnerAnimatorListener(View view) {
        this.view = view;
    }

    @Override
    public void onAnimationStart(Animator animation) {
        ViewCompat.setHasTransientState(view, true);
    }

    @Override
    public void onAnimationEnd(Animator animation) {
        ViewCompat.setHasTransientState(view, false);
        anims.remove(animation);
    }

    @Override
    public void onAnimationCancel(Animator animation) {
        view.setTranslationX(0);
        view.setTranslationY(0);
        ((SkewingRelativeLayout) view).setSkewX(0);
    }

}

Now when we cancel the animation we remove all displacemets instantly. One thing to keep in mind is that onAnimationEnd method is called every time regardless of you called cancel or not. 

Another important thing is to add transient state flag to our view. This flag will make sure ListView won't reuse your view as it's being animated. ViewPropertyAnimator does that for you while ObjectAnimator doesn't.


Summary 

This approach allows us to create a simple yet appealing animations to our ListView. You can modify animated values to customize the way you want to look it, but the ide is the same.
One thing to notice here is that I am using this approach for a small list. I don't think there are many problems to have it work in longer lists. But I would probably want to reduce number of objects I create during the animation. AnimatorSet is a reusable class so you can arrange a pool of unused animators and pick free animators from pool. Obviouly, pool size correlates with the maximum speed of scrilling you choose.

 

posted @ 2015-12-16 11:46  清澈见底  阅读(181)  评论(0编辑  收藏  举报