Android ViewDragHelper

구글 2013 I/O를 통해서 두개의 새로운 레이아웃이 도입 되었다. 구글뮤직앱을 아래에 붙은 현재 재생중 레이아웃이다. 
이 레이아웃을 터치하거나 아래에서 위로 드레그를 하게 되면 전체 화면으로 변하게 된다.  
  
YouTube앱 또한 동영상 재생시 Back버튼을 누르게 되면 미니플레이어로 변형되며, 이것을 위쪽으로 드레그 하면 전체 화면으로 돌아가게 되는것을 볼 수있다. 
 

이것은 SinglePaneLayout과 DrawerLayout으로 새로운 개념으로 뷰를 좀더 쉽게 드래그관리를 할 수 있는 ViewDragHelper와 함께 사용하여 만들 수 있다. 아래 예제코드는 Lavienlaurent Blog를 통해 확인 가능하다.

 

ViewDragHelper에 대해 기억해야할 몇가지

 
ViewDragHelper.Callback은 상위뷰와 ViewDragHelper간의 통신채널로 사용.
-ViewDragHelper 인스턴스를 생성하는 static 팩토리 메소드가 있다.
-원하는 방향으로 드래그를 구성할수 있다.
-가장자리를 감지 할 수 있다.
ViewDragHelper는 support Library V4에서 제공한다.
 
그렇다면 간단한 예제를 통해서 어떻게 사용하는지 구현해보자.
 
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);
}

 

ViewGroup을 확장한 LinerLayout 초기화

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

 

ViewDragHelper 생성자를 통해 초기화 한다. 1.0f는 드래그 시작시 민감도이다.

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

 

가장중요한것은 onInterceptTouchEventonTouchEvent에서 ViewDragHelper를 호출하도록 구현하는 것이다. 여기까지 해줌으로써 ViewDragHelper Callback동작을 위해 구성했다.

수평드래그를 하는 예제를 하나 구현 해보자. ViewDragHelper.Callback를 implement를 해서 clampViewPositionHorizontal를 구현하면 된다. ViewGroup내 ChildView가 좌우위치가 변경되길 원하는 시점에 Callback된다.

 

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

 

변경될 위치를 return한다. 이런식으로 clampViewPositionVertical을 통해서 수직드래그도 구현가능하다.

 

 

특정 ChildView만 그래그하게 하고 싶다면 tryCaptureView를 구현해 특정뷰일때만 return true를 하면된다. 또한 가장자리에서 드래그를 원한다면 setEdgeTrackingEnabled()를 통해 쉽게 처리 가능하다.  

 

 

그럼 YouTube 앱의 재생 레이아웃을 구성해보자.

 

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

 

하단에 ListView를 덮은 후 그위에 ViewDragHelper를 구현한  View 를 얻는 레이아웃이다.

 

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

 

 

 

 

이처럼 ViewDragHelper는 View의 그래그를 좀더 쉽게 관리 할 수있기때문에 앞으로 기존처럼 딱딱한 방식이 아닌 좀더 화려한 UX를 기대해본다.