10:04 AM
0
Recently, at the Google I/O 2013, two new layouts have been introduced: SlidingPaneLayout, a view that can be dragged from bottom to top and vice versa and the DrawerLayout, now used in almost all Google applications. Both of these use a new concept to more easily manage dragging: the ViewDragHelper.
In this article, I’m going to talk about the ViewDragHelper (aka VDH) because making a custom layout with dragging child view may be pain sometimes. First, I will show you how to use it and how it works (the main lines). Secondly, I will expose you a use case where the VDH is really useful.
Download Sample Application

API design

In a few words

There are some important points to remember about VDH:
  • a ViewDragHelper.Callback is used as a communication channel between parent view and VDH
  • there is a static factory method to create a VDH instance
  • you can configure the drag direction as you want
  • a drag can be detected from edge even if there is no view to capture (left, right, top, bottom)
Remember to read the official documentation: ViewDragHelper and ViewDragHelper.Callback

Reading the source code

The VDH and its callback are available in the support-v4 library. You can read the source code : ViewDragHelper and ViewDragHelper.Callback.
It uses some common classes of the framework : a VelocityTracker for tracking fingling and other touch events and a Scroller to scroll views when it’s needed.
You must read the source code as much as possible because first, it’s very interesting and then if you know how it works, you will be able to use it in a better way.

Using the VDH

In this section, I’m going to show you a few examples of what is possible to configure on a VDH. Let’s begin with some initializations and then, I will explain a few possible configurations.

VDH’s initialization

A custom ViewGroup extending a LinearLayout (DragLayout) with a simple child View (named mDragView).
public class DragLayout extends LinearLayout {

private final ViewDragHelper mDragHelper;
private View mDragView;

public DragLayout(Context context) {
  this(context, null);
}

public DragLayout(Context context, AttributeSet attrs) {
  this(context, attrs, 0);
}

public DragLayout(Context context, AttributeSet attrs, int defStyle) {
  super(context, attrs, defStyle);
}
Create a VDH with its callback. Note that you can specify the sensivity (official documentation says Multiplier for how sensitive the helper should be about detecting the start of a drag. Larger values are more sensitive. 1.0f is normal.)

1
2
3
4
5
public DragLayout(Context context, AttributeSet attrs, int defStyle) {
  super(context, attrs, defStyle);
  mDragHelper = ViewDragHelper.create(this, 1.0f, new DragHelperCallback());
}

The most important, don’t forget to call VDH methods in onInterceptTouch and onTouch.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
  final int action = MotionEventCompat.getActionMasked(ev);
  if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
      mDragHelper.cancel();
      return false;
  }
  return mDragHelper.shouldInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent ev) {
  mDragHelper.processTouchEvent(ev);
  return true;
}

Now, you can change the VDH behavior just by configuring the callback.

Horizontal only

Implements clampViewPositionHorizontal to allow horizontal drag and to bound the drag motion. Note that documentation says The default implementation does not allow horizontal motion.
You have to take margins and parent padding into consideration.

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
  Log.d("DragLayout", "clampViewPositionHorizontal " + left + "," + dx);

  final int leftBound = getPaddingLeft();
  final int rightBound = getWidth() - mDragView.getWidth();

  final int newLeft = Math.min(Math.max(left, leftBound), rightBound);

  return newLeft;
}


Vertical only

Implements clampViewPositionVertical to allow horizontal drag and to bound the drag motion. Note that documentation says The default implementation does not allow vertical motion.
You have to take margins and parent padding into consideration. Not like in the code below

1
2
3
4
5
6
7
8
9
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
  final int topBound = getPaddingTop();
  final int bottomBound = getHeight() - mDragView.getHeight();

  final int newTop = Math.min(Math.max(top, topBound), bottomBound);

  return newTop;
}

Capture or not capture a view

Implements tryCaptureView to allow a child view to be captured. Here, there are two child views (mDragView1 and mDragView2) but only one (mDragView1) is draggable.

1
2
3
4
@Override
public boolean tryCaptureView(View child, int pointerId) {
  return child == mDragView1;
}

DragRange

Implements getViewHorizontalDragRange or getViewVerticalDragRange to returns the range of horizontal|vertical drag in pixels. This range is used by the VDH when you call smoothSlideViewTo or settleCapturedViewAt to calculate the scroll duration. Also, it’s used to check the horizontal|vertical touch slop.

Edge dragging

This feature is used in the DrawerLayout with EDGE_LEFT and EDGE_RIGHT.
Configure the VDH to enable edge tracking.


mDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);
Implements onEdgeTouched called when the configured edge is touched. Note that at this time, no child view is currently captured.

@Override
public void onEdgeTouched(int edgeFlags, int pointerId) {
    super.onEdgeTouched(edgeFlags, pointerId);
    Toast.makeText(getContext(), "edgeTouched", Toast.LENGTH_SHORT).show();
}
Implements onEdgeDragStarted called when a real drag from the configured edge has started. Note that at this time, no child view is currently captured. In this method, you have to capture a child view manually.




@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
    mDragHelper.captureChildView(mDragView2, pointerId);
}

A real example, the Youtube while playing layout

Recently, I’ve received an update of the Youtube app on my phone. Before this update, the most annoying thing was to not be able to watch a video and search the next video at the same time. They fixed this by implementing a nice layout in which you can minimize the video view from top to bottom.
I’m going to show how to do it and how it’s simple thanks to VDH.
Here is the expected result

Key points:
  • tryCaptureView returns true only for the header view
  • drag range is calculated onLayout
  • use VDH’s methods in onInterceptTouchEvent and onTouchEvent
  • call continueSettling in computeScroll (because VDH uses a scroller)
  • use smoothSlideViewTo to finish the drag motion
Be careful, this layout is not well made: it’s a draft. There is still work to do; on the scale part, touch event when the header is scaled, onLayout and onMeasure are badly written too. Also, I don’t know if calling requestLayout in onViewPositionChanged is good solution… Anyway, if you have remarks or ideas to improve this layout, please tell me!).

activity_main.xml
<FrameLayout

        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <ListView
            android:id="@+id/listView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:tag="list"
            />

    <com.example.vdh.YoutubeLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/youtubeLayout"
            android:orientation="vertical"
            android:visibility="visible">

        <TextView
                android:id="@+id/viewHeader"
                android:layout_width="match_parent"
                android:layout_height="128dp"
                android:fontFamily="sans-serif-thin"
                android:textSize="25sp"
                android:tag="text"
                android:gravity="center"
                android:textColor="@android:color/white"
                android:background="#AD78CC"/>

        <TextView
                android:id="@+id/viewDesc"
                android:tag="desc"
                android:textSize="35sp"
                android:gravity="center"
                android:text="Loreum Loreum"
                android:textColor="@android:color/white"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:background="#FF00FF"/>

    </com.example.vdh.YoutubeLayout>
</FrameLayout>


YoutubeLayout.java
public class YoutubeLayout extends ViewGroup{

    private static final String TAG = "YoutubeLayout";

    private static final int MIN_FLING_VELOCITY = 400; // dips per second

    private View mHeaderView;
    private View mDescView;

    private float mInitialMotionX;
    private float mInitialMotionY;

    private int mDragRange;
    private int mTop;
    private float mDragOffset;

    private ViewDragHelper mDragHelper;



    public YoutubeLayout(Context context) {
        super(context);
        init(context);
    }

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

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

    private void init(Context context) {
        final float density = context.getResources().getDisplayMetrics().density;
        mDragHelper = ViewDragHelper.create(this, 1.0f, new DragHelperCallback());
        mDragHelper.setMinVelocity(MIN_FLING_VELOCITY * density);
    }

    public void maximize()
    {
        smoothSlideTo(0.0f);
    }

    public void minimize()
    {
        smoothSlideTo(1.0f);
    }

    private boolean smoothSlideTo(float slideOffset) {
        final int topBound = getPaddingTop();
        int y = (int) (topBound + slideOffset * mDragRange);

        if(mDragHelper.smoothSlideViewTo(mHeaderView, mHeaderView.getLeft(), y))
        {
            ViewCompat.postInvalidateOnAnimation(this);
            return true;
        }
        return false;
    }

    @Override
    protected void onFinishInflate() {
        Log.d(TAG, "onFInishInflate");
        mHeaderView = findViewById(R.id.viewHeader);
        mDescView   = findViewById(R.id.viewDesc);
    }

    @SuppressLint("NewApi")
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//        Log.d(TAG, "onMeasure");
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int maxWidth = MeasureSpec.getSize(widthMeasureSpec);
        int maxHeight = MeasureSpec.getSize(heightMeasureSpec);

        setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 0), resolveSizeAndState(maxHeight, heightMeasureSpec, 0));

        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int parentViewHeight = getHeight();
        int dragViewHeight = mHeaderView.getMeasuredHeight();
        mDragRange = parentViewHeight - dragViewHeight;

        mHeaderView.layout(
                0,
                mTop,
                r,
                mTop + mHeaderView.getMeasuredHeight());

        mDescView.layout(
                0,
                mTop + mHeaderView.getMeasuredHeight(),
                r,
                mTop  + b);
    }

    private class DragHelperCallback extends ViewDragHelper.Callback
    {
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return child == mHeaderView;
        }

        @SuppressLint("NewApi")
        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            mTop = top;
            mDragOffset = (float) top / mDragRange;

            mHeaderView.setPivotX(mHeaderView.getWidth());
            mHeaderView.setPivotY(mHeaderView.getHeight());
            mHeaderView.setScaleX(1 - mDragOffset / 2);
            mHeaderView.setScaleY(1 - mDragOffset / 2);

            mDescView.setAlpha(1 - mDragOffset);

            requestLayout();
        }

        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            final int topBound = getPaddingTop();
            final int bottomBound = getHeight() - mHeaderView.getHeight() - mHeaderView.getPaddingBottom();

            final int newTop = Math.min(Math.max(top, topBound), bottomBound);
            return newTop;
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {

            int top = getPaddingTop();
            if (yvel > 0 || (yvel == 0 && mDragOffset > 0.4f)) {
                top += mDragRange;
            }
            mDragHelper.settleCapturedViewAt(releasedChild.getLeft(), top);
            invalidate();
        }

        @Override
        public int getViewVerticalDragRange(View child) {
            return mDragRange;
        }

    }

    @Override
    public void computeScroll() {
        if(mDragHelper.continueSettling(true))
        {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final int action = MotionEventCompat.getActionMasked(ev);

        if(action != MotionEvent.ACTION_DOWN)
        {
            mDragHelper.cancel();
            return super.onInterceptTouchEvent(ev);
        }

        if(action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP)
        {
            mDragHelper.cancel();
            return false;
        }

        final float x = ev.getX();
        final float y = ev.getY();
        boolean interceptTap = false;

        switch(action)
        {
            case MotionEvent.ACTION_DOWN:
                mInitialMotionX = x;
                mInitialMotionY = y;
                interceptTap = mDragHelper.isViewUnder(mHeaderView, (int) x, (int) y);
                break;

            case MotionEvent.ACTION_MOVE:
                final float adx = Math.abs(x - mInitialMotionX);
                final float ady = Math.abs(y - mInitialMotionY);
                final int slop = mDragHelper.getTouchSlop();
                if(ady > slop && adx > ady)
                {
                    mDragHelper.cancel();
                    return false;
                }
                break;
        }

        return mDragHelper.shouldInterceptTouchEvent(ev) || interceptTap;
    }

    @SuppressLint("ClickableViewAccessibility")
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mDragHelper.processTouchEvent(event);

        final int action = event.getAction();
        final float x = event.getX();
        final float y = event.getY();

        boolean isHeaderViewUnder = mDragHelper.isViewUnder(mHeaderView, (int)x, (int)y);

        switch(action & MotionEventCompat.ACTION_MASK)
        {
            case MotionEvent.ACTION_DOWN:
            {
                mInitialMotionX = x;
                mInitialMotionY = y;
                break;
            }
            case MotionEvent.ACTION_UP:
            {
                final float dx = x - mInitialMotionX;
                final float dy = y - mInitialMotionY;
                final float slop = mDragHelper.getTouchSlop();

                if(dx * dx + dy * dy < slop * slop && isHeaderViewUnder)
                {
                    if (mDragOffset == 0) {
                        minimize();
                    } else {
                        maximize();
                    }
                }
                break;
            }
        }

        return isHeaderViewUnder && isViewHit(mHeaderView, (int) x, (int) y) || isViewHit(mDescView, (int) x, (int) y);
    }

    private boolean isViewHit(View view, int x, int y) {
        int[] viewLocation = new int[2];
        view.getLocationOnScreen(viewLocation);
        int[] parentLocation = new int[2];
        this.getLocationOnScreen(parentLocation);
        int screenX = parentLocation[0] + x;
        int screenY = parentLocation[1] + y;
        return screenX >= viewLocation[0] && screenX < viewLocation[0] + view.getWidth() &&
                screenY >= viewLocation[1] && screenY < viewLocation[1] + view.getHeight();
    }
}



Conclusion

The VDH is one of the useful but unknown class in the framework. Don’t hesitate to try it, use it and to appreciate it because it saves a lot of time and a lot of code!

 Source: http://flavienlaurent.com/blog/2013/08/28/each-navigation-drawer-hides-a-viewdraghelper/

0 comments:

Post a Comment