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를 기대해본다.

 




Chromecast로 YouTube앱 사용 후기

아직 국내에 크롬캐스트 출시전이라 사용후기가 많이 없는것 같다. 어제 맥북에 크롬캐스트 에뮬레이터를 구현해서 잠시나마 사용해 볼 수 있었고, 사용하면서의 후기를 적어 보려고 한다.


 YouTube말고 Google MusicPlay, Netflix등 몇몇가지가 지원되나 국내서비스를 하지 않아 YouTube앱을 선택 했다.


 



크롬캐스트가 사용할 수 있으면 YouTube앱에서 재생화면에서 액션바를 보면 아이콘이 나타난다. 아이콘만 누르면 핸드폰에는 재생이 멈추고 크롬캐스트가 연결된 장치에 재생이된다. 사용자입장에서는 아주 간단하다. 크롬캐스트는 화면 미러링 방식이 아니고, 스트리밍 URL을 전송하면 크롬캐스트가 직접 재생하는 방식이기때문에 앱에 부화를 주지 않는다. 그래서 재생중 핸드폰의 화면을 끄거나 종료해도 재생은 계속 된다.


 



동영상 시킹기능도 제공되며, 핸드폰의 볼륨 조절키로 볼륨도 제어가 가능하다. 하지만, 앱을 빠져나가 대기화면이라던지, 다른앱에서는 제어가 불가능 하다. SDK에서 이런부분을 좀 챙겨주면 좋을것 같은데.. 아직 약간 디테일하진 않다.


 



그리고  YouTube앱에서 크롬캐스트로 재생중이면 하단에 컨트롤 박스가 뜬다. 지금재생중인곡 정보, 정지/다음곡으로 넘어 가거나 대기열을 볼 수 있는 아이콘들이 배치 되어 있다.


 



대기열 아이콘을 누르면 현재 재생중인 동영상과 대기중인 동영상들이 나오게 되는데, 여기서 재생을 누르게 되면 바로 재생이 된다. 대기열 순서 편집을 아직 지원하지 않는다.


 



대기열에 동영상을 추가 하는 방법은 지금재생중인 동영상 외 다른 동영상을 검색하거나 들어가게 되면 “TV  대기열에 추가” 라는 항목이 생긴다.


 



부가적으로 노티피케이션, 잠금화면일때 재생을 컨트롤 할수 있는 기능들이 있다. 일반적으로 음악앱의 경우에만 이런 컨트롤 기능을 제공하는데, 동영상앱임에도 불구하고 이런 기능이 들어가있다. 그 말은즉, 동영상을 보면서도 다른작업을 할 수 있다는 뜻이 되지 않을까 생각한다. 실질적으로 미러링방식이 아닌 자체 재생방식으로 동영상을 재생하면서 다른 작업해도 전혀 느리거나 끊기지 않는다.


 


크롬캐스트의 가장 장점은 손쉬운 사용성과 가격이 저렴하다는 점이다. 단순히 YouTube앱을 사용해봤지만, 이런 컨텐츠앱들이 많아 지게 되거나 이외 재미있는 앱들이 개발된다면 스마트TV는 두려움에 떨어야 할 것이라는 생각이든다.