1. 程式人生 > >自定義元件開發八 Scroller與平滑滾動

自定義元件開發八 Scroller與平滑滾動

概述

Scroller 譯為“滾動器”,是 ViewGroup 類中原生支援的一個功能。我們經常有這樣的體驗:開啟聯絡人,手指向上滑動,聯絡人列表也會跟著一起滑動,但是,當我們鬆手之後,滑動並不會因此而停止,而是伴隨著一段慣性繼續滑動,最後才慢慢停止。這樣的使用者體驗完全照顧了人的習慣和對事物的感知,是一種非常舒服自然的操作。要實現這樣的功能,需要 Scroller 類的支援。

Scroller 類並不負責“滾動”這個動作,只是根據要滾動的起始位置和結束位置生成中間的過渡位置,從而形成一個滾動的動畫。這一點至關重要。

所謂的“滾動”,事實上就是一個持續不斷重新整理 View 的繪圖區域的過程,給定一個起始位置、結束位置、滾動的持續時間,Scroller 自動計算出中間位置和滾動節奏,再呼叫 invalidate()方法不斷重新整理,從這點看,好像也不是那麼複雜。

還有一點需要強調的是,一個 View 的滾動不是自身發起的動作,而是由父容器驅動子元件來完成,換句話說,需要 Scroller 和 ViewGroup 的配合才能產生滾動這個過程。所以,我們不要誤以為是 View 自己在滾動,顯然不是,而是容器讓子元件滾動,主動權在 ViewGroup 手中。

View 也可以滾動,但是滾動的不是自己,而是 View 中的內容。

滾動往往分別兩個階段:第一個階段是手指在螢幕上滑動,容器內的子元件跟隨手指的速率一起滑動,當手指鬆開後,進入第二個階段——慣性滾動,滾動不會馬上停止,而是給出一個負的加速度,滾動速度會越來越慢,直到最後處於靜態狀態。這符合 Android 中很多元件的使用場景。

本節我們不僅僅只學習 Scroller 類,更要學習 ViewGroup 是如何配合 Scroller 實現慣性滾動的。

認識scrollTo()和scrollBy()方法

View 類中有兩個與滾動有關的方法——scrollTo()和 scrollBy(),這兩個方法的原始碼如下:

    public void scrollTo(int x, int y) {
        if (mScrollX != x || mScrollY != y) {
            int oldX = mScrollX;
            int oldY = mScrollY;
            mScrollX = x;
            mScrollY = y;
            invalidateParentCaches();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            if
(!awakenScrollBars()) { postInvalidateOnAnimation(); } } } public void scrollBy(int x, int y) { scrollTo(mScrollX + x, mScrollY + y); }

scrollTo(int x, int y)方法中,引數 x、y 是目標位置,方法先判斷新的滾動位置是否確實發生了變化,如果是,先儲存上一次的位置,再應用這一次的新位置(x,y),接著呼叫 onScrollChanged()方法,並重新整理 View 元件。scrollTo()方法表示“滾動到……”之意。

scrollBy(int x, int y)方法則不同,是要原來的基礎上水平方向滾動 x 個距離,垂直方向滾動 y個距離,最終還是呼叫了 scrollTo(int x, int y)方法。本質上,這兩個方法是一樣的。scrollBy()方法表示“滾動了……”之意。

我們寫一個簡單的案例來說明 scrollTo()和 scrollBy()的基本使用,並瞭解這兩個方法給元件帶來的影響。定義一個 TextView 元件,並放兩個 Button,兩個按鈕分別呼叫 scrollTo()和 scrollBy()兩個方法,並實現相同的功能。建立 scrolltoby.xml 佈局檔案,內容如下:

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

    <TextView
        android:id="@+id/tv"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="#99CCCCCC"
        android:text="Android" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="scrollBy"
            android:text="scrollBy" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@android:color/darker_gray"
            android:onClick="scrollTo"
            android:text="scrollTo" />
    </LinearLayout>
</LinearLayout>

定義 ScrollToByActivity 類,繼承自 Activity,在 ScrollToByActivity 類中載入 scrolltoby.xml 檔案,並獲得 TextView 物件,定義 Button 的單擊事件響應方法,分別呼叫 scrollTo()和 scrollBy()方法。

