ViewDragHelper 的基本使用(一)

weixin_33860722發表於2018-02-25

ViewDragHelper 的使用和分析

使用方法

一個簡單的例子

假設要實現一個可以對內部的 view 進行自由拖拽的 ViewGroup,效果如圖:

2060588-35204813dfe07cb7.gif
圖.1 可隨手拖拽的view

可以重寫 onTouchEvent(MotionEvent event) 方法,對 MotionEvent 進行判斷和處理,從而實現拖拽的效果。但是使用 ViewDragHelper 可以很方便的實現。只要寫很少的程式碼,如下:

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>

即可實現圖中的效果。該ViewGroup中的任何子view都有隨手指頭拖拽效果。

在上面的 DragLayout 中,基本上只做了三件事:

  1. 建立 ViewDragHelper 的例項;
  2. onInterceptTouchEvent(MotionEvent ev) 傳遞給 ViewDragHelper 的 shouldInterceptTouchEvent(ev)
  3. onTouchEvent(MotionEvent event) 傳遞給 ViewDragHelper 的 processTouchEvent(event);

先說一下第一條,如何建立一 個ViewDragHelper 的例項。

建立 ViewdragHelper

ViewDragHelper 提供了兩個 create() 方法來建立例項,分別傳入兩個和三個引數:

/**
 *工廠方法建立新的 ViewDragHelper 的例項.
 *
 * @param forParent 與 ViewDragHelper 相關聯的父 ViewGroup
 * @param 滑動和拖拽的事件的回撥
 * @return 新的 ViewDragHelper 的例項
 */
public static ViewDragHelper create(ViewGroup forParent, Callback cb) {
    return new ViewDragHelper(forParent.getContext(), forParent, cb);
}

/**
 * 工廠方法建立新的 ViewDragHelper 的例項.
 *
 * @param 與 ViewDragHelper 相關聯的父 ViewGroup
 * @param 靈敏度,越大越靈敏,1.0f是正常值
 * @param 滑動和拖拽的事件的回撥
 * @return 新的 ViewDragHelper 的例項
 */
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;
}

第二個方法比第一個方法多了一個靈敏度的引數。
先使用第一個方法建立例項,需要傳入兩個引數。

第一個引數是 ViewGroup,拖拽事件就是發生在這個 ViewGroup 裡面的子 View 上。

第二個引數是一個 callback;這個回撥用來指示拖拽時的各種狀態和事件的變化,回撥中的方法有很多,一共13個。先看幾個相對常用和重要的,其餘的放到後面再看。

  1. public abstract boolean tryCaptureView(View child, int pointerId);
    這是唯一的一個抽象方法,需要自己實現的。返回值表示是否捕捉這個 view 的拖拽事件。這個方法會呼叫多次,哪怕這個 view 已經被捕捉過了,在下一次開始拖拽的時候,還是會回撥這個方法。如果只想對 ViewGroup 內的特定的 view 進行拖拽的處理,只需要返回類似於 child == mDragView 這樣的形式就行了。

  2. public int clampViewPositionHorizontal(View child, int left, int dx); 這個方法約束了 View 在水平方向上的運動。該方法預設是返回0的,所以一般都是需要重寫的。這個方法有三個引數:第一個 View 自然就是拖動的 View;第二個引數 left,指的是拖動的 View 理論上將要滑動到的水平方向上的值;第三個引數 dx 可以理解為滑動的速度,單位是 px 每秒。返回值是水平方向上的實際的x座標的值。上面的DragLayout 中直接返回了 left,就是說需要滑動到哪裡,child 這個 View 就 滑動到哪裡。

  3. clampViewPositionVertical(View child, int top, int dy) 這個方法和 public int clampViewPositionHorizontal(View child, int left, int dx); 是一樣的,只不過約束的是View 在豎直方向上的運動。

可以看到圖1中的方塊是可以拖拽並滑動到螢幕邊緣並且超出螢幕邊緣的。假設需要讓圖中的方形塊不滑動超出螢幕的邊緣,就需要在 clampViewPositionHorizontal 中動手腳。

以下不超出螢幕邊緣的實現程式碼參考了Android ViewDragHelper完全解析 自定義ViewGroup神器Each Navigation Drawer Hides a ViewDragHelper 這兩篇文章。

