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.

No comments:

Post a Comment