public class ScrollToByActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.scrolltoby);
        tv = (TextView) findViewById(R.id.tv);
    }
    private TextView tv;


    public void scrollBy(View view){
        tv.scrollBy(-5, 0);
    }
    public void scrollTo(View view){
        int x = tv.getScrollX();
        int y = tv.getScrollY();
        Log.e("ScrollToByActivity","");
        tv.scrollTo(x - 5, y);
    }
}

ScrollToByActivity 類的 scrollBy()方法是第一個按鈕的事件響應方法,呼叫了 tv.scrollBy(-5, 0)
語句,表示 x 方向每次移動 5 個單位距離,y 不變;scrollTo()方法是第二個按鈕的事件響應方法,先呼叫 tv.getScrollX()和 tv.getScrollY()獲取當前 tv 物件的滾動距離,再通過 tv.scrollTo(x - 5, y)方法在 x 方向移動 5 個單位距離,y 不變。這兩個方法實現的功能是相同的。
這裡寫圖片描述
仔細觀察執行結果,可以得出以下幾個結論:
移動的並不是 View 元件自身,而是元件的內容,當我們點選按鈕時,文字“Android”
的位置向右開始移動;
因為移動的是 View 元件的內容,所以,我們發現其方向與圖形座標系相反,也就是
說,scrollBy()方法的在 x 方向上引數為負時,向右移動,為正時,向左移動,y 方向
上引數為負時,向下移動,為正時,向上移動。scrollTo()方法的新座標比原座標小,
x 方向向右移動,y 方向向下移動,反之亦然。

我們可能會疑惑為什麼滾動子元件的時候方向與我們的習慣是相反的,其實通過閱讀原始碼
能夠有幫助我們理解。啟動滾動後,呼叫 invalidate()方法重新整理繪製,在該方法中,有如下的實現:

    public void invalidate(int l, int t, int r, int b) {
        if (ViewDebug.TRACE_HIERARCHY) {
            ViewDebug.trace(this, ViewDebug.HierarchyTraceType.INVALIDATE);
        }
        if (skipInvalidate()) {
            return;
        }
        if ((mPrivateFlags & (DRAWN | HAS_BOUNDS)) == (DRAWN | HAS_BOUNDS) ||
                (mPrivateFlags & DRAWING_CACHE_VALID) == DRAWING_CACHE_VALID ||
                (mPrivateFlags & INVALIDATED) != INVALIDATED) {
            mPrivateFlags &= ~DRAWING_CACHE_VALID;
            mPrivateFlags |= INVALIDATED;
            mPrivateFlags |= DIRTY;
            final ViewParent p = mParent;
            final AttachInfo ai = mAttachInfo;
//noinspection PointlessBooleanExpression,ConstantConditions
            if (!HardwareRenderer.RENDER_DIRTY_REGIONS) {
                if (p != null && ai != null && ai.mHardwareAccelerated) {
// fast-track for GL-enabled applications; just invalidate the whole hierarchy
// with a null dirty rect, which tells the ViewAncestor to redraw everything
                    p.invalidateChild(this, null);
                    return;
                }
            }
            if (p != null && ai != null && l < r && t < b) {
                final int scrollX = mScrollX;
                final int scrollY = mScrollY;
                final Rect tmpr = ai.mTmpInvalRect;
                tmpr.set(l - scrollX, t - scrollY, r - scrollX, b - scrollY);
                p.invalidateChild(this, tmpr);
            }
        }
    }

下劃線所在這行程式碼 tmpr.set(l - scrollX, t - scrollY, r - scrollX, b - scrollY)用於重新定義子元件的位置和大小,通過一個減法運算來定義新的矩形區域,這就是為什麼子元件滾動方向相反的原因。

接下來再來演示 scrollTo()和 scrollBy()方法對佈局容器的影響。定義 scrolltoby_layout.xml 布
局檔案,內容如下:

<?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:id="@+id/linearlayout"
    android:orientation="vertical">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Android 自定義元件開發詳解" />
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">
    <Button
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:onClick="scrollBy"
        android:text="scrollBy" />
    <Button
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:background="@android:color/darker_gray"
        android:onClick="scrollTo"
        android:text="scrollTo" />
    </LinearLayout>
</LinearLayout>

在本例中,我們為 LinearLayout 佈局定義 id 為 linearlayout,並且呼叫該物件的 scrollBy()和
scrollTo()方法,以觀察對LinearLayout佈局的影響。定義ScrollToByLayoutActivity類,繼承自Activity,內容如下:

public class ScrollToByLayoutActivity extends Activity {


    private LinearLayout linearlayout;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_scroll_to_by_layout);
        linearlayout = (LinearLayout) findViewById(R.id.linearlayout);
    }
    public void scrollBy(View view) {
        linearlayout.scrollBy(-5, 0);
    }
    public void scrollTo(View view) {
        int x = linearlayout.getScrollX();
        int y = linearlayout.getScrollY();
        linearlayout.scrollTo(x - 5, y);
    }

}

執行結果如圖所示,和 View 一樣,當呼叫 linearlayout.scrollBy(-5, 0)和
linearlayout.scrollTo(x - 5, y)方法移動 LinearLayout 時,並不是移動 LinearLayout 本身,而是移動LinearLayout 中的子元件,一個 TextView、兩個 Button 共 3 個子元件發生了整體水平移動。

這裡寫圖片描述
這裡寫圖片描述

在 View 中,還定義了獲取滾動距離的方法,方法原型如下:

public final int getScrollX()
返回 x 方向滾動過的距離,也是當前 view 的左上角相對於父檢視的左上角的 x 軸偏
移量;
public final int getScrollY()
返回 y 方向滾動過的距離,也是當前 view 的左上角相對於父檢視的左上角的 y 軸偏
移量。

Scroller 類

Scroller 類在滾動過程的的幾個主要作用如下:
啟動滾動動作;
根據提供的滾動目標位置和持續時間計算出中間的過渡位置;
判斷滾動是否結束;
介入 View 或 ViewGroup 的重繪流程,從而形成滾動動畫。

Scroller 類雖然對滑動作用非同小可,但定義的的方法並不多,我們最好是能閱讀該類的源
碼,瞭解 Scroller 的工作原理。下面是 Scroller 的方法說明。

public Scroller(Context context)
public Scroller(Context context, Interpolator interpolator)
public Scroller(Context context, Interpolator interpolator, boolean flywheel)
構 造 方 法 , interpolator 指 定 插 速 器 , 如 果 沒 有 指 定 , 默 認 插 速 器 為
ViscousFluidInterpolator,flywheel 引數為 true 可以提供類似“飛輪”的行為;
public final void setFriction(float friction)
設定一個摩擦係數,預設為 0.015f,摩擦係數決定慣性滑行的距離;
public final int getStartX()
返回起始 x 座標值;
public final int getStartY()
返回起始 y 座標值;
public final int getFinalX()
返回結束 x 座標值;
public final int getFinalY()
返回結束 y 座標值;
public final int getCurrX()
返回滾動過程中的 x 座標值,滾動時會提供 startX(起始)和 finalX(結束),currX 根
據這兩個值計算而來;
public final int getCurrY()
返回滾動過程中的 y 座標值,滾動時會提供 startY(起始)和 finalY(結束),currY 根
據這兩個值計算而來;
public boolean computeScrollOffset()
計算滾動偏移量,必調方法之一。主要負責計算 currX 和 currY 兩個值,其返回值為
true 表示滾動尚未完成,為 false 表示滾動已結束;
public void startScroll(int startX, int startY, int dx, int dy)
public void startScroll(int startX, int startY, int dx, int dy, int duration)
啟動滾動行為,startX 和 startY 表示起始位置,dx、dy 表示要滾動的 x、y 方向的距離,
duration 表示持續時間,預設時間為 250 毫秒;
public final boolean isFinished()
判斷滾動是否已結束,返回 true 表示已結束;
public final void forceFinished(boolean finished)
強制結束滾動,currX、currY 即為當前座標;
public void abortAnimation()
與 forceFinished 功用類似,停止滾動,但 currX、currY 設定為終點座標;
public void extendDuration(int extend)
延長滾動時間;
public int timePassed()
返回滾動已耗費的時間,單位為毫秒;
public void setFinalX(int newX)
設定終止位置的 x 座標,可能需要呼叫 extendDuration()延長或縮短動畫時間;
public void setFinalY(int newY)
設定終止位置的 y 座標,可能需要呼叫 extendDuration()延長或縮短動畫時間。

上面的方法中,常用的主要有 startScroll()、computeScrollOffset()、getCurrX()、getCurrY()和abortAnimation()等幾個方法,下面我們通過一個簡單的案例來演示 Scroller 類的基本使用。

定義一個名稱為 BaseScrollerViewGroup 的類,繼承自 ViewGroup,在該類中使用程式碼(非配置)定義一個子元件 Button。為了將重點放在 Scroller 類的使用上,BaseScrollerViewGroup 在定義時做了大量簡化,比如 layout_width 和 layout_height 不支援 wrap_content、Button 直接加入容器、onLayout()方法中將 Button 的位置固定死等等。

public class BaseScrollerViewGroup extends ViewGroup {

    private Scroller scroller;
    private Button btnAndroid;

    public BaseScrollerViewGroup(Context context) {
        super(context);
    }

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

    public BaseScrollerViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        scroller = new Scroller(context);
        btnAndroid = new Button(context);
        LayoutParams layoutParams = new LayoutParams(
                LayoutParams.WRAP_CONTENT,
                LayoutParams.WRAP_CONTENT);
        btnAndroid.setText("Android 自定義元件");
        this.addView(btnAndroid, layoutParams);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        btnAndroid.layout(10, 10, btnAndroid.getMeasuredWidth() + 10,
                btnAndroid.getMeasuredHeight() + 10);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST
                || MeasureSpec.getMode(heightMeasureSpec)
                == MeasureSpec.AT_MOST)
            throw new IllegalStateException("Must be MeasureSpec.EXACTLY.");
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
                MeasureSpec.getSize(heightMeasureSpec));
    }


    @Override
    public void computeScroll() {
        if (scroller.computeScrollOffset()) {
            //設定容器內元件的新位置
            this.scrollTo(scroller.getCurrX(), scroller.getCurrY());
            //重繪以重新整理產生動畫
            postInvalidate();
        }
    }

    /**
     * 開始滾動,外部呼叫
     */
    public void start() {
        //從當前位置開始滾動,x 方向向右滾動 900,
        //y 方向不變,也就是水平滾動
        scroller.startScroll(this.getScrollX(), this.getScrollY(),
                -900, 0, 10000);
        //重繪
        postInvalidate();
    }

    /**
     * 取消滾動,直接到達目的地
     */
    public void abort() {
        scroller.abortAnimation();
    }
}

我們首先定義了一個 Scroller 型別的成員變數 scroller,並在構造方法中進行了例項化。重點
是重寫了 ViewGroup 的 computeScroll()方法,該方法的預設實現是空方法,在繪製 View 時呼叫。在 computeScroll()方法中,呼叫 scroller.computeScrollOffset()方法計算下一個位置的座標值(currX,currY),再通過 this.scrollTo(scroller.getCurrX(), scroller.getCurrY())語句移動到該座標位置,特別要注意的是一定要呼叫 invadate()或 postInvalidate()方法重繪,一旦 computeScrollOffset()方法返回false 表示滾動結束,停止重繪。

另外,我們還定義了兩個用來與外部互動的方法:start()和 abort()。start()方法用於啟動滾動
動作,執行了 scroller.startScroll(this.getScrollX(), this.getScrollY(), - 900, 0, 10000)語句,其中引數this.getScrollX()和 this.getScrollY()是容器內容的初始位置,x 方向向右移動 900 個單位距離(為負才表示向右),y 方向不變,也就是水平向右移動,為了更好的檢視動畫過程,將滾動持續時間設為 10 秒。和上面一樣,就算呼叫了 startScroll()方法,也需要呼叫 invadate()或 postInvalidate()方法進行重繪。在 abort()方法中呼叫了 scroller.abortAnimation()方法,用來停止滾動。

我們通過一個測試程式來驗證 BaseScrollerViewGroup 容器的工作過程。定義一個名為
base_scroller.xml 的佈局檔案,檔案內有一個 BaseScrollerViewGroup 標籤,另外有兩個並排擺放的按鈕,分別執行“開始滾動”和“停止滾動”的功能。

<?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.trkj.lizanhong.chapter8.BaseScrollerViewGroup
        android:id="@+id/scroll_layout"
        android:layout_width="match_parent"
        android:layout_height="100dp" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="start"
            android:text="開始滾動" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="@android:color/holo_blue_bright"
            android:onClick="abort"
            android:text="停止滾動" />
    </LinearLayout>
</LinearLayout>

BaseScrllerActivity 類則很簡單,為兩個 Button 定義事件處理方法,分別呼叫 start()和
abort()方法用於開始滾動和停止滾動。

public class BaseScrllerActivity extends Activity {

    private BaseScrollerViewGroup scrollerViewGroup;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.base_scroller);
        scrollerViewGroup = (BaseScrollerViewGroup)
                findViewById(R.id.scroll_layout);
    }
    /**
     * 滾動
     * @param view
     */
    public void start(View view){
        scrollerViewGroup.start();
    }
    /**
     * 停止
     * @param view
     */
    public void abort(View view){
        scrollerViewGroup.abort();
    }
}

這裡寫圖片描述

平滑滾動的原理

以上可以總結出平滑滾動的基本工作流程:
1) 呼叫 scroller 的 startScroll()方法定義滾動的起始位置和滾動的距離;
2) 通過 invalidate()或 postInvalidate()方法重新整理,呼叫 draw(Canvas)方法重繪元件;
3) 呼叫 computeScroll()計算下一個位置的座標;
4) 再次呼叫 invalidate()或 postInvalidate()方法重新整理重繪;
5) 判斷 computeScroll()方法的返回值,如果為 false 表示結束滾動,為 true 表示繼續滾動。

上面的步驟其實構建了一個方法呼叫迴圈:1) -> 2) -> 3) -> 4) -> 5) -> 3) -> 4) -> 5)……,3) ->4) -> 5)就是一個迴圈,該迴圈用於不斷計算下一個位置,並通過重繪移動到該位置,這樣就產生了動畫效果。

我們通過閱讀原始碼的方式進一步瞭解平滑滾動的工作原理。當呼叫 invalidate()方法或
postInvalidate()方法後,將重繪請求傳送到 ViewRoot,再分發到對應的元件,呼叫 draw(Canvas canvas)方法。

public void draw(Canvas canvas) {
……
// Step 4, draw the children
dispatchDraw(canvas);
……
}

在 draw(Canvas canvas)方法中又呼叫 dispatchDraw(Canvas canvas)方法,該方法負責將繪製請求分發給子元件。

protected void dispatchDraw(Canvas canvas) {
……
more |= drawChild(canvas, child, drawingTime);
……
}

dispatchDraw(Canvas canvas)方法又呼叫了 drawChild()方法完成子元件的繪製工作。

protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
……
child.computeScroll();
……
}

重點來了,drawChild()方法呼叫了 child.computeScroll()方法,但是該方法是一個空方法,什
麼也沒有,我們需要重寫該方法才能實現平滑滾動。

案例:觸控滑屏

觸控滑屏的技術

Android 手機一個最明顯的標識就是進入桌面後可以左右滑屏檢視 App 應用圖示,和
Windows XP 的桌面有顯著區別,這也給有限的桌面帶來無限的空間,和垂直滑動顯示內容相比,左右滑動更方便使用者的手勢操作,帶來更好的使用者體驗,並獲得使用者的一致認可。

觸控滑屏分為兩個過程:一是手指在螢幕上滑動時螢幕跟隨一起滑動,滑動速度與手指速
度相同,現在的硬體質量有了很大提升,基本上是很粘手的,以前硬體沒有到達現在這個水平
時,總有一些延遲,以前魅族生產的第一部手機 M8 就遇到過這個問題,現在基本不存在了。
二是手指鬆開後,根據手指的速度、已滑動距離判斷螢幕是要回滾還是滑動到下一屏。這兩個
過程共同構成了滑屏的基本動作。

如 果 您 熟 悉 Android 的 事 件 處 理 機 制 , 一 定 清 楚 public boolean onIntercept-
TouchEvent(MotionEvent ev)方法的作用,主要用於截攔事件,事件一旦被截攔,便無法將事件傳遞給子元件。觸屏滑動時,必須考慮這個問題,當螢幕正處於滑動狀態時,容器內的子元件便不再接受任何事件,onInterceptTouchEvent()方法必須返回 true,事件便繞過子元件往回傳遞。所以,我們必須在該方法中判斷使用者手指的狀態是不是滑動狀態,如果是滑動狀態,返回 true 值,否則返回 false 值。

觸控滑動的操作在 public boolean onTouchEvent(MotionEvent event)方法中完成,手指按下時,判斷是否正在滑屏中,如果是,則馬上停止,同時記下手指的初始座標。手指移動過程中,獲取手指移動的距離,並讓容器內容以相同的方向移動相同的距離。手指鬆開後,根據手指移動速度和已移動的距離判斷是要回滾還是移動到下一屏。

ViewGroup 的內容區域是無限大的,我們可以將無陣列件都放進去,但因為螢幕空間有限,
所以只能看到一部分內容。就像執行中游戲,場景很大,但是看到的卻很少。要實現觸控分屏,必須將容器內的每個子元件設定成與螢幕大小相同,但一次只顯示其中的一個。
這裡寫圖片描述

容器的總寬度是容器可見寬度乘以子元素的個數,而高度則為可見高度大小。

速度跟蹤器 VelocityTracker

VelocityTracker 主要用於跟蹤觸控式螢幕事件(flinging 事件和其他 gestures 手勢事件)的速率。
當我們要跟蹤一個 touch 事件的時候,使用 obtain()方法得到這個類的例項,然後用
addMovement(MotionEvent)函式將你接受到的 motion event 加入到 VelocityTracker 類例項中。當我們需要使用到速率時,使用 computeCurrentVelocity(int)初始化速率的單位,並獲得當前的事件的速率,然後使用 getXVelocity() 或 getXVelocity()獲得橫向和豎向的速率。另外,通過VelocityTracker 還可以知道手指的滑動方向。

VelocityTracker 的基本使用如下:
手指按下時(ACTION_DOWN),獲取 VelocityTracker 物件

if(velocityTracker == null){
//建立 velocityTracker 物件
velocityTracker = VelocityTracker.obtain();
}
//關聯事件物件
velocityTracker.addMovement(ev);

手指移動過程中(ACTION_MOVE),計算速率

velocityTracker.computeCurrentVelocity(1000);

獲取 xy 兩個方向的速率:
int velocityX = velocityTracker.getXVelocity();
int velocityY = velocityTracker.getYVelocity();

手指鬆開後(ACTION_UP),釋放並回收資源
//釋放 VelocityTracker 資源

if(velocityTracker != null){
    velocityTracker.clear();
    velocityTracker.recycle();
    velocityTracker = null;
}

觸控滑屏的分步實現

定義一個容器類 MultiLauncher,繼承自 ViewGroup,容器類中的子元件將與容器大小相
同。

第一步:初始化。平滑滾動需要使用 Scroller 物件,另外還需要給定一個最小滑動距離,
通過 ViewConfiguration.get(context).getScaledTouchSlop()可以獲取到當前手機上預設的最小滑動距離。

private Scroller scroller;
private int touchSlop = 0;//最小滑動距離,超過了,才認為開始滑動
private static final String TAG = "MultiLauncher";
public MultiLauncher(Context context) {
    this(context, null);
}
public MultiLauncher(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}
public MultiLauncher(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    scroller = new Scroller(context);
    touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}

第二步:測量容器寬度與高度。不允許使用 MeasureSpec.AT_MOST,每個子元件與容器相
同,容器的 layout_width 值雖然為 MeasureSpec. EXACTLY,但容器大小 = 父容器的寬度 * 子元件的個數,高度與父容器相同。

/**
* 測量容器本身的大小
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    measureChildren(widthMeasureSpec, heightMeasureSpec);
    int width = this.measureWidth(widthMeasureSpec);
    int height = this.measureHeight(heightMeasureSpec);
    setMeasuredDimension(width, height);
}
/**
* 測量元件的寬度
* @param widthMeasureSpec
* @return
*/
private int measureWidth(int widthMeasureSpec){
    int mode = MeasureSpec.getMode(widthMeasureSpec);
    int size = MeasureSpec.getSize(widthMeasureSpec);
    int width = 0;
    if(mode == MeasureSpec.AT_MOST){
        throw new IllegalArgumentException("Must not be
        MeasureSpec.AT_MOST.");
    }else{
        width = size;
    }
    //容器的寬度是螢幕的 n 倍,n 是容器中子元素的個數
    return width * this.getChildCount();
}
/**
* 測量元件的高度
* @param heightMeasureSpec
* @return
*/
private int measureHeight(int heightMeasureSpec){
    int mode = MeasureSpec.getMode(heightMeasureSpec);
    int size = MeasureSpec.getSize(heightMeasureSpec);
    int height = 0;
    if(mode == MeasureSpec.AT_MOST){
        throw new IllegalArgumentException("Must not
        be MeasureSpec.AT_MOST.");
    }else{
        height = size;
    }
    return height;
}

第三步:定位子元件。預設情況下,螢幕出現第一個子元件,子元件佔滿容器的可見區
域,其他子元件以相同大小依次排列在後面。

protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int n = this.getChildCount();
    int w = (r - l) / n;//分屏的寬度
    int h = b - t;//容器的高度
    for(int i = 0; i < n; i ++){
        View child = getChildAt(i);
        int left = i * w;
        int right = (i + 1) * w;
        int top = 0;
        int bottom = h;
        child.layout(left, top, right, bottom);
    }
}

第四步:判斷滾動狀態,狀態為分兩種:停止狀態和滑動狀態。容器根據狀態決定是否截
攔事件。

private static final int TOUCH_STATE_STOP = 0x001;//停止狀態
private static final int TOUCH_STATE_FLING = 0x002;//滑動狀態
private int touchState = TOUCH_STATE_STOP;
private int touchSlop = 0;//最小滑動距離,超過了,才認為開始滑動
private float lastionMotionX = 0;//上次觸控式螢幕的 x 位置

public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getAction();
    final int x = (int) ev.getX();
    final int y = (int) ev.getY();
    if (action == MotionEvent.ACTION_MOVE &&
            touchState == TOUCH_STATE_STOP)
        return true;
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            lastionMotionX = x;
            touchState = scroller.isFinished() ? TOUCH_STATE_STOP
                    : TOUCH_STATE_FLING;
            break;
        case MotionEvent.ACTION_MOVE:
            //滑動距離過小不算滑動
            final int dx = (int) Math.abs(x - lastionMotionX);
            if (dx > touchSlop) {
                touchState = TOUCH_STATE_FLING;
            }
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            touchState = TOUCH_STATE_STOP;
            break;
        default:
            break;
    }
    return touchState != TOUCH_STATE_STOP;
}

第五步:慣性滾屏。在下面的程式碼中,最重要的一個語句是 int dx = curScreen * splitWidth -
scrollX,獲得當前屏的索引 curScreen(從 0 開始),乘以一屏的寬度,減去容器滾過的距離,得到的值就是剩下的慣性距離。假設一共有 5 屏,每屏寬度為 10,當前 curScreen 為 1 時表示將滾動到第 2 屏,如果容器已滾動了 6,則 dx = 1 * 10 - 6 = 4,意思是剩下的 4 個單位距離將自動滾過去。下面是從第 0 屏滾動到第 1 屏,從第 1 屏滾動到第 2 屏,再由第 2 屏滾動到第 1 屏,第 1屏滾動到第 0 屏各變數輸出的值(測試手機為魅族 Pro5),大家可以通過這些輸出結果找出一些規律或得到一些結論:
02-06 23:03:36.021 /? I/MultiLauncher: moveToScreen
02-06 23:03:36.021 /? I/MultiLauncher: curScreen:1
02-06 23:03:36.021 /? I/MultiLauncher: scrollX:498 dx:582 splitWidth:1080
02-06 23:03:37.691 /? I/MultiLauncher: moveToScreen
02-06 23:03:37.691 /? I/MultiLauncher: curScreen:2
02-06 23:03:37.691 /? I/MultiLauncher: scrollX:1532 dx:628 splitWidth:1080
02-06 23:05:30.451 /? I/MultiLauncher: moveToScreen
02-06 23:05:30.451 /? I/MultiLauncher: curScreen:1
02-06 23:05:30.451 /? I/MultiLauncher: scrollX:1648 dx:-568 splitWidth:1080
02-06 23:05:32.051 /? I/MultiLauncher: moveToScreen
02-06 23:05:32.051 /? I/MultiLauncher: curScreen:0
02-06 23:05:32.051 /? I/MultiLauncher: scrollX:342 dx:-342 splitWidth:1080

  private int curScreen; //當前屏
    private VelocityTracker velocityTracker;//速率跟蹤器
    public void moveToScreen(int whichScreen){
        Log.i(TAG, "moveToScreen");
        curScreen = whichScreen;
        Log.i(TAG, "curScreen:" + curScreen);
        if(curScreen > getChildCount() - 1)
            curScreen = getChildCount() - 1;
        if(curScreen < 0) curScreen = 0;
        int scrollX = getScrollX();
        //每一屏的寬度
        int splitWidth = getWidth() / getChildCount();
        //要移動的距離
        int dx = curScreen * splitWidth - scrollX;
        Log.i(TAG, "dx:" + dx);
        //開始移動
        scroller.startScroll(scrollX, 0, dx, 0, Math.abs(dx));
        invalidate();
    }

手指滑動距離如果超過容器一半或者滑動速度足夠快,則進入下一屏(或者上一屏)。如果
沒有超過一半或速度很慢則回滾到初始位置。定義 moveToDestination()方法如下,最關鍵的語句是 int toScreen = (getScrollX() + splitWidth / 2 ) / splitWidth,getScrollX()是容器滾動過的距離,splitWidth 是每一屏的寬度。比如每一屏的寬度為 10,當前屏為第 2 屏,容器已滾過 23,則 toScreen= (23 + 10 / 2) / 10 = (23 + 5) / 10 = 28 / 10 = 2.8 = 2,也就是說要回滾到第 2 屏;如果容器已滾動28,則 toScreen = (28 + 10 / 2) / 10 = 32 / 10 = 3.2 = 3,表示要滾動到第 3 屏。

public void moveToDestination(){
    Log.i(TAG, "moveToDestination");
    //每一屏的寬度
    int splitWidth = getWidth() / getChildCount();
    //判斷是回滾還是進入下一分屏
    int toScreen = (getScrollX() + splitWidth / 2 ) / splitWidth ;
    //移動到目標分屏
    moveToScreen(toScreen);
}

定義了 moveToNext()和 moveToPrevious()簡化滑屏呼叫。

/**
* 滾動到下一屏
*/
public void moveToNext(){
    moveToScreen(curScreen + 1);
}
/**
* 滾動到上一屏
*/
public void moveToPrevious(){
    moveToScreen(curScreen - 1);
}

第六步:響應使用者手指的按下、移動和鬆開事件,這是整個滑動的關鍵,特別是鬆開後,要
判斷滾屏還是回滾。為了支援上一屏和下一屏,需要辨別手指滑動的方向,VelocityTracker 類可以獲取 x 方向的速率,其正值代表向左滑動,負值代表向右滑動。如果 x 方向的速率在[-
SNAP_VELOCITY,SNAP_VELOCITY]之間,則要根據使用者滑動的距離(滑動距離是否超過一屏的1/2)決定是要繼續滾屏還是回滾到初始狀態。

public boolean onTouchEvent(MotionEvent event) {
        if(velocityTracker == null){
            velocityTracker = VelocityTracker.obtain();
        }
        velocityTracker.addMovement(event);
        super.onTouchEvent(event);
        int action = event.getAction();
        final int x = (int) event.getX();
        switch (action){
            case MotionEvent.ACTION_DOWN:
                //手指按下時,如果正在滾動,則立刻停止
                if(scroller != null && !scroller.isFinished()){
                    scroller.abortAnimation();
                }
                lastionMotionX = x;
                break;
            case MotionEvent.ACTION_MOVE:
                //隨手指滾動
                int dx = (int) (lastionMotionX - x);
                scrollBy(dx, 0);
                lastionMotionX = x;
                break;
            case MotionEvent.ACTION_UP:
                final VelocityTracker velocityTracker = this.velocityTracker;
                velocityTracker.computeCurrentVelocity(1000);
                int velocityX = (int) velocityTracker.getXVelocity();
                //通過 velocityX 的正負值可以判斷滑動方向
                if(velocityX > SNAP_VELOCITY && curScreen > 0){
                    moveToPrevious();
                }else if(velocityX < -SNAP_VELOCITY && curScreen < (getChildCount()-1)){
                    moveToNext();
                }else {
                    moveToDestination();
                }
                if(velocityTracker != null){
                    this.velocityTracker.clear();
                    this.velocityTracker.recycle();
                    this.velocityTracker = null;
                }
                touchState = TOUCH_STATE_STOP;
                break;
            case MotionEvent.ACTION_CANCEL:
                touchState = TOUCH_STATE_STOP;
                break;
        }
        return true;
    }

我們接下來對 MultiLauncher 類進行簡單測試,測試有兩種情形:一種是手指的觸控滾屏,
另一種是點選按鈕實現上一屏和下一屏的滾屏。定義 multi_launcher.xml 佈局檔案,佈局中定義了 5 個 LinearLayout,代表五屏內容——事實上您可以定義任意多的分屏,也可以使用其他佈局作為分屏容器。multi_launcher.xml 佈局檔案的內容如下:

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

    <bczm.com.day0617.MultiLauncher

        android:id="@+id/ml"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="#FF0000"
            android:orientation="vertical"></LinearLayout>

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="#FFFF00"
            android:orientation="vertical"></LinearLayout>

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="#00FF00"
            android:orientation="vertical"></LinearLayout>

        <