Flavien Laurent

Personal blog on programming | mainly Android, Java

Each Navigation Drawer Hides a ViewDragHelper

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).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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.

1
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.

1
2
3
4
5
@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.

1
2
3
4
@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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
public class YoutubeLayout extends ViewGroup {

private final ViewDragHelper mDragHelper;

private View mHeaderView;
private View mDescView;

private float mInitialMotionX;
private float mInitialMotionY;

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


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

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

@Override
protected void onFinishInflate() {
    mHeaderView = findViewById(R.id.viewHeader);
    mDescView = findViewById(R.id.viewDesc);
}

public YoutubeLayout(Context context, AttributeSet attrs, int defStyle) {
  super(context, attrs, defStyle);
  mDragHelper = ViewDragHelper.create(this, 1f, new DragHelperCallback());
}

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

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;
}

private class DragHelperCallback extends ViewDragHelper.Callback {

  @Override
  public boolean tryCaptureView(View child, int pointerId) {
        return child == mHeaderView;
  }

    @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 void onViewReleased(View releasedChild, float xvel, float yvel) {
      int top = getPaddingTop();
      if (yvel > 0 || (yvel == 0 && mDragOffset > 0.5f)) {
          top += mDragRange;
      }
      mDragHelper.settleCapturedViewAt(releasedChild.getLeft(), top);
  }

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

  @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 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;
          }
      }
  }

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

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

  final int action = ev.getAction();
    final float x = ev.getX();
    final float y = ev.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 int slop = mDragHelper.getTouchSlop();
          if (dx * dx + dy * dy < slop * slop && isHeaderViewUnder) {
              if (mDragOffset == 0) {
                  smoothSlideTo(1f);
              } else {
                  smoothSlideTo(0f);
              }
          }
          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();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    measureChildren(widthMeasureSpec, heightMeasureSpec);

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

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

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

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

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

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!

Comments