學習總結--View 的移動

ljuns發表於2017-01-24

在自定義 View 的時候經常會用到有關 View 的移動,用的比較多的估計是動畫,但是除了動畫還有沒有什麼方法可以實現相同的效果呢?有,而且還有好幾種方法,這裡總結一下目前所瞭解到的有關 View 的移動方法。在開始之前先來看張圖:

學習總結--View 的移動

這張圖結合了 Android 座標系和一些常用的 API(最大的矩形相當於手機螢幕;中間黃色的矩形是 ViewGroup,比如LinearLayout、RelativeLayout...;最中間的是具體的某個 View ,比如 Button、TextView...;小黑點表示觸控點),很直觀的闡明瞭各個 API 的含義,根據這張圖分別解釋一下各個方法:

  • View 提供的獲取座標的方法:
    getLeft():獲取到的是 View 自身的左邊到其父佈局左邊的距離;
    getTop():獲取到的是 View 自身的頂邊到其父佈局頂邊的距離;
    getRight():獲取到的是 View 自身的右邊到其父佈局左邊的距離;
    getBottom():獲取到的是 View 自身的底邊到其父佈局頂邊的距離。
  • MotionEvent 提供的獲取座標的方法:
    getX():獲取點選事件距離控制元件左邊的距離,即檢視座標;
    getY():獲取點選事件距離控制元件頂邊的距離,即檢視座標;
    getRawX():獲取點選事件距離整個螢幕左邊的距離,即絕對座標;
    getRawY():獲取點選事件距離整個螢幕頂邊的距離,即絕對座標。

有了上面這些知識點,對於接下來的計算就容易很多了。這次的總結分為以下方面:

  1. layout()
  2. offsetLeftAndRight() 與 offsetTopAndBottom()
  3. setLayoutParams()
  4. scrollTo() 與 scrollBy()
  5. Scroller
  6. 屬性動畫
  7. ViewDragHelper

layout()

如果對自定義 View 有一定的瞭解就會知道,在 View 的繪製過程中會呼叫 onLayout() 方法來設定 View 的位置。那麼同樣可以通過修改 View 的 left、top、right、bottom 四個屬性來控制 View 的位置。下面來看看用 layout() 怎麼實現 View 的移動:

  1. 首先是自定義一個 View ,然後重寫 onTouchEvent() 方法,在每次回撥 onTouchEvent() 方法的時候獲取觸控點的座標:
     // 相對位置
     int x = (int) event.getX();
     int y = (int) event.getY();複製程式碼
  2. 在 ACTION_DOWN 事件中記錄觸控點的座標:
     case MotionEvent.ACTION_DOWN:
         lastX = x;
         lastY = y;
         break;複製程式碼
  3. 在 ACTION_MOVE 事件中計算偏移量,然後在 View 當前的 left、top、right、bottom 上加上偏移量,最後將相加的結果設定到 layout() 方法中:

     case MotionEvent.ACTION_MOVE:
         // 計算偏移量
         int offSetX = x - lastX;
         int offSetY = y - lastY;
    
         // 在 View 當前的left、top、right、bottom 基礎上加上偏移量
         layout(getLeft() + offSetX,
             getTop() + offSetY,
             getRight() + offSetX,
             getBottom() + offSetY);
         break;複製程式碼

這裡有一點需要提示一下:layout() 方法的引數順序是 left、top、right、bottom。經過上面的三個步驟,每次移動後 View 都會呼叫 layout() 方法對自己重新佈局,從而達到移動 View 的效果。

學習總結--View 的移動

下面是 onTouchEvent() 方法完整的程式碼:

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        // 相對位置
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                // 計算偏移量
                int offSetX = x - lastX;
                int offSetY = y - lastY;

                // 在當前的left、top、right、bottom 基礎上加上偏移量
                layout(getLeft() + offSetX,
                        getTop() + offSetY,
                        getRight() + offSetX,
                        getBottom() + offSetY);
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }複製程式碼

在上面的程式碼中使用的是 getX()、getY() 方法來獲取觸控點的座標值,即使用相對位置。自定義 View 的佈局程式碼:

    <cn.ljuns.androidgrowing.practice.DragView
        android:id="@+id/dragView"
        android:layout_width="100dp"
        android:layout_height="100dp">
    </cn.ljuns.androidgrowing.practice.DragView>複製程式碼

那可不可以使用 getRawX() 和 getRawY() 方法即絕對位置呢?答案是肯定的,只需要在前面的基礎上修改一小部分程式碼就可以實現同樣的效果:

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        // 絕對位置
        int rawX = (int) event.getRawX();
        int rawY = (int) event.getRawY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_MOVE:
                // 計算偏移量
                int offSetX = rawX - lastX;
                int offSetY = rawY - lastY;

                // 在當前的left、top、right、bottom 基礎上加上偏移量
                layout(getLeft() + offSetX,
                        getTop() + offSetY,
                        getRight() + offSetX,
                        getBottom() + offSetY);

                // 重新設定座標
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }複製程式碼

主要修改的是:1、在第1步獲取觸控點座標的時候用 getRawX()和 getRawY() 代替 getX()和 getY() ;2、在第3步 ACTION_MOVE 事件中重新設定初始座標。至於為什麼要在最後設定初始座標,根據一開始的圖自己比劃比劃就懂了。

offsetLeftAndRight() 和 offsetTopAndBottom()

其實根據方法名字很容易猜到這兩個方法的意思:左右的偏移、上下的偏移。那該怎麼用呢?也很簡單,不管是使用相對位置還是絕對位置來計算偏移量,前面的第1步和第2步不變,只需要把 layout() 方法替換為 offsetLeftAndRight() 和 offsetTopAndBottom() 就可以了,即:

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        // 相對位置
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                // 計算偏移量
                int offSetX = x - lastX;
                int offSetY = y - lastY;

                // 同時對 left 和 right 進行偏移
                offsetLeftAndRight(offSetX);
                // 同時對 top 和 bottom 進行偏移
                offsetTopAndBottom(offSetY);
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }複製程式碼

這裡是用相對位置計算的偏移量,用絕對位置計算的便宜量也一樣可以實現相同的效果,只要記住:在最後需要重新設定初始座標。

setLayoutParams()

首先我們要知道 LayoutParams 中儲存了一個 View 的佈局引數,通過改變 LayoutParams 來動態修改一個佈局的位置引數也可以實現前面的效果。前面的第1、2步依然不變,只需修改第3步:

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        // 相對位置
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                // 計算偏移量
                int offSetX = x - lastX;
                int offSetY = y - lastY;

                /**
                 * LayoutParams 主要是通過修改 margin 來修改 view 的位置
                 */
                LinearLayout.LayoutParams params =
                        (LinearLayout.LayoutParams) getLayoutParams();
                params.leftMargin = getLeft() + offSetX;
                params.topMargin = getTop() + offSetY;
                setLayoutParams(params);
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }複製程式碼

scrollTo() 和 scrollBy()

在一個 View 中,系統提供了 scrollTo() 和 scrollBy() 兩種方法來改變一個 View 的位置。這兩個方法的區別是:scrollTo(x, y) 表示移動到一個具體的座標點(x, y);scrollBy(x, y) 表示移動的偏移量為 x、y。與前面幾種方式相同,只需修改第3步的關鍵方法就可以實現相同的效果:

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        // 相對位置
//        int x = (int) event.getX();
//        int y = (int) event.getY();

        // 絕對位置
        int rawX = (int) event.getRawX();
        int rawY = (int) event.getRawY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
//                lastX = x;
//                lastY = y;
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_MOVE:
                // 計算偏移量
//                int offSetX = x - lastX;
//                int offSetY = y - lastY;
                int offSetX = rawX - lastX;
                int offSetY = rawY - lastY;

               ((View)getParent()).scrollBy(-offSetX, -offSetY);
    //          ((View)getParent()).scrollTo(-offSetX, -offSetY);
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }複製程式碼

