Thursday, September 5, 2013

Custom layout. Pull from below panel and parallax scrolling.

Today I woke up in the mood to experiment with a simple layout manager and some animations. I decided to make a similar thing you can see in most social network photo viewers where you have a content (a photo) and a comments section you can pull from below. As you drag it, the content moves up, but slower than the panel, which is the parallax scrolling.
First of all I gotta say that this a showcase that took a couple of hours to write and not a complete production ready component, but it is more than enough to make one based on it as it has all the foundation: items layout, dragging, completion animation, parallax scrolling.

Extending FrameLayout

Again for simplicity sake I am not extenging a ViewGroup (something I will do in my next article though) but will use a FrameLayout as a base component. This allows us to avoid all these routine ops like measuring, laying out our children and stuff while still give us enough flexibility to achieve out goal.
So lets start with creating a DraggablePanelLayout class and modifying its onLayout method. Our contract for this view will be that it can have only two children, both of which will match out parent dimensions.

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);

    if (getChildCount() != 2) {
        throw new IllegalStateException("DraggedPanelLayout must have 2 children!");
    }

    bottomPanel = getChildAt(0);
    bottomPanel.layout(left, top, right, bottom - bottomPanelPeekHeight);

    slidingPanel = getChildAt(1);
    if (!opened) {
        int panelMeasuredHeight = slidingPanel.getMeasuredHeight();
        slidingPanel.layout(left, bottom - bottomPanelPeekHeight, right, bottom - bottomPanelPeekHeight
            + panelMeasuredHeight);
    }
}

Simple. We added condition that our layout can have only two children and then we force the upper layer to be moved down and have only bottomPanelPeekHeight pixels visible.

A simple layout shows how it works:

<com.dataart.animtest.DraggedPanelLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:dp="http://schemas.android.com/apk/res/com.dataart.animtest"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    dp:bottom_panel_height="64dp"
    tools:context=".MainActivity" >

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@drawable/stripes" >

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:contentDescription="@string/android"
            android:src="@drawable/android" />
    </FrameLayout>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#FFFFFF"
        android:text="@string/hello_world" >

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:text="@string/random_button" />
    </FrameLayout>

</com.dataart.animtest.DraggedPanelLayout>




Yay! This exactly what I wanted it to look like. Now lets bring it to life by allowing some dragging.

Dragging motion support

In order to implement dragging we have to override the onTouchEvent method. This is where we will remember out starting Y position in ACTION_DOWN. Then during the ACTION_MOVE we will displace our panel and finally on ACTION_UP we will let it go (and we will see further what letting it go means).

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
        startDragging(event);
    } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
        if (touching) {
        float translation = event.getY() - touchY;
        translation = boundTranslation(translation);
        slidingPanel.setTranslationY(translation);
        bottomPanel
            .setTranslationY((float) (opened ? -(getMeasuredHeight() - bottomPanelPeekHeight - translation)
                * parallaxFactor : translation * parallaxFactor));
        }
    } else if (event.getAction() == MotionEvent.ACTION_UP) {
        isBeingDragged = false;
        touching = false;
    }
    return true;
 }

Simple, right? The boundTranslation() method bounds movement of the panel so it does not go beyond allowed range and setTranslation moves the component.

So just to be on the same page I want to step away a little bit and explain what is layout and what is translation. Layout is the process of building of your screen content - every component gets positioned somewhere based on some rules, this all requires prior measuring of all children of the view and children of children too. For complex layouts this is an extremely heavy procedure and there is no way you could do it 60 times per second. Unless you want to have a stuttering animation, which is a different case. Translation allows you changing visual position of some view without executing the layout over and over. Basically it is a displacement of the view relative to the position given to it during last layout operation. It is very cheap and very handy for doing animations. Apart from translation you have such properties as rotation, scale, left, top, right, bottom which you can animate too. ANd you certainly can define your own properties that would affect the way the view is drawn on canvas (just recall skew property from my last article).

Thus the main rule you have to keep in mind while doing animations is - NEVER DO LAYOUT DURING ANIMATIONS!!!

Gesture completion

