Android自定義ViewGroup神器-ViewDragHelper

一、概述

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時(shí),ViewDragHelper可以用來(lái)拖拽和設(shè)置子View的位置(在ViewGroup范圍內(nèi))。另外,還提供了一系列的方法和狀態(tài)跟蹤。

可見,在自定義ViewGroup時(shí),ViewDragHelper一般用來(lái)處理子View的位置移動(dòng)。

二、入門示例

demo1.gif

效果很簡(jiǎn)單,屏幕中間有兩個(gè)TextView,位置隨著我們的手指不斷移動(dòng)。

傳統(tǒng)方式實(shí)現(xiàn):一般需要重寫onInterceptTouchEventonTouchEvent這兩個(gè)方法,寫好這兩個(gè)方法不是一件容易的事情,需要自己去處理:事件沖突、加速檢測(cè)等。

ViewDragHelper簡(jiǎn)化了很多工作,讓我們更加關(guān)注“業(yè)務(wù)”的需求,實(shí)現(xiàn)步驟如下:

  1. 創(chuàng)建ViewDragHelper實(shí)例
  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;
  }
}

VDHLinearLayout的代碼還是非常簡(jiǎn)單的,主要是分為以下三個(gè)步驟:

  1. 創(chuàng)建ViewDragHelper實(shí)例

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

創(chuàng)建需要三個(gè)參數(shù),第一個(gè)為當(dāng)前的ViewGroup,第二個(gè)為sensitivity,主要用于設(shè)置touchSlop

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

傳入越大,touchSlop就越小。第三個(gè)參數(shù)就是ViewDragHelper.Callback,觸摸過(guò)程中會(huì)回調(diào)相關(guān)方法。

  1. 實(shí)現(xiàn)ViewDragHelper.Callback相關(guān)方法

    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;
       }
    }
    
  • tryCaptureView:如果返回true表示捕獲相關(guān)View,你可以根據(jù)第一個(gè)參數(shù)child決定捕獲哪個(gè)View。
  • clampViewPositionVertical:計(jì)算child垂直方向的位置,top表示y軸坐標(biāo)(相對(duì)于ViewGroup),默認(rèn)返回0(如果不復(fù)寫該方法)。這里,你可以控制垂直方向可移動(dòng)的范圍。
  • clampViewPositionHorizontal:與clampViewPositionVertical類似,只不過(guò)是控制水平方向的位置。

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

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

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

onInterceptTouchEvent直接交給dragHelper.shouldInterceptTouchEvent去處理,onTouchEvent通過(guò)dragHelper.processTouchEvent來(lái)處理。

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

(二) 布局文件

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

布局很簡(jiǎn)單,自定義的ViewGroup包含兩個(gè)TextView。

三、更多用法

ViewDragHelper不僅僅能夠讓子View跟隨我們的手指移動(dòng),還能實(shí)現(xiàn)以下功能:

  • 邊界觸摸檢測(cè)
  • Drag釋放回調(diào)
  • 移動(dòng)到某個(gè)指定位置

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

demo2.gif

第一個(gè)View,可以隨意被拖動(dòng)位置
第二個(gè)View,只能從ViewGroup左側(cè)拖動(dòng)
第三個(gè)View,拖動(dòng)釋放之后會(huì)回到原始位置

修改后的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;
          }

          // 當(dāng)前被捕獲的View釋放之后回調(diào)
          @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);
          }
      });
      // 設(shè)置左邊緣可以被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();
  }
}
  1. tryCaptureView方法,我們只捕獲第一個(gè)和第三個(gè)View,分別是dragViewautoBackView。

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

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

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.

  1. onViewReleased方法會(huì)在被捕獲的子View釋放之后調(diào)用,我們判斷釋放的View:releasedChildautoBackView,使用dragHelper.settleCapturedViewAt方法設(shè)置autoBackView的位置為它的初始位置。

注意,此方法內(nèi)部是通過(guò)Scroller實(shí)現(xiàn)的,所以我們需要使用invalidate來(lái)刷新,同時(shí)需要重寫computeScroll方法:

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

dragHelper.continueSettling方法是用來(lái)判斷當(dāng)前被捕獲的子View是否還需要繼續(xù)移動(dòng),類似ScrollercomputeScrollOffset方法一樣,我們需要在返回true的時(shí)候使用invalidate刷新。


至此,我么已經(jīng)介紹了ViewDragHelper以及ViewDragHelper.Callback的多數(shù)用法。

還記得前面我們留下的一個(gè)問(wèn)題嗎?

“如果你希望拖拽的子View是不可點(diǎn)擊的,可以不重寫onInterceptTouchEvent方法,后面我們會(huì)介紹為什么?!?/p>

我們嘗試將TextView設(shè)置成clickable=true,你會(huì)發(fā)現(xiàn)原本可以被拖拽的View都不動(dòng)了。我們思考下,這是為什么呢?

原因在于:

由于子View是可被點(diǎn)擊的,那么會(huì)觸發(fā)ViewGroup的onInterceptTouchEvent方法。默認(rèn)情況下,事件會(huì)被子View消耗掉,這顯然是有問(wèn)題的,因?yàn)檫@樣ViewGroup的onTouch方法就不會(huì)被調(diào)用,而onTouch方法中正是我們的關(guān)鍵方法:dragHelper.processTouchEvent。

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

確實(shí),如果你直接返回true,會(huì)發(fā)現(xiàn)一切都能正常工作了。

這里我們需要解釋下:

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

納尼,為啥呢?因?yàn)閂iewGroup攔截了它的事件了啊。。。好吧,我們還是老實(shí)這樣寫吧:

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

你迫不及待的運(yùn)行修改之后的代碼。咦?為啥還是不能拖拽。。。
此時(shí),遇到這種情況,我一般是查看下dragHelper.shouldInterceptTouchEvent的源碼(此處省略了部分不相關(guān)的代碼):

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)用了。

horizontalDragRangeverticalDragRange分別是Callback的getViewHorizontalDragRangegetViewVerticalDragRange方法返回的值,這兩個(gè)方法默認(rèn)情況下都返回0。

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

我們嘗試重寫這兩個(gè)方法:

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

@Override
public int getViewHorizontalDragRange(View child) {
   return getMeasuredWidth() - child.getMeasuredWidth();
}

再次運(yùn)行下,你會(huì)發(fā)現(xiàn)TextView設(shè)置clickable=true之后也可以被拖拽了。


至此,ViewDragHelper的基本使用方式我們已經(jīng)介紹完了。詳細(xì)的代碼可以查看文章最后的源碼,另外,源碼中還實(shí)現(xiàn)了一個(gè)比較常用的效果:

demo3.gif

本文源碼

如果你喜歡我的文章,動(dòng)動(dòng)小手,關(guān)注我的個(gè)人簡(jiǎn)書吧~

也可以保存zhuhf.tech這個(gè)網(wǎng)址,它會(huì)自動(dòng)跳轉(zhuǎn)到我的簡(jiǎn)書個(gè)人主頁(yè)哦~

每周給自己定一個(gè)小的目標(biāo),加油~

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容