不超出螢幕邊緣,意味著方塊的 x 座標>=paddingleft,方塊的 x 座標<=ViewGroup.getWidth()-paddingright-child.getWidth;
於是 clampViewPositionHorizontal 寫成:

@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
    Log.d(TAG, "left=" + left + "; dx=" + dx);
    // 最小 x 座標值不能小於 leftBound
    final int leftBound = getPaddingLeft();
    // 最大 x 座標值不能大於 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) 應該寫成:

@Override
public int clampViewPositionVertical(View child, int top, int dy) {
    Log.d(TAG, "top=" + top + "; dy=" + dy);
    // 最小 y 座標值不能小於 topBound
    final int topBound = getPaddingTop();
    // 最大 y 座標值不能大於 bottomBound
    final int bottomBound = getHeight() - child.getHeight() - getPaddingBottom();
    final int newTop = Math.min(Math.max(top, topBound), bottomBound);
    return newTop;
}

效果如圖2所示,可以看到無法拖動到超出螢幕邊緣,因為就算 left 或者 top 的值已經是負數的時候,就返回的是 leftBound 和 topBound;當 left 或者 top 的值已經是大於螢幕寬度或者高度的時候,就返回的是 rightBound 和 bottomBound。

2060588-2506917b51f06f4b.gif
圖.2 方塊不能滑動超過螢幕邊緣

ViewDragHelper.Callback 中的方法的使用

ViewDragHelper.Callback 裡面一共有 13 個方法。在上面只說了 3 個,下面說一下其他的方法。

1. onViewReleased(View releasedChild, float xvel, float yvel)

這個方法在 View 釋放的時候呼叫,就是說這個 View 已經不再被拖拽的時候呼叫。View 已經不再被拖拽的時候,該 View 可能並沒有停止滑動,xvel 和 yvel 表示的是此時該 View 在水平和豎直方向上的速度,單位是px/s。

在使用微信語音通話的時候,可以看到一個方形的懸浮框,這個懸浮框在可以拖動,並且當你放手的時候,這個懸浮框就會自動跑到螢幕邊緣。當放手時候懸浮框的位置靠近左邊的時候就自動跑到左邊緣,當放手時候懸浮框的位置靠近右邊的時候就自動跑到右邊緣。

2060588-474ecfcb7fb39648.gif
圖.4 微信語音懸浮框自動貼邊

這個效果使用 ViewDragHelper 也可以很好的實現。也只需要幾行程式碼,主要也就是在 onViewReleased 裡面進行操作。
先看一下效果,如圖4所示:

2060588-f4a5d7c86cdeb0b8.gif
圖.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 座標值不能小於 leftBound
        final int leftBound = getPaddingLeft();
        // 最大 x 座標值不能大於 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 座標值不能小於 topBound
        final int topBound = getPaddingTop();
        // 最大 y 座標值不能大於 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();// 右邊緣
        // 方塊的中點超過 ViewGroup 的中點時,滑動到左邊緣,否則滑動到右邊緣
        if ((childWidth / 2 + mCurrentLeft) < parentWidth / 2) {
            mDragHelper.settleCapturedViewAt(leftBound, mCurrentTop);
        } else {
            mDragHelper.settleCapturedViewAt(rightBound, mCurrentTop);
        }
        invalidate();
    }
};
mDragHelper = ViewDragHelper.create(this, callback);

增加了兩個引數,分別是 mCurrentTop 和 mCurrentleft ,指代了當前拖拽的 View 的當前的水平和豎直方向的位置,分別在 clampViewPositionHorizontalclampViewPositionVertical 裡面對其賦值;然後在 onViewReleased 中,判斷鬆手時候的方塊的位置,方塊的中點超過 ViewGroup 的中點時,滑動到左邊緣,否則滑動到右邊緣。通過 ViewDragHelper 的 settleCapturedViewAt 方法來將方塊 View 設定到某個位置。

這裡需要注意的是,僅僅呼叫 settleCapturedViewAt 是不能達到目的的,還需要重寫一下 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)