懵逼了吧?為毛是 ((View)getParent()).scrollBy(-offSetX, -offSetY) 而不是 scrollBy(offSetX, offSetY) ??為毛是 (-offSetX, -offSetY) ??
第一個問題:因為 scrollTo() 和 scrollBy() 方法移動的是 View 的 content,即移動的是 View 的內容。例如 TextView 的 content 就是它的文字,所以如果要移動某個 View ,那麼就要在 View 所在的 ViewGroup 中使用 scrollTo()、scrollBy() 方法。明白了第一個問題,第二個問題也就迎刃而解了:因為 scrollTo()、scrollBy() 方法作用在 ViewGroup 上,所以要往反方向移動才能實現我們需要的效果。

Scroller

首先來看個效果圖:

學習總結--View 的移動

當我滑鼠鬆開時,藍色的矩形會平滑的移動,這是怎麼做到的呢?
由於在 ACTION_MOVE 事件中不斷獲取手指移動的微小的偏移量,這樣就將一段距離劃分成了 N 個非常小的偏移量,在每個小的偏移量裡面通過呼叫 scrollTo() 方法進行了移動。因為人眼的視覺暫留特性,使得在整體上是一個平滑移動的效果。
這就是 Scroller ,接下來看看程式碼是怎麼實現的:

  • 建立 Scroller 物件
      // 初始化 Scroller
      mScroller = new Scroller(context);複製程式碼
  • 重寫 computeScroll() 方法
      /**
       * 核心方法,該方法是個空方法,實質是通過呼叫 scrollTo 實現移動
       */
      @Override
      public void computeScroll() {
          super.computeScroll();
          // 判斷 Scroller 是否執行完畢
          if (mScroller.computeScrollOffset()) {
              ((View)getParent()).scrollTo(
                      mScroller.getCurrX(),
                      mScroller.getCurrY());
              // 通過重繪來不斷呼叫 computeScroll()
              invalidate();
          }
      }複製程式碼
    computeScroll() 方法是使用Scroller 類的核心,系統在繪製 View 的時候會在 draw() 方法中呼叫該方法。Scroller 類提供了 computeScrollOffset() 方法來判斷是否完成了整個滑動,同時也提供了 getCurrX()、getCurrY() 方法來獲得當前的滑動座標。
  • 使用 startScroll() 開啟平滑移動

      View viewGroup = (View) getParent();
      // 啟動
      mScroller.startScroll(viewGroup.getScrollX(),
              viewGroup.getScrollY(),
              -viewGroup.getScrollX(),
              -viewGroup.getScrollY(),
              3000);
      invalidate();複製程式碼

    這裡給它設定了一個時長:3000,是平滑移動的時長,當然也可以省略。最重要的是 computeScroll() 方法不會自動呼叫,只能通過 invalidate() -> draw() -> computeScroll() 來間接呼叫 computeScroll() ,所以一定要在最後呼叫 invalidate() 方法。
    總的執行流程是這樣的:startScroll() -> invalidate() -> draw() -> computeScroll() -> invalidate() -> draw() -> computeScroll()... 就這樣一直迴圈下去直到結束。整個自定義 View 的完整程式碼:

      public class DragView extends View {
    
          private int lastX;
          private int lastY;
          private Scroller mScroller;
    
          public DragView6(Context context) {
              this(context, null);
          }
    
          public DragView6(Context context, AttributeSet attrs) {
              super(context, attrs);
              // 設定背景色
              setBackgroundColor(Color.BLUE);
              mScroller = new Scroller(context);
          }
    
          /**
          * 核心方法,該方法是個空方法,實質是通過呼叫 scrollTo 實現移動
           */
          @Override
          public void computeScroll() {
              super.computeScroll();
              // 判斷 Scroller 是否執行完畢
              if (mScroller.computeScrollOffset()) {
                  ((View)getParent()).scrollTo(
                          mScroller.getCurrX(),
                          mScroller.getCurrY());
                  // 通過重繪來不斷呼叫 computeScroll
                  postInvalidate();
              }
          }
    
          @Override
          public boolean onTouchEvent(MotionEvent event) {
    
              // 相對位置
              int x = (int) event.getX();
              int y = (int) event.getY();
    
              switch (event.getAction()) {
                  case MotionEvent.ACTION_DOWN:
                      lastX = x;
                      lastY = y;
                      break;
                  case MotionEvent.ACTION_MOVE:
                      // 計算偏移量
                      int offSetX = x - lastX;
                      int offSetY = y - lastY;
    
                      ((View)getParent()).scrollBy(-offSetX, -offSetY);
                      break;
                  case MotionEvent.ACTION_UP:
                      View viewGroup = (View) getParent();
                      // 啟動
                      mScroller.startScroll(viewGroup.getScrollX(),
                              viewGroup.getScrollY(),
                              -viewGroup.getScrollX(),
                              -viewGroup.getScrollY(),
                              3000);
                      invalidate();
                      break;
              }
              return true;
          }
      }複製程式碼

    屬性動畫

    之前寫了一篇屬性動畫的總結:學習總結--屬性動畫,用屬性動畫來實現 View 的移動會更簡單,先獲取到需要移動的 View ,然後給它設定動畫:

      //屬性動畫
      dragView = (DragView) findViewById(R.id.dragView);
      ObjectAnimator animator1 = ObjectAnimator.ofFloat(dragView,
                  "translationX", 0, 200);
      ObjectAnimator animator2 = ObjectAnimator.ofFloat(dragView,
                  "translationY", 0, 200);
      AnimatorSet set = new AnimatorSet();
      set.playTogether(animator1, animator2);
      set.setDuration(3000);
      set.start();複製程式碼

    這裡是屬性動畫組合,View 會從座標 (0, 0) 平滑移動到座標 (200, 200),持續時長是3000ms(也就是3秒)。

