Android自定義ViewGroup神器-ViewDragHelper

qsfe4757 8年前發布 | 7K 次閱讀 安卓開發 Android開發 移動開發

一、概述

ViewDragHelper is a utility class for writing custom ViewGroups. It offers a number of useful operations and state tracking for allowing a user to drag and reposition views within their parent ViewGroup.

這是官方的解釋:在自定義ViewGroup時,ViewDragHelper可以用來拖拽和設置子View的位置(在ViewGroup范圍內)。另外,還提供了一系列的方法和狀態跟蹤。

可見,在自定義ViewGroup時,ViewDragHelper一般用來處理子View的位置移動。

二、入門示例

demo1.gif

效果很簡單,屏幕中間有兩個TextView,位置隨著我們的手指不斷移動。

傳統方式實現:一般需要重寫 onInterceptTouchEvent 和 onTouchEvent 這兩個方法,寫好這兩個方法不是一件容易的事情,需要自己去處理:事件沖突、加速檢測等。

ViewDragHelper簡化了很多工作,讓我們更加關注“業務”的需求,實現步驟如下:

  1. 創建ViewDragHelper實例

  2. 處理ViewGroup的觸摸事件

  3. ViewDragHelper.Callback的編寫

(一) 自定義ViewGroup

public class VDHLinearLayout extends LinearLayout {
  ViewDragHelper dragHelper;

public VDHLinearLayout(Context context, AttributeSet attrs) { super(context, attrs); dragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() { @Override public boolean tryCaptureView(View child, int pointerId) { return true; }

      @Override
      public int clampViewPositionVertical(View child, int top, int dy) {
          return top;
      }

      @Override
      public int clampViewPositionHorizontal(View child, int left, int dx) {
          return left;
      }
  });

}

@Override public boolean onInterceptTouchEvent(MotionEvent ev) { return dragHelper.shouldInterceptTouchEvent(ev); }

@Override public boolean onTouchEvent(MotionEvent event) { dragHelper.processTouchEvent(event); return true; } }</code></pre>