Now when we learned how to drag our panel we need to make it finish the movement when user releases the touch. The logic is following:
  1. If dragging velocity is high enough, we continue the movement and switch component state (open/close)
  2. Otherwise we check if panel was dragged at least half of the way and finish the movement with a predefined speed if yes or revert the movement if not without switching of the state.
public void finishAnimateToFinalPosition(float velocityY) {
    final boolean flinging = Math.abs(velocityY) > 0.5;

    boolean opening;
    float distY;
    long duration;

    if (flinging) {
        opening = velocityY < 0;
        distY = calculateDistance(opening);
        duration = Math.abs(Math.round(distY / velocityY));
        animatePanel(opening, distY, duration);
    } else {
        boolean halfway = Math.abs(slidingPanel.getTranslationY()) >= (getMeasuredHeight() - bottomPanelPeekHeight) / 2;
        opening = opened ? !halfway : halfway;
        distY = calculateDistance(opening);
        duration = Math.round(300 * (double) Math.abs((double) slidingPanel.getTranslationY())
            / (double) (getMeasuredHeight() - bottomPanelPeekHeight));
    }

    animatePanel(opening, distY, duration);
}

The above method implements this logic. We are using a built in class VelocityTracker to track movement speed. Finally we create an ObjectAnimator to complete the motion.
public void animatePanel(final boolean opening, float distY, long duration) {
    ObjectAnimator slidingPanelAnimator = ObjectAnimator.ofFloat(slidingPanel, View.TRANSLATION_Y,
        slidingPanel.getTranslationY(), slidingPanel.getTranslationY() + distY);
    ObjectAnimator bottomPanelAnimator = ObjectAnimator.ofFloat(bottomPanel, View.TRANSLATION_Y,
        bottomPanel.getTranslationY(), bottomPanel.getTranslationY() + (float) (distY * parallaxFactor));

    AnimatorSet set = new AnimatorSet();
    set.playTogether(slidingPanelAnimator, bottomPanelAnimator);
    set.setDuration(duration);
    set.setInterpolator(sDecelerator);
    set.addListener(new MyAnimListener(opening));
    set.start();
}

After animation is finished we switch the state, clean up all translations and perform a layout to "officially" persist our new component state.
@Override
public void onAnimationEnd(Animator animation) {
        setOpenedState(opening);

        bottomPanel.setTranslationY(0);
        slidingPanel.setTranslationY(0);

        requestLayout();
}

Touch interception

This is no accident I put a button on our top panel. Right now if you try to drag a panel while starting your motion from a button you'll notice that button steals the motion and does not let us drag a panel. This is incorrect behaviour. Correct behaviour for our component is to detect that we are doing a particular motion (vertical swipe in our case) and then start intercepting all touch events until the motion is complete.
In order to do this each ViewGroup has a method called onInterceptTouchEvent to override. This method looks a lot like onTouchEvent, but it is made for different purpose and has a fairly complicated relationship with onTouchEvent which is explained in detail in documentation.


@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
        touchY = event.getY();
    } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
        if (Math.abs(touchY - event.getY()) > touchSlop) {
        isBeingDragged = true;
        startDragging(event);
        }
    } else if (event.getAction() == MotionEvent.ACTION_UP) {
        isBeingDragged = false;
    } 

    return isBeingDragged;
}

In our implementation we are making sure that user has moved finger enough for a drag (system defined touch slop constant is used) and returning true. This means our component have taken control over touch gesture and all further events are not sent to its children.
Now if user pressed a button, we can still see it's touch feedback, but as soon as user moves our panel, it disappears. Button will not register a press, but will receive an ACTION_CANCEL action.

Conclusion

This article describes the basics of animated draggable layout. There are many more things that could be added to mak this implementation final.
  • Stopping running animation when user touches the panel again
  • Handling cancellation if the parent view intercepted touch
  • Optimizing drawing of a partially clipped panel
  • etc
All sources are available on my github repo. Apart from things described in this article it also has drawing shadow between panels, custom attributes, usage of hardware layers for animation speedup.

I hope this was useful, thanks.

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 talk by +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() {
    @Override
    public void 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() {

    private int previousFirstVisibleItem = 0;
    private long previousEventTime = 0;
    private double speed = 0;

    private int scrollState;

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

    @Override
    public void 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.