這三個方法都與邊緣相關,常見的側滑選單和滑動返回都可以利用這幾個方法實現。android有一個下拉選單,就是從螢幕狀態列上方往下拉,可以拉出一個選單。這裡利用 ViewDragHelper 的邊緣檢測的幾個方法來實現一個從螢幕下方網上拉而拉出選單的例子。效果如下:

2060588-d678cf20a5716cef.gif
底部上拉選單

程式碼也很短,只有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);
        // 觸發邊緣為下邊緣
        mDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_BOTTOM);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // 假設第一個子 view 是內容區域,第二個是選單
        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="內容區域"/>

    <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="底部選單區域"/>

</com.testcollection.viewdrag.BottomMenuLayout>

tryCaptureView中,捕捉到的是底部選單的 View,內容區域的 View 不需要捕捉:

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

onEdgeDragStarted中,手動捕獲底部選單的 View,呼叫 ViewDragHelper 的 captureChildView 方法。onEdgeDragStarted 表示使用者開始從邊緣拖拽。而 onEdgeTouched 表示開始觸控到 ViewGroup 的邊緣,此時並不一定開始有拖拽的動作。

@Override
public void onEdgeDragStarted(int edgeFlags, int pointerId) {
    Log.d(TAG, "onEdgeDragStarted");
    mDragHelper.captureChildView(mBottomMenu, pointerId);
}

此處在 tryCaptureViewonEdgeDragStarted 中都捕獲了底部選單的 mBottomMenu,是不是重複了?答案不是的,這兩個地方都要捕獲。可以試驗一下,假設 tryCaptureView 中直接返回 false,當然這個 mBottomMenu 還是能從底部邊緣滑出來,但是當滑出來之後,就不能再滑動回去了,因為滑出來之後再往下滑動,就不是執行 onEdgeDragStarted 而是執行 tryCaptureView 了,所以 tryCaptureView 要也要捕獲到 BottomMenu,即返回 child == mBottomMenu 才行。

clampViewPositionVertical 中,返回豎直方向上要到達的位置。
onViewReleased 中,判斷y方向的速速,如果<=0,即往上滑,就把選單完全展現出來,如果往下滑動,就把選單隱藏。利用 mDragHelper.settleCapturedViewAt 來設定選單的位置。

@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); 來設定要監測的邊緣拖拽。

還有一個方法 onEdgeLock(int edgeFlags) 沒有使用到,這個方法返回 true 會鎖住當前的邊界。

3. getViewHorizontalDragRange(View child)getViewVerticalDragRange(View child)

這兩個方法分別返回子 View 在水平和豎直方向可以被拖拽的範圍,返回值的單位是 px。
假設在前面的方塊(即TextView) 設定 android:clickable="true",則再執行程式,會發現方塊拖不動了,為什麼呢?因為觸控事件被 TextView 消耗掉了。

這篇文章(Android自定義ViewGroup神器-ViewDragHelper)解釋的很清楚:

子View是可被點選的,那麼會觸發ViewGroup的onInterceptTouchEvent方法。預設情況下,事件會被子View消耗掉,這顯然是有問題的,因為這樣ViewGroup的onTouch方法就不會被呼叫,而onTouch方法中正是我們的關鍵方法: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方法中會設定mDragState=STATE_DRAGGING
                if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                    break;
                }
            }
            break;
        }
    }
    return mDragState == STATE_DRAGGING;
}

shouldInterceptTouchEvent 返回true的條件是 mDragState == STATE_DRAGGING,然而 mDragState 是在 tryCaptureViewForDrag 方法中被設定為STATE_DRAGGING的。

所以,如果horizontalDragRange == 0 && verticalDragRange == 0 這個條件一直為true的話,tryCaptureViewForDrag 方法就得不到呼叫了。

horizontalDragRangeverticalDragRange 分別是 Callback 的 getViewHorizontalDragRangegetViewVerticalDragRange 方法返回的值,這兩個方法預設情況下都返回 0。

重寫這兩個方法:

@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) 就能拖拽並且能響應點選事件了。

參考連結:

  1. Android自定義ViewGroup神器-ViewDragHelper

  2. Android ViewDragHelper完全解析 自定義ViewGroup神器

  3. Each Navigation Drawer Hides a ViewDragHelper

  4. 神奇的 ViewDragHelper,讓你輕鬆定製擁有拖拽能力的 ViewGroup

相關文章