VDHLinearLayout的代碼還是非常簡單的,主要是分為以下三個步驟:

  1. 創建ViewDragHelper實例

    dragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {});

    創建需要三個參數,第一個為當前的ViewGroup,第二個為 sensitivity ,主要用于設置 touchSlop :

    helper.mTouchSlop = (int) (helper.mTouchSlop * (1 / sensitivity));

    傳入越大, touchSlop 就越小。第三個參數就是ViewDragHelper.Callback,觸摸過程中會回調相關方法。

  2. 實現ViewDragHelper.Callback相關方法

    new ViewDragHelper.Callback() {
       @Override
       public boolean tryCaptureView(View child, int pointerId) {
           return true;
       }

    @Override public int clampViewPositionVertical(View child, int top, int dy) { return top; }

    @Override public int clampViewPositionHorizontal(View child, int left, int dx) { return left; } }</code></pre>

    • tryCaptureView:如果返回true表示捕獲相關View,你可以根據第一個參數child決定捕獲哪個View。
    • clampViewPositionVertical:計算child垂直方向的位置,top表示y軸坐標(相對于ViewGroup),默認返回0(如果不復寫該方法)。這里,你可以控制垂直方向可移動的范圍。
    • clampViewPositionHorizontal:與clampViewPositionVertical類似,只不過是控制水平方向的位置。

      比如效果圖中, “拖拽2” 明顯超過屏幕范圍了,你可以這樣控制:

      @Override
      public int clampViewPositionHorizontal(View child, int left, int dx) {
         if (left > getWidth() - child.getMeasuredWidth()) // 右側邊界
         {
             left = getWidth() - child.getMeasuredWidth();
         }
         else if (left < 0) // 左側邊界
         {
             left = 0;
         }
         return left;
      }
    </li>
  3. 處理ViewGroup觸摸事件

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
       return dragHelper.shouldInterceptTouchEvent(ev);
    }

    @Override public boolean onTouchEvent(MotionEvent event) { dragHelper.processTouchEvent(event); return true; }</code></pre>

    onInterceptTouchEvent 直接交給 dragHelper.shouldInterceptTouchEvent 去處理, onTouchEvent 通過 dragHelper.processTouchEvent 來處理。

    如果你希望拖拽的子View是不可點擊的,可以不重寫onInterceptTouchEvent方法,后面我們會介紹為什么。

    </li> </ol>

    (二) 布局文件

    <?xml version="1.0" encoding="utf-8"?>
    <android.drag.viewdraghelperdemo.VDHLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/activity_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:orientation="vertical">
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="20dp"
            android:background="@color/colorPrimaryDark"
            android:textColor="@android:color/white"
            android:text="拖拽1"/>
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="20dp"
            android:layout_marginTop="10dp"
            android:background="@color/colorPrimaryDark"
            android:textColor="@android:color/white"
            android:text="拖拽2"/>
    </android.drag.viewdraghelperdemo.VDHLinearLayout>

    布局很簡單,自定義的ViewGroup包含兩個TextView。

    三、更多用法

    ViewDragHelper不僅僅能夠讓子View跟隨我們的手指移動,還能實現以下功能:

    • 邊界觸摸檢測

    • Drag釋放回調

    • 移動到某個指定位置

    我么改造下上面的例子,效果圖如下:

    demo2.gif

    第一個View,可以隨意被拖動位置

    第二個View,只能從ViewGroup左側拖動

    第三個View,拖動釋放之后會回到原始位置

    修改后的ViewGroup代碼如下:

    public class VDHLinearLayout extends LinearLayout {
      ViewDragHelper dragHelper;

    public VDHLinearLayout(Context context, AttributeSet attrs) { super(context, attrs); dragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() { @Override public boolean tryCaptureView(View child, int pointerId) { return child == dragView || child == autoBackView; }

          @Override
          public int clampViewPositionVertical(View child, int top, int dy) {
              return top;
          }
    
          @Override
          public int clampViewPositionHorizontal(View child, int left, int dx) {
              return left;
          }
    
          // 當前被捕獲的View釋放之后回調
          @Override
          public void onViewReleased(View releasedChild, float xvel, float yvel) {
              if (releasedChild == autoBackView)
              {
                  dragHelper.settleCapturedViewAt(autoBackViewOriginLeft, autoBackViewOriginTop);
                  invalidate();
              }
          }
    
          @Override
          public void onEdgeDragStarted(int edgeFlags, int pointerId) {
              dragHelper.captureChildView(edgeDragView, pointerId);
          }
      });
      // 設置左邊緣可以被Drag
      dragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);
    

    }

    @Override public boolean onInterceptTouchEvent(MotionEvent ev) { return dragHelper.shouldInterceptTouchEvent(ev); }

    @Override public boolean onTouchEvent(MotionEvent event) { dragHelper.processTouchEvent(event); return true; }

    @Override public void computeScroll() { if (dragHelper.continueSettling(true)) { invalidate(); } }

    View dragView; View edgeDragView; View autoBackView; @Override protected void onFinishInflate() { super.onFinishInflate(); dragView = findViewById(R.id.dragView); edgeDragView = findViewById(R.id.edgeDragView); autoBackView = findViewById(R.id.autoBackView); }

    int autoBackViewOriginLeft; int autoBackViewOriginTop; @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); autoBackViewOriginLeft = autoBackView.getLeft(); autoBackViewOriginTop = autoBackView.getTop(); } }</code></pre>

    1. tryCaptureView 方法,我們只捕獲第一個和第三個View,分別是 dragView 和 autoBackView 。

    2. 使用 dragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT) 設置ViewGroup左邊緣可以被拖拽,同時在ViewDragHelper.Callback的 onEdgeDragStarted 方法中,使用 dragHelper.captureChildView 主動去捕獲第二個View: edgeDragView 。

      雖然在 tryCaptureView 方法中我們并未捕獲 edgeDragView ,但 dragHelper.captureChildView 可以繞過該方法,詳見官方解釋:

      Capture a specific child view for dragging within the parent. The callback will be notified but {@link Callback#tryCaptureView(android.view.View, int)} will not be asked permission to capture this view.

    3. onViewReleased 方法會在被捕獲的子View釋放之后調用,我們判斷釋放的View: releasedChild 是 autoBackView ,使用 dragHelper.settleCapturedViewAt 方法設置 autoBackView 的位置為它的初始位置。

      注意,此方法內部是通過 Scroller 實現的,所以我們需要使用 invalidate 來刷新,同時需要重寫 computeScroll 方法:

      @Override
      public void computeScroll() {
         if (dragHelper.continueSettling(true))
         {
            invalidate();
         }
      }

      dragHelper.continueSettling 方法是用來判斷當前被捕獲的子View是否還需要繼續移動,類似 Scroller 的 computeScrollOffset 方法一樣,我們需要在返回true的時候使用 invalidate 刷新。

    至此,我么已經介紹了ViewDragHelper以及ViewDragHelper.Callback的多數用法。

    還記得前面我們留下的一個問題嗎?

    “如果你希望拖拽的子View是不可點擊的,可以不重寫onInterceptTouchEvent方法,后面我們會介紹為什么。”

    我們嘗試將TextView設置成 clickable=true ,你會發現原本可以被拖拽的View都不動了。我們思考下,這是為什么呢?

    原因在于:

    由于子View是可被點擊的,那么會觸發ViewGroup的 onInterceptTouchEvent 方法。默認情況下,事件會被子View消耗掉,這顯然是有問題的,因為這樣ViewGroup的 onTouch 方法就不會被調用,而 onTouch 方法中正是我們的關鍵方法: dragHelper.processTouchEvent 。

    既然我們找到原因了,有人說:你不能在 onInterceptTouchEvent 直接返回true嗎?為啥還要用 dragHelper.shouldInterceptTouchEvent(ev) 的返回值啊???

    確實,如果你直接返回true,會發現一切都能正常工作了。

    這里我們需要解釋下:

    打個比方,如果你的ViewGroup中有另外一個Button(或者任何可點擊的View),但是它不在ViewDragHelper的處理范圍內,你可能需要監聽它的 onClick 事件,如果直接返回true,你會發現 onClick 事件不會被觸發了。

    納尼,為啥呢?因為ViewGroup攔截了它的事件了啊。。。好吧,我們還是老實這樣寫吧:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return dragHelper.shouldInterceptTouchEvent(ev);
    }

    你迫不及待的運行修改之后的代碼。咦?為啥還是不能拖拽。。。

    此時,遇到這種情況,我一般是查看下 dragHelper.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;
    

    }</code></pre>

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

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

    而 horizontalDragRange 和 verticalDragRange 分別是Callback的 getViewHorizontalDragRange 和 getViewVerticalDragRange 方法返回的值,這兩個方法默認情況下都返回0。

    • getViewHorizontalDragRange,返回子View水平方向可以被拖拽的范圍
    • getViewVerticalDragRange,返回子View垂直方向可以被拖拽的范圍

    我們嘗試重寫這兩個方法:

    @Override
    public int getViewVerticalDragRange(View child) {
       return getMeasuredHeight() - child.getMeasuredHeight();
    }

    @Override public int getViewHorizontalDragRange(View child) { return getMeasuredWidth() - child.getMeasuredWidth(); }</code></pre>

    再次運行下,你會發現TextView設置 clickable=true 之后也可以被拖拽了。

    至此,ViewDragHelper的基本使用方式我們已經介紹完了。詳細的代碼可以查看文章最后的源碼,另外,源碼中還實現了一個比較常用的效果:

    demo3.gif

     

     

     

    來自:http://www.jianshu.com/p/111a7bc76a0e

     

 本文由用戶 qsfe4757 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!