ViewDragHelper

在 support 庫中有 DrawerLayout 和 SlidingPaneLayout 兩個佈局可以實現側邊欄的滑動,而它們的核心就是 ViewDragHelper 類,通過 ViewDragHelper 基本可以實現各種不同的滑動、拖放的需求。依然是前面的效果:

  • 初始化 ViewDragHelper
      // 初始化
      mHelper = ViewDragHelper.create(this, callback);複製程式碼
    第一個引數是要監聽的 View,通常是一個 ViewGroup ;第二個引數是一個 Callback 回撥。
  • 攔截事件
    重寫 onInterceptTouchEvent() 和 onTouchEvent() 方法。如果不瞭解這兩個方法,得先去了解下 Android 的事件攔截機制。

      /**
       * 事件攔截
       */
      @Override
      public boolean onInterceptTouchEvent(MotionEvent ev) {
          return mHelper.shouldInterceptTouchEvent(ev);
      }
    
      /**
       * 事件處理
       */
      @Override
      public boolean onTouchEvent(MotionEvent event) {
          // 將觸控事件傳遞給 ViewDragHelper
          mHelper.processTouchEvent(event);
          return true;
      }複製程式碼
  • 重寫 computeScroll()
      @Override
      public void computeScroll() {
          if (mHelper.continueSettling(true)) {
              ViewCompat.postInvalidateOnAnimation(this);
          }
      }複製程式碼
    computeScroll() 方法在前面 Scroller 類的時候有提到過,ViewGroupHelper 內部也是通過 Scroller 來實現平滑移動的。
  • Callback 回撥

      private class HelperCallback extends ViewDragHelper.Callback{
    
          /**
           * 檢測觸控事件
           */
          @Override
          public boolean tryCaptureView(View child, int pointerId) {
              // 如果當前觸控的 View 是 mView 就開始檢測觸控事件
              return mView == child;
          }
    
          @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() 方法可以指定哪一個子 View 可以移動;clampViewPositionVertical() 和 clampViewPositionHorizontal() 分別對應垂直和水平方向上的滑動。它們的預設返回值是0,即不滑動。

  • 載入佈局
      @Override
      protected void onFinishInflate() {
          super.onFinishInflate();
          mView = getChildAt(0);
      }複製程式碼
    通過 getChildAt() 方法按順序來載入子 View。

其實 ViewDragHelper 還有更多更復雜的用法,可以實現更炫的效果,感興趣的可以自己去搜尋一下相關的文章。
上面一共總結了7種方法可以實現 View 的移動,這篇學習總結也就到這了。這是第二篇學習總結,接下來會繼續學習繼續總結。