ViewDragHelper 的使用和分析
使用方法
一個(gè)簡單的例子
假設(shè)要實(shí)現(xiàn)一個(gè)可以對(duì)內(nèi)部的 view 進(jìn)行自由拖拽的 ViewGroup,效果如圖:
可以重寫 onTouchEvent(MotionEvent event)
方法,對(duì) MotionEvent 進(jìn)行判斷和處理,從而實(shí)現(xiàn)拖拽的效果。但是使用 ViewDragHelper 可以很方便的實(shí)現(xiàn)。只要寫很少的代碼,如下:
public class DragLayout extends FrameLayout {
private static final String TAG = "DragLayout";
private ViewDragHelper mDragHelper;
public DragLayout(@NonNull Context context) {
super(context);
init();
}
public DragLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public DragLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
Log.d(TAG, "tryCaptureView, left="+child.getLeft()+"; top="+child.getTop());
return true;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
Log.d(TAG, "left=" + left + "; dx=" + dx);
return left;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
Log.d(TAG, "top=" + top + "; dy=" + dy);
return top;
}
};
mDragHelper = ViewDragHelper.create(this, callback);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
}
然后再在 xml 中寫布局文件,如下:
<?xml version="1.0" encoding="utf-8"?>
<com.viewdraghelperlearn.DragLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center"
android:layout_margin="10dp"
android:background="@color/colorAccent"
android:gravity="center" />
<TextView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center"
android:layout_margin="10dp"
android:background="@color/colorPrimaryDark"
android:gravity="center" />
<TextView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center"
android:layout_margin="10dp"
android:background="@color/colorPrimary"
android:gravity="center" />
</com.viewdraghelperlearn.DragLayout>
即可實(shí)現(xiàn)圖中的效果。該ViewGroup中的任何子view都有隨手指頭拖拽效果。
在上面的 DragLayout
中,基本上只做了三件事:
- 創(chuàng)建 ViewDragHelper 的實(shí)例;
- 將
onInterceptTouchEvent(MotionEvent ev)
傳遞給 ViewDragHelper 的shouldInterceptTouchEvent(ev)
; - 將
onTouchEvent(MotionEvent event)
傳遞給 ViewDragHelper 的processTouchEvent(event)
;
先說一下第一條,如何創(chuàng)建一 個(gè)ViewDragHelper 的實(shí)例。
創(chuàng)建 ViewdragHelper
ViewDragHelper 提供了兩個(gè) create()
方法來創(chuàng)建實(shí)例,分別傳入兩個(gè)和三個(gè)參數(shù):
/**
*工廠方法創(chuàng)建新的 ViewDragHelper 的實(shí)例.
*
* @param forParent 與 ViewDragHelper 相關(guān)聯(lián)的父 ViewGroup
* @param 滑動(dòng)和拖拽的事件的回調(diào)
* @return 新的 ViewDragHelper 的實(shí)例
*/
public static ViewDragHelper create(ViewGroup forParent, Callback cb) {
return new ViewDragHelper(forParent.getContext(), forParent, cb);
}
/**
* 工廠方法創(chuàng)建新的 ViewDragHelper 的實(shí)例.
*
* @param 與 ViewDragHelper 相關(guān)聯(lián)的父 ViewGroup
* @param 靈敏度,越大越靈敏,1.0f是正常值
* @param 滑動(dòng)和拖拽的事件的回調(diào)
* @return 新的 ViewDragHelper 的實(shí)例
*/
public static ViewDragHelper create(ViewGroup forParent, float sensitivity, Callback cb) {
final ViewDragHelper helper = create(forParent, cb);
helper.mTouchSlop = (int) (helper.mTouchSlop * (1 / sensitivity));
return helper;
}
第二個(gè)方法比第一個(gè)方法多了一個(gè)靈敏度的參數(shù)。
先使用第一個(gè)方法創(chuàng)建實(shí)例,需要傳入兩個(gè)參數(shù)。
第一個(gè)參數(shù)是 ViewGroup,拖拽事件就是發(fā)生在這個(gè) ViewGroup 里面的子 View 上。
第二個(gè)參數(shù)是一個(gè) callback;這個(gè)回調(diào)用來指示拖拽時(shí)的各種狀態(tài)和事件的變化,回調(diào)中的方法有很多,一共13個(gè)。先看幾個(gè)相對(duì)常用和重要的,其余的放到后面再看。
public abstract boolean tryCaptureView(View child, int pointerId);
這是唯一的一個(gè)抽象方法,需要自己實(shí)現(xiàn)的。返回值表示是否捕捉這個(gè) view 的拖拽事件。這個(gè)方法會(huì)調(diào)用多次,哪怕這個(gè) view 已經(jīng)被捕捉過了,在下一次開始拖拽的時(shí)候,還是會(huì)回調(diào)這個(gè)方法。如果只想對(duì) ViewGroup 內(nèi)的特定的 view 進(jìn)行拖拽的處理,只需要返回類似于child == mDragView
這樣的形式就行了。public int clampViewPositionHorizontal(View child, int left, int dx);
這個(gè)方法約束了 View 在水平方向上的運(yùn)動(dòng)。該方法默認(rèn)是返回0的,所以一般都是需要重寫的。這個(gè)方法有三個(gè)參數(shù):第一個(gè) View 自然就是拖動(dòng)的 View;第二個(gè)參數(shù) left,指的是拖動(dòng)的 View 理論上將要滑動(dòng)到的水平方向上的值;第三個(gè)參數(shù) dx 可以理解為滑動(dòng)的速度,單位是 px 每秒。返回值是水平方向上的實(shí)際的x坐標(biāo)的值。上面的DragLayout 中直接返回了 left,就是說需要滑動(dòng)到哪里,child 這個(gè) View 就 滑動(dòng)到哪里。clampViewPositionVertical(View child, int top, int dy)
這個(gè)方法和public int clampViewPositionHorizontal(View child, int left, int dx);
是一樣的,只不過約束的是View 在豎直方向上的運(yùn)動(dòng)。
可以看到圖1中的方塊是可以拖拽并滑動(dòng)到屏幕邊緣并且超出屏幕邊緣的。假設(shè)需要讓圖中的方形塊不滑動(dòng)超出屏幕的邊緣,就需要在 clampViewPositionHorizontal
中動(dòng)手腳。
以下不超出屏幕邊緣的實(shí)現(xiàn)代碼參考了Android ViewDragHelper完全解析 自定義ViewGroup神器 和 Each Navigation Drawer Hides a ViewDragHelper 這兩篇文章。
不超出屏幕邊緣,意味著方塊的 x 坐標(biāo)>=paddingleft,方塊的 x 坐標(biāo)<=ViewGroup.getWidth()-paddingright-child.getWidth;
于是 clampViewPositionHorizontal
寫成:
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
Log.d(TAG, "left=" + left + "; dx=" + dx);
// 最小 x 坐標(biāo)值不能小于 leftBound
final int leftBound = getPaddingLeft();
// 最大 x 坐標(biāo)值不能大于 rightBound
final int rightBound = getWidth() - child.getWidth() - getPaddingRight();
final int newLeft = Math.min(Math.max(left, leftBound), rightBound);
return newLeft;
}
同樣,clampViewPositionVertical(View child, int top, int dy)
應(yīng)該寫成:
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
Log.d(TAG, "top=" + top + "; dy=" + dy);
// 最小 y 坐標(biāo)值不能小于 topBound
final int topBound = getPaddingTop();
// 最大 y 坐標(biāo)值不能大于 bottomBound
final int bottomBound = getHeight() - child.getHeight() - getPaddingBottom();
final int newTop = Math.min(Math.max(top, topBound), bottomBound);
return newTop;
}
效果如圖2所示,可以看到無法拖動(dòng)到超出屏幕邊緣,因?yàn)榫退?left 或者 top 的值已經(jīng)是負(fù)數(shù)的時(shí)候,就返回的是 leftBound 和 topBound;當(dāng) left 或者 top 的值已經(jīng)是大于屏幕寬度或者高度的時(shí)候,就返回的是 rightBound 和 bottomBound。
ViewDragHelper.Callback 中的方法的使用
ViewDragHelper.Callback 里面一共有 13 個(gè)方法。在上面只說了 3 個(gè),下面說一下其他的方法。
1. onViewReleased(View releasedChild, float xvel, float yvel)
這個(gè)方法在 View 釋放的時(shí)候調(diào)用,就是說這個(gè) View 已經(jīng)不再被拖拽的時(shí)候調(diào)用。View 已經(jīng)不再被拖拽的時(shí)候,該 View 可能并沒有停止滑動(dòng),xvel 和 yvel 表示的是此時(shí)該 View 在水平和豎直方向上的速度,單位是px/s。
在使用微信語音通話的時(shí)候,可以看到一個(gè)方形的懸浮框,這個(gè)懸浮框在可以拖動(dòng),并且當(dāng)你放手的時(shí)候,這個(gè)懸浮框就會(huì)自動(dòng)跑到屏幕邊緣。當(dāng)放手時(shí)候懸浮框的位置靠近左邊的時(shí)候就自動(dòng)跑到左邊緣,當(dāng)放手時(shí)候懸浮框的位置靠近右邊的時(shí)候就自動(dòng)跑到右邊緣。
這個(gè)效果使用 ViewDragHelper 也可以很好的實(shí)現(xiàn)。也只需要幾行代碼,主要也就是在 onViewReleased
里面進(jìn)行操作。
先看一下效果,如圖4所示:
代碼如下:
private int mCurrentTop;
private int mCurrentLeft;
ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
mDragOriLeft = child.getLeft();
mDragOriTop = child.getTop();
Log.d(TAG, "tryCaptureView, left=" + child.getLeft() + "; top=" + child.getTop());
return true;
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
Log.d(TAG, "left=" + left + "; dx=" + dx);
// 最小 x 坐標(biāo)值不能小于 leftBound
final int leftBound = getPaddingLeft();
// 最大 x 坐標(biāo)值不能大于 rightBound
final int rightBound = getWidth() - child.getWidth() - getPaddingRight();
final int newLeft = Math.min(Math.max(left, leftBound), rightBound);
mCurrentLeft = newLeft;
return newLeft;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
Log.d(TAG, "top=" + top + "; dy=" + dy);
// 最小 y 坐標(biāo)值不能小于 topBound
final int topBound = getPaddingTop();
// 最大 y 坐標(biāo)值不能大于 bottomBound
final int bottomBound = getHeight() - child.getHeight() - getPaddingBottom();
final int newTop = Math.min(Math.max(top, topBound), bottomBound);
mCurrentTop = newTop;
return newTop;
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
Log.d(TAG, "onViewReleased, xvel=" + xvel + "; yvel=" + yvel);
int childWidth = releasedChild.getWidth();
int parentWidth = getWidth();
int leftBound = getPaddingLeft();// 左邊緣
int rightBound = getWidth() - releasedChild.getWidth() - getPaddingRight();// 右邊緣
// 方塊的中點(diǎn)超過 ViewGroup 的中點(diǎn)時(shí),滑動(dòng)到左邊緣,否則滑動(dòng)到右邊緣
if ((childWidth / 2 + mCurrentLeft) < parentWidth / 2) {
mDragHelper.settleCapturedViewAt(leftBound, mCurrentTop);
} else {
mDragHelper.settleCapturedViewAt(rightBound, mCurrentTop);
}
invalidate();
}
};
mDragHelper = ViewDragHelper.create(this, callback);
增加了兩個(gè)參數(shù),分別是 mCurrentTop 和 mCurrentleft ,指代了當(dāng)前拖拽的 View 的當(dāng)前的水平和豎直方向的位置,分別在 clampViewPositionHorizontal
和 clampViewPositionVertical
里面對(duì)其賦值;然后在 onViewReleased
中,判斷松手時(shí)候的方塊的位置,方塊的中點(diǎn)超過 ViewGroup 的中點(diǎn)時(shí),滑動(dòng)到左邊緣,否則滑動(dòng)到右邊緣。通過 ViewDragHelper 的 settleCapturedViewAt
方法來將方塊 View 設(shè)定到某個(gè)位置。
這里需要注意的是,僅僅調(diào)用 settleCapturedViewAt
是不能達(dá)到目的的,還需要重寫一下 ViewGroup 的 computeScroll
方法。
@Override
public void computeScroll() {
super.computeScroll();
if (mDragHelper != null && mDragHelper.continueSettling(true)) {
invalidate();
}
}
2. onEdgeTouched(int edgeFlags, int pointerId)
、onEdgeDragStarted(int edgeFlags, int pointerId)
和 onEdgeLock(int edgeFlags)
這三個(gè)方法都與邊緣相關(guān),常見的側(cè)滑菜單和滑動(dòng)返回都可以利用這幾個(gè)方法實(shí)現(xiàn)。android有一個(gè)下拉菜單,就是從屏幕狀態(tài)欄上方往下拉,可以拉出一個(gè)菜單。這里利用 ViewDragHelper 的邊緣檢測的幾個(gè)方法來實(shí)現(xiàn)一個(gè)從屏幕下方網(wǎng)上拉而拉出菜單的例子。效果如下:
代碼也很短,只有100行:
public class BottomMenuLayout extends LinearLayout {
private static final String TAG = "BottomMenuLayout";
private ViewDragHelper mDragHelper;
private View mContent;
private View mBottomMenu;
public BottomMenuLayout(Context context) {
super(context, null);
init();
}
public BottomMenuLayout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs, 0);
init();
}
public BottomMenuLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setOrientation(VERTICAL);
ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(View child, int pointerId) {
return child == mBottomMenu;
}
@Override
public void onEdgeTouched(int edgeFlags, int pointerId) {
super.onEdgeTouched(edgeFlags, pointerId);
Log.d(TAG, "onEdgeTouched");
}
@Override
public boolean onEdgeLock(int edgeFlags) {
Log.d(TAG, "onEdgeLock");
return super.onEdgeLock(edgeFlags);
}
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
Log.d(TAG, "onEdgeDragStarted");
mDragHelper.captureChildView(mBottomMenu, pointerId);
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
return Math.max(getHeight() - child.getHeight(), top);
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
if (yvel <= 0) {
mDragHelper.settleCapturedViewAt(0,
getHeight() - releasedChild.getHeight());
} else {
mDragHelper.settleCapturedViewAt(0, getHeight());
}
invalidate();
}
};
mDragHelper = ViewDragHelper.create(this, callback);
// 觸發(fā)邊緣為下邊緣
mDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_BOTTOM);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 假設(shè)第一個(gè)子 view 是內(nèi)容區(qū)域,第二個(gè)是菜單
mContent = getChildAt(0);
mBottomMenu = getChildAt(1);
}
@Override
public void computeScroll() {
super.computeScroll();
if (mDragHelper != null && mDragHelper.continueSettling(true)) {
invalidate();
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (mBottomMenu != null && mContent != null) {
mBottomMenu.layout(0, getHeight(), mBottomMenu.getMeasuredWidth(),
getHeight() + mBottomMenu.getMeasuredHeight());
mContent.layout(0, 0, mContent.getMeasuredWidth(), mContent.getMeasuredHeight());
}
}
}
布局文件如下:
<?xml version="1.0" encoding="utf-8"?>
<com.testcollection.viewdrag.BottomMenuLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.enhao.testcollection.views.viewdrag.BottomMenuActivity">
<TextView
android:id="@+id/content_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:gravity="center"
android:text="內(nèi)容區(qū)域"/>
<TextView
android:id="@+id/menu_view"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="@color/colorAccent"
android:gravity="center"
android:alpha="0.4"
android:textColor="@android:color/black"
android:text="底部菜單區(qū)域"/>
</com.testcollection.viewdrag.BottomMenuLayout>
在tryCaptureView
中,捕捉到的是底部菜單的 View,內(nèi)容區(qū)域的 View 不需要捕捉:
@Override
public boolean tryCaptureView(View child, int pointerId) {
return child == mBottomMenu;
}
在onEdgeDragStarted
中,手動(dòng)捕獲底部菜單的 View,調(diào)用 ViewDragHelper 的 captureChildView
方法。onEdgeDragStarted
表示用戶開始從邊緣拖拽。而 onEdgeTouched
表示開始觸摸到 ViewGroup 的邊緣,此時(shí)并不一定開始有拖拽的動(dòng)作。
@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
Log.d(TAG, "onEdgeDragStarted");
mDragHelper.captureChildView(mBottomMenu, pointerId);
}
此處在 tryCaptureView
和 onEdgeDragStarted
中都捕獲了底部菜單的 mBottomMenu,是不是重復(fù)了?答案不是的,這兩個(gè)地方都要捕獲。可以試驗(yàn)一下,假設(shè) tryCaptureView
中直接返回 false,當(dāng)然這個(gè) mBottomMenu 還是能從底部邊緣滑出來,但是當(dāng)滑出來之后,就不能再滑動(dòng)回去了,因?yàn)榛鰜碇笤偻禄瑒?dòng),就不是執(zhí)行 onEdgeDragStarted
而是執(zhí)行 tryCaptureView
了,所以 tryCaptureView
要也要捕獲到 BottomMenu,即返回 child == mBottomMenu
才行。
在 clampViewPositionVertical
中,返回豎直方向上要到達(dá)的位置。
在 onViewReleased
中,判斷y方向的速速,如果<=0,即往上滑,就把菜單完全展現(xiàn)出來,如果往下滑動(dòng),就把菜單隱藏。利用 mDragHelper.settleCapturedViewAt
來設(shè)置菜單的位置。
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
if (yvel <= 0) {
mDragHelper.settleCapturedViewAt(0,
getHeight() - releasedChild.getHeight());
} else {
mDragHelper.settleCapturedViewAt(0, getHeight());
}
invalidate();
通過 mDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_BOTTOM);
來設(shè)置要監(jiān)測的邊緣拖拽。
還有一個(gè)方法 onEdgeLock(int edgeFlags)
沒有使用到,這個(gè)方法返回 true 會(huì)鎖住當(dāng)前的邊界。
3. getViewHorizontalDragRange(View child)
和 getViewVerticalDragRange(View child)
這兩個(gè)方法分別返回子 View 在水平和豎直方向可以被拖拽的范圍,返回值的單位是 px。
假設(shè)在前面的方塊(即TextView) 設(shè)置 android:clickable="true"
,則再運(yùn)行程序,會(huì)發(fā)現(xiàn)方塊拖不動(dòng)了,為什么呢?因?yàn)橛|摸事件被 TextView 消耗掉了。
這篇文章(Android自定義ViewGroup神器-ViewDragHelper)解釋的很清楚:
子View是可被點(diǎn)擊的,那么會(huì)觸發(fā)ViewGroup的onInterceptTouchEvent方法。默認(rèn)情況下,事件會(huì)被子View消耗掉,這顯然是有問題的,因?yàn)檫@樣ViewGroup的onTouch方法就不會(huì)被調(diào)用,而onTouch方法中正是我們的關(guān)鍵方法:dragHelper.processTouchEvent。
在 ViewDragHelper 的 shouldInterceptTouchEvent 的源碼中
public boolean shouldInterceptTouchEvent(MotionEvent ev) {
final int action = MotionEventCompat.getActionMasked(ev);
switch (action) {
case MotionEvent.ACTION_MOVE: {
final int pointerCount = ev.getPointerCount();
for (int i = 0; i < pointerCount; i++) {
final int horizontalDragRange = mCallback.getViewHorizontalDragRange(
toCapture);
final int verticalDragRange = mCallback.getViewVerticalDragRange(toCapture);
// 如果getViewHorizontalDragRange和getViewVerticalDragRange的返回值都為0,則break
if (horizontalDragRange == 0 && verticalDragRange == 0) {
break;
}
// tryCaptureViewForDrag方法中會(huì)設(shè)置mDragState=STATE_DRAGGING
if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
break;
}
}
break;
}
}
return mDragState == STATE_DRAGGING;
}
shouldInterceptTouchEvent
返回true的條件是 mDragState == STATE_DRAGGING
,然而 mDragState
是在 tryCaptureViewForDrag
方法中被設(shè)置為STATE_DRAGGING的。
所以,如果horizontalDragRange == 0 && verticalDragRange == 0
這個(gè)條件一直為true的話,tryCaptureViewForDrag
方法就得不到調(diào)用了。
而 horizontalDragRange
和 verticalDragRange
分別是 Callback 的 getViewHorizontalDragRange
和 getViewVerticalDragRange
方法返回的值,這兩個(gè)方法默認(rèn)情況下都返回 0。
重寫這兩個(gè)方法:
@Override
public int getViewHorizontalDragRange(View child) {
Log.d(TAG, "getViewHorizontalDragRange");
return getMeasuredWidth() - child.getMeasuredWidth();
}
@Override
public int getViewVerticalDragRange(View child) {
Log.d(TAG, "getViewVerticalDragRange");
return getMeasuredHeight() - child.getMeasuredHeight();
}
方塊(即TextView) 就能拖拽并且能響應(yīng)點(diǎn)擊事件了。
參考鏈接: