閱讀徐宜生《Android羣英傳》的筆記——第5章 Android Scroll 分析

5.1 滑動效果是如何產生的

滑動一個 View,本質上來說就是移動一個 View。改變其當前所處的位置,它的原理與動畫效果的實現非常相似,都是通過不斷地改變 View 的座標來實現這一效果。所以,要實現 View 的滑動,就必須監聽用戶觸摸的事件,並根據事件傳入的座標,動態且不斷地改變 View 的座標,從而實現 View 跟隨用戶觸摸的滑動而滑動。
在講解如何實現滑動效果之前,需要先來了解一些 Android 中的窗口座標體系和屏幕的觸控事件 —— MotionEvent。

5.1.1 Android 座標系

在物理學中,要描述一個物體的運動,就必須選定一個參考系。所謂滑動,正是相對於參考系的運動。在 Android 中,將屏幕最左上角的頂點作爲 Android 座標系的原點,從這個點向右是 X 軸正方向,從這個點向下是 Y 軸正方向,如下圖所示:

Android 座標系
系統提供了 getLocationOnScreen(int location[]) 這樣的方法來獲取 Android 座標系中點的位置,即該試圖左上角在 Androd 座標系中的座標。另外,在觸控事件中使用 getRawX(),getRawY() 方法所獲得的座標同樣是 Android 座標系中的座標。

5.1.2 視圖座標系

Android 中除了上面所說的這種座標系之外,還有一個視圖座標系,它描述了子視圖在父視圖中的位置關係。這兩種座標系並不矛盾也不復雜,他們的作用是相輔相成的。與 Android 座標系類似 ,視圖座標系同樣是以原點向右爲 X 軸正方向,以原點向下爲 Y 軸正方向,只不過在視圖座標系中,原點不再是 Android 座標系中的屏幕最左上角,而是以父視圖左上角爲座標原點,如下圖所示:

視圖座標系

在觸控事件中,通過 getX()、getY() 所獲得的座標就是視圖座標系中的座標。

5.1.3 觸控事件 —— MotionEvent

觸控事件 MotionEvent 在用戶交互中,佔着舉足輕重的地位,學好觸控事件是掌握後續內容的基礎。首先,來看看 MotionEvent 中封裝的一些常用的事件常量,它定義了觸控事件的不同類型。

// 單點觸摸按下動作
public static final int ACTION_DOWN = 0;
// 單點觸離開下動作
public static final int ACTION_UP = 1;
// 觸摸點移動動作
public static final int ACTION_MOVE = 2;
// 觸摸動作取消
public static final int ACTION_CANCEL = 3;
// 觸摸動作超出邊界
public static final int ACTION_OUTSIDE = 4;
// 多點觸摸按下動作
public static final int ACTION_POINTER_DOWN = 5;
// 多點離開動作
public static final int ACTION_POINTER_UP = 6;

通常情況下,我們會在 onTouchEvent(MotionEvent event) 方法中通過 event.getAction() 方法來獲取觸控事件的類型,並使用 switch-case 方法來進行篩選,這個代碼的模式基本固定,如下所示:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // 獲取當前輸入點的 X、Y 座標(視圖座標)

    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 處理輸入的按下事件

            break;
        case MotionEvent.ACTION_MOVE:
            // 處理輸入的移動事件

            break;
        case MotionEvent.ACTION_UP:
            // 處理輸入的離開事件

            break;
    }
    return true;
}

在不涉及多點操作的情況下,通常可以使用以上代碼來完成觸控事件的監聽,不過這裏只是一個代碼模板,後面我們會在觸控事件中完成具體的邏輯。
在 Android 中,系統提供了非常多的方法來獲取座標值、相對距離等。方法豐富固然好,但也給初學者帶來了很多困惑,不知道在什麼情況下使用什麼方法,下面總結了一些 API,結合 Android 座標系來看看該如何使用它們,如下圖所示:
在這裏插入圖片描述
這些方法可以分成如下兩個類別:

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

5.2 實現滑動的七種方法

當了解了 Android 座標系和觸控事件後,我們再來看看如何使用系統提供的 API 來實現動態地修改一個 View 的座標,即實現滑動效果。而不管採用哪一種方式,其實現的思想基本是一致的,當觸摸 View 時,系統記下當前觸摸點座標;當手指移動時,系統記下移動後的觸摸點座標,從而獲取到相對於前一次座標點的偏移量,並通過偏移量來修改 View 的座標,這也不斷重複,從而實現滑動過程。
下面我們就通過一個實例,來看看在 Android 中該如何實現滑動效果。定義一個 View,並置於一個 LinearLayout 中,實現一個簡單的佈局,代碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.example.test.DragView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="#000000" />

</LinearLayout>

自定義 View:

package com.example.test;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;

/**
* Created by HourGlassRemember on 2016/10/7.
*/
public class DragView extends View {

    public DragView(Context context) {
        this(context, null);
    }

    public DragView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DragView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

}

我們的目的就是讓這個自定義的 View 隨着手指在屏幕上的滑動而滑動,初始化時顯示效果如下所示:
在這裏插入圖片描述

5.2.1 layout 方法

在 View 進行繪製時,會調用 onLayout() 方法來設置顯示的位置。同樣,可以通過修改 View 的 left、top、right、bottom 四個屬性來控制 View 的座標,與前面提供的模板代碼一樣,在每次回調 onTouchEvent的時候,我們都來獲取一下觸摸點的座標,代碼入下所示:

package com.example.test;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

/**
* Created by HourGlassRemember on 2016/10/7.
*/
public class DragView extends View {

    private int lastX, lastY;

    public DragView(Context context) {
        this(context, null);
    }

    public DragView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DragView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @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 的基礎上,增加計算出來的偏移量
                //每次移動時,View 都會調用 Layout 方法來對自己重新佈局,從而達到移動 View 的效果
                layout(getLeft() + offsetX, getTop() + offsetY, getRight() + offsetX, getBottom() + offsetY);
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }

}

在上面的代碼中,使用的是 getX()、getY() 方法來獲取座標值,即通過視圖座標來獲取偏移量。當然,同樣可以使用 getRawX()、getRawY() 來獲取座標,並使用絕對座標來計算偏移量,代碼如下所示:

package com.example.test;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

/**
* Created by HourGlassRemember on 2016/10/7.
*/
public class DragView extends View {

    private int lastX, lastY;

    public DragView(Context context) {
        this(context, null);
    }

    public DragView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DragView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int rawX = (int) event.getX();
        int rawY = (int) event.getY();
        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 的基礎上,增加計算出來的偏移量
                //每次移動時,View 都會調用 Layout 方法來對自己重新佈局,從而達到移動 View 的效果
                layout(getLeft() + offsetX, getTop() + offsetY, getRight() + offsetX, getBottom() + offsetY);
                //重新設置初始座標
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }

}

使用絕對座標系,有一點非常需要注意的地方,就是在每次執行完 ACTION_MOVE 的邏輯後,一定要重新設置初始座標,這樣才能準確地獲取偏移量。

5.2.2 offsetLeftAndRight() 與 offsetTopAndBottom()

這個方法相當於系統提供的一個對左右、上下移動的 API 的封裝。當計算出偏移量後,只需要使用如下代碼就可以完成 View 的重新佈局,效果與使用 Layout 方法一樣,代碼如下所示:

// 同時對 left 和 right 進行偏移
offsetLeftAndRight(offsetX);
// 同時對 top 和 bottom 進行偏移
offsetTopAndBotom(offsetY);

這裏的 offsetX、offsetY 與在 Layout 方法中的計算的 offset 的方法一樣,這裏就不重複了。

5.2.3 LayoutParams

LayoutParams 保存了一個 View 的佈局參數,因此可以在程序中改變 LayoutParams 來動態地修改一個佈局的位置參數,從而達到改變 View 位置的效果。代碼如下所示:

LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams)getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);

不過這裏需要注意的是,通過 getLayoutParams() 獲取 LayoutParams 時,需要根據 View 所在父佈局的類型來設置不同的佈局,當然,這一切的前提是你必須要有一個父佈局,不然系統無法獲取 LayoutParams。
在通過改變 LayoutParams 來改變一個 View 的位置時,通常改變的是這個 View 的 Margin 屬性,所以除了使用佈局的 LayoutParams 之外,還可以使用 ViewGroup.MarginLayoutParams 來實現這樣一個功能,代碼如下所示:

ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams)getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);

我們可以發現,使用 ViewGroup.MarginLayoutParams 更加的方便,不需要考慮父佈局的類型,當然它們的本質都是一樣的。

5.2.4 scrollTo 與 scrollBy

在一個 View 中,系統提供了 scrollTo、scrollBy 兩種方式來改變一個 View 的位置,這兩個方法的區別非常好理解,與英文中 To 與 By 的區別類似,scrollTo(x, y) 表示移到了一個 具體的座標點 (x, y),而 scrollBy(x, y) 表示移動的增量爲 dx,dy。
scrollTo、scrollBy 方法移動的是 View 的 content,即讓 View 的內容移動,如果在 ViewGroup 中使用 scrollTo、scrollBy 方法,那麼移動的將是所有子 View,但如果在 View 中使用,那麼移動的將是 View 的內容,例如 TextView,content 就是它的文本,ImageView,content 就是它的 drawable 對象。
我們需要了解一下視圖移動的一些知識,不妨這樣想象手機屏幕是一箇中空的蓋板,蓋板下面是一個巨大的畫布,也就是我們想要顯示的視圖。當這個蓋板蓋在畫布上的某一處時,透過中間空的矩形,我們看見了手機屏幕上顯示的視圖,而畫布上其他地方的視圖,則被蓋住了無法看見。我們的視圖與這個例子非常相似,我們沒有看見視圖,並不代表它就不存在,有可能只是在屏幕外面而已。來看一個具體的例子:
理解 scrollBy

在上圖中,中間的矩形相當於屏幕,即可視區域。後面的 content 就相當於畫布,代表視圖。大家可以看到,只有視圖的中間部分目前是可視的,其他部分都不可見。在可見區域中,我們設置了一個 Button,它的座標是 (20, 10)。下面使用 scrollBy 方法,將蓋板(屏幕,可視區域),在水平方向向 X 軸正方向(右方)平移 20,在豎直方向上向 Y 軸方向(下方)平移 10,那麼平移之後的可視區域如下圖所示:
移動之後的可視區域

通過上面的分析可以發現,如果將 scrollBy 中的參數 dx 和 dy 設置爲正數,那麼 content 將向座標軸負方向移動;如果將 scrollBy 中的參數 dx 和 dy 設置爲負數,那麼 content 將向座標軸正方向移動。因此要實現跟隨手指移動而滑動的效果,就必須將偏移量改爲負值,代碼如下所示:

int offsetX = x - lastX;
int offsetY = y - lastY;
((View)getParent).scrollBy(-offsetX, -offsetY);

類似地,在使用絕對座標時,也可以通過使用 scrollTo 方法實現這一效果。

5.2.5 Scroller

Scroller 類與 scrollTo、scrollBy 方法的區別是 scrollTo、scrollBy 方法,子 View 的平移都是瞬間發生的,在事件執行的時候平移就已經完成了,這樣的效果讓人感覺非常突兀,Google 建議使用自然的過渡動畫來實現移動效果,當然也要遵循這一原則,因此,Scroller 類就應運而生了,通過 Scroller 類可以實現平滑移動的效果,而不再是瞬間完成的移動,這就是他們的區別。
說到 Scroller 類的實現原理,其實它與前面使用 scrollTo 和 scrollBy 方法來實現子 View 跟隨手指移動的原理基本類似。雖然 scrollBy 方法是讓子 View 瞬間從某點移動到另一個點,但是由於在 ACTION_MOVE 事件中不斷獲取手指移動的微小的偏移量,這樣就將一段距離劃分成了 N 個非常小的偏移量。雖然在每個偏移量裏面,通過 scrollBy 方法進行了瞬間移動,但是在整體上卻可以獲得一個平滑移動的效果。這個原理與動畫的實現原理也是基本類似的,它們都是利用了人眼的視覺暫留特性。
下面我們就在本章的例子中,演示一下如何使用 Scroller 類實現平滑移動。在這個實例中,同樣讓子 View 跟隨手指的滑動而滑動,但是在手指離開屏幕時,讓子 View 平滑的移動到初始位置,即屏幕左上角。一般情況下,使用 Scroller 類需要如下三個步驟:

  1. 初始化 Scroller
    首先,通過它的構造方法來創建一個 Scroller 對象,代碼如下所示:
// 初始化 Scroller
mScroller = new Scroller(context);
  1. 重寫 computerScroll() 方法,實現模擬滑動
    下面我們需要重寫 computerScroll() 方法,它是使用 Scroller 類的核心,系統在繪製 View 的時候會在 draw() 方法中調用該方法。這個方法實際上就是使用 scrollTo 方法。再結合 Scroller 對象,幫助獲取到當前的滾動值。我們可以通過不斷地瞬間移動一個小的距離來實現整體上的平滑移動效果。通常情況下,computerScroll 的代碼可以利用如下模板代碼來實現。
@Override
public void computerScroll(){
    super.computerScroll();
    // 判斷 Scroller 是否執行完畢
    if(mScroller.computerScrollOffset()){
        ((View)getParent()).scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        // 通過重繪來不斷調用 computerScroll
        invalidate();
    }
}

Scroller 類提供了 computerScrollOffset() 方法來判斷是否完成了整個滑動,同時也提供了 getCurrX()、getCurrY() 方法來獲得當前的滑動座標。在上面的代碼中,唯一需要注意的是 invalidate() 方法,因爲只能在 computerScroll() 方法中獲取模擬過程中的 scrollX 和 scrollY 座標。但 computerScroll() 方法是不會自動調用的,只能通過 invalidate() -> draw() -> computerScroll() 來間接調用 computerScroll() 方法,所以需要在模板代碼中調用 invalidate() 方法,實現循環獲取 scrollX 和 scrollY 的目的。而當模擬過程結束後,scroller.computerScrollOffset() 方法會返回 false,從而中斷循環,完成整個平滑移動過程。

  1. startScroll 開啓模擬過程
    我們在需要使用平滑移動的事件中,使用 Scroller 類的 startScroll() 方法來開啓平滑移動過程。startScroll() 方法具有兩個重載方法:
  • public void startScroll(int startX, int startY, int dx, int dy, int duration);
  • public void startScroll(int startX, int startY, int dx, int dy);

可以看到它們的區別就是一個具有指定的持續時長,而另一個沒有。這個非常好理解,與在動畫中設置 duration 和使用默認的顯示時長是一個道理。而其他四個座標,則與它們的命名含義相同,就是起始座標與偏移量。在獲取座標時,通常可以使用 getScrollX() 和 getScrollY() 方法來獲取父視圖中 content 所滑動到的點的座標,不過要注意的是這個值的正負,它與在 scrollBy、scrollTo 中講解的情況是一樣的。
通過上面三個步驟,我們就可以使用 Scroler 類來實現平滑移動了,下面回到實例中,在構造方法中初始化 Scroller 對象,並重寫 View 的 computerScroll() 方法。最後,需要監聽手指離開屏幕的事件,並在該事件中通過 startScroll() 方法完成平滑移動。那麼要監聽手指離開屏幕的事件,只需要在onTouchEvent 中增加一個 ACTION_UP 監聽選項即可,代碼如下所示:

case MotionEvent.ACTION_UP:
    // 手指離開時,執行滑動過程
    View viewGroup = ((View)getParent());
    mScroller.startScroll(viewGroup.getScrollX(), viewGroup.getScrollY(), -viewGroup.getScrollX(), -viewGroup.getScrollY());
    invalidate();
    break;

在 startScroll() 方法中,我們獲取子 View 移動的距離 —— getScrollX()、getScrollY(),並將偏移量設置爲其相反數,從而將子 View 滑動到原位置。這裏需要注意的還是 invalidate() 方法,需要使用這個方法來通知 View 進行重繪,從而來調用 computerScroll() 的模擬過程。當然,也可以給 startScroll() 方法增加一個 duration 的參數來設置滑動的持續時長。

5.2.6 屬性動畫
在第7章中,我們將詳細講解了如何使用屬性動畫來控制一個 View 的移動效果,在這裏,同樣可以使用屬性動畫來完成 View 的滑動特效,這與在屬性動畫中講解的方法基本一致,這裏就不重複了。

5.2.7 ViewDragHelper
Google 在其 support 庫中爲我們提供了 DrawLayout 和 SlidingPaneLayout 兩個佈局來幫助開發者實現側邊欄滑動的效果。這兩個新的佈局,大大方便了我們創建自己的滑動佈局界面,然而,這兩個功能強大的佈局背後,卻隱藏着一個鮮爲人知卻功能強大的類——ViewDraghelper。通過 ViewDragHelper,基本可以實現各種不同的滑動、拖動需求,因此這個方法也是各種滑動解決方案中的終極絕招。
ViewDragHelper 雖然功能強大,但其使用方法也是本章中最複雜的。讀者朋友需要在理解 ViewDragHelper 基本使用方法的基礎上,通過不斷練習來掌握它的使用技巧。下面通過一個實例,來演示一下如何使用 ViewDragHelper 創建一個滑動佈局。在這個例子中,準備實現類似 QQ 滑動側邊欄的佈局,初始時顯示內容界面,當用戶手指滑動超過一段距離時,內容界面側滑顯示菜單界面,整個過程如下面兩張圖所示:
圖1:初始狀態
圖1:初始狀態
圖2:滑動展開菜單界面
圖2:滑動展開菜單界面

下面來看具體的代碼是如何實現的:

  • 初始化 ViewDragHelper
    首先,自然是需要初始化 ViewDragHelper,ViewDragHelper 通常定義在一個 ViewGroup 的內部,並通過其靜態工廠方法進行初始化,代碼如下所示:
mViewDragHelper = ViewDragHelper.create(this, callback);

它的第一個參數是要監聽的 View,通常需要是一個 ViewGroup,即 parentView;第二個參數是一個 Callback 回調,這個回調就是整個 ViewDragHelper 的邏輯核心,後面再來詳細講解。

  • 攔截事件
    接下來,就要重寫攔截事件方法,將事件傳遞給 ViewDragHelper 進行處理,代碼如下所示:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    return mViewDragHelper.shouldInterceptTouchEvent(event);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
    // 將觸摸事件傳遞給 ViewDragHelper,此操作必不可少
    mViewDragHelper.processTouchEvent(event);
    return true;
}

這一點我們在講 Android 事件機制的時候已經進行了詳細講解,這裏就不再重複了。

  • 處理 computerScroll()
    沒錯,使用 ViewDragHelper 同樣需要重寫下 computerScroll() 方法,因爲 ViewDragHelper 內部也是通過 Scroller 來實現平滑移動的。通常情況下,可以使用如下所示的模板代碼:
@Override
public void computeScroll() {
    if (mViewDragHelper.continueSettling(true)){
        ViewCompat.postInvalidateOnAnimation(this);
    }
}
  • 處理回調 Callback
    下面就是最關鍵的 Callback 實現,通過如下所示代碼來創建一個 ViewDragHelper.Callback。
private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
    @Override
    public boolean tryCaptureView(View child, int pointerId) {
        return false;
    }
};

IDE 自動幫我們重寫了一個方法——tryCaptureView(),通過這個方法,我們可以指定在創建 ViewDragHelper 時,參數 parentView 中的哪一個子 View 可以被移動。例如在這個實例中自定義了一個 ViewGroup,裏面定義了兩個子 View——MenuView 和 MainView,當指定如下代碼時,則只有 MainView 是可以被拖動的。

// 何時開始檢測觸摸事件
@Override
public boolean tryCaptureView(View child, int pointerId) {
    // 如果當前觸摸的 child 是 mMainView 時開始檢測
    return mMainView == child;
}

下面來看具體的滑動方法——clampViewPositionVertical() 和 clampViewPositionHorizontal(),分別對應垂直和水平方向上的滑動。如果要實現滑動效果,那麼這兩個方法是必須要重寫的。因爲它默認的返回值爲 0,即不發生滑動。當然,如果只重寫 clampViewPositionVertical() 或 clampViewPositionHorizontal() 中的一個,那麼就只會實現該方向上的滑動效果了,代碼如下所示:

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

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

clampViewPositionVertical(View child, int top, int dy) 中的參數 top,代表在垂直方向上 child 移動的距離,而 dy 則表示比較前一次的增量。同理,clampView PositionHorizontal(View child, int left, int dy) 也是類似的含義。通常情況下,只需要返回 top 和 left 即可,但當需要更加精確地計算 padding 等屬性的時候,就需要對 left 進行一些處理,並返回合適大小的值。
僅僅是通過重寫上面的這三個方法,就可以實現一個最基本的滑動效果了,當用手拖動 MainView 的時候,它就可以跟隨手指的滑動而滑動了,代碼如下所示:

private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
    // 何時開始檢測觸摸事件
    @Override
    public boolean tryCaptureView(View child, int pointerId) {
        // 如果當前觸摸的 child 是 mMainView 時開始檢測
        return mMainView == child;
    }

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

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

下面繼續來優化這個實例。在講解 Scroller 類時,曾實現了這樣一個效果——在手指離開屏幕後,子 View 滑動回初始位置。當時我們是通過監聽 ACTION_UP 事件,並通過調用Scroller 類來實現的,這裏使用 ViewDragHelper 來實現這樣的效果。在 ViewDragHelper.Callback 中,系統提供了這樣的方法——onViewReleased(),通過重寫這個方法,可以非常簡單地實現當手指離開屏幕後實現的操作。當然,這個方法內部是通過 Scroller 類來實現的,這也是前面重寫 computerScroll() 方法的原因,這部分代碼如下所示:

// 拖動結束後調用
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
    super.onViewReleased(releasedChild, xvel, yvel);
    // 手指擡起後緩慢移動到指定位置
    if (mMainView.getLeft() < 500) {
        // 關閉菜單,相當於 Scroller 的 startScroll 方法
        mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
        ViewCompat.postInvalidateOnAnimation(TestViewGroup.this);
    } else {
        // 打開菜單
        mViewDragHelper.smoothSlideViewTo(mMainView, 300, 0);
        ViewCompat.postInvalidateOnAnimation(TestViewGroup.this);
    }
}

設置讓 MainView 移動後左邊距小於500像素的時候,就使用 smoothSliderViewTo() 方法來將 MainView 還原到初始狀態,即座標爲 (0, 0) 的點。而當其左邊距大於500的時候,則將 MainView 移動到 (300, 0) 座標,即顯示 MenuView。讀者朋友可以發現如下所示的這兩行代碼,與在使用 Scroller 類的時候使用的 startScroll() 方法是不是很像呢?

// ViewDragHelper
mViewDragHelper.smothSlideView(mMainView,0,0);
ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
// Scroller

mScroller.startScroll(x,y,dx,dy);
invalidate();

通過前面一步步的分析,現在要實現類似 QQ 側滑菜單的效果,是不是就非常簡單了呢?下面自定義一個 ViewGroup 來完成整個實例的編寫。滑動的處理部分前面已經講過了,在自定義 ViewGroup 的 onFinishInflate() 方法中,按順序將子 View 分別定義成 MenuView 和 MainView,並在 onSizeChanged() 方法中獲得 View 的寬度,如果你需要根據 View 的寬度來處理滑動後的效果,就可以使用這個值來進行判斷。這部分代碼如下所示:

/**
* 加載完佈局後調用
*/
@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    mMenuView = getChildAt(0);
    mMainView = getChildAt(1);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mWidth = mMenuView.getMeasuredWidth();
}

最後,通過整個 ViewDragHelper 實現 QQ 側滑功能的代碼如下所示:

package com.zyt.viewdraghelper;

import android.content.Context;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.ViewDragHelper;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;

/**
* Created by HourGlassRemember on 2017/5/18.
*/
public class DragViewGroup extends FrameLayout {

    private ViewDragHelper mViewDragHelper;
    private View mMenuView, mMainView;
    private int mWidth = 0;

    private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {

        /**
        * 何時開始檢測觸摸事件。
        * 在這個例子中自定義了一個 ViewGroup,裏面定義了兩個 View——MenuView 和 MainView,
        * 當指定 mMainView == child 的時候,則只有 MainView 是可以被拖動的。
        * @param child
        * @param pointerId
        * @return
        */
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            // 如果當前觸摸的 child 是 mMainView 時開始檢測
            return mMainView == child;
        }

        /**
        * 處理水平方向的滑動
        * @param child
        * @param left 在水平方向上 child 移動的距離
        * @param dx 比較前一次的增量
        * @return 默認返回值是 0, 即不發生滑動
        */
        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }

        /**
        * 處理垂直方向的滑動
        * @param child
        * @param top 在垂直方向上 child 移動的距離
        * @param dy 比較前一次的增量
        * @return 默認返回值是 0, 即不發生滑動
        */
        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            return 0;
        }

        /**
        * 拖動結束後調用
        * @param releasedChild
        * @param xvel
        * @param yvel
        */
        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
            // 手指擡起後緩慢移動到指定位置
            if (mMainView.getLeft() < 500) {
                // 關閉菜單
                // 相當於 Scroller 的 startScroll 方法——還原 mMainView 的座標爲原點 (0,0)
                mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
                // 刷新界面
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            } else {
                // 打開菜單
                // 將 mMainView 的座標設置爲 (300,0),從而顯示 mMenuView 菜單
                mViewDragHelper.smoothSlideViewTo(mMainView, 300, 0);
                // 刷新界面
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            }
        }
    };

    public DragViewGroup(Context context) {
        this(context, null, 0);
    }

    public DragViewGroup(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 初始化 ViewDragHelper,參數說明:
        // 第一個參數:要監聽的 View,通常需要一個 ViewGroup,即 parentView
        // 第二個參數:一個 Callback 回調,這個回調就是整個 ViewDragHelper 的邏輯核心
        mViewDragHelper = ViewDragHelper.create(this, callback);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        return mViewDragHelper.shouldInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 將觸摸事件傳遞給 ViewDragHelper,此操作必不可少
        mViewDragHelper.processTouchEvent(event);
        return true;
    }

    @Override
    public void computeScroll() {
        if (mViewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
    * 加載完佈局後調用
    */
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mMenuView = getChildAt(0);
        mMainView = getChildAt(1);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = mMenuView.getMeasuredWidth();
    }

}

當然,這裏只是非常簡單地模擬了 QQ 側滑菜單這個功能。ViewDragHelper 的很多強大功能還沒能夠得到展示。在 ViewDragHelper.Callback 中,系統定義了大量的監聽事件來幫助我們處理各種事件。下面舉例來說:

  • onViewCaptured():這個事件在用戶觸摸到 View 後回調
  • onViewDragStateChanged():這個事件在拖拽狀態改變時回調,比如 idea,dragging 等狀態。
  • onViewPositionChanged():這個事件在位置改變時回調,常用於滑動時更改 scale 進行縮放等效果。

使用 ViewDragHelper 可以幫助我們非常好地處理程序中的滑動效果。但同時 ViewDragHelper 的使用也比較複雜,需要開發者對事件攔截、滑動處理都有比較清除的認識。所以建議初學者循序漸進,在掌握前面幾種解決方法的基礎上,再來學習 ViewDragHelper,以實現更加豐富的滑動效果。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章