實現滑動的七種方法(Android群英傳)
內容是博主照著書敲出來的,博主碼字挺辛苦的,轉載請註明出處,後序內容陸續會碼出。
當了解了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.blankj.achievescroll.DragView
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#ff09cfb1"/>
</LinearLayout>
我們的目的就是讓這個自定義View隨著手指在螢幕上的滑動而滑動。初始化時顯示效果如下圖所示。
layout方法
我們知道,在View進行繪製時,會呼叫onLayout()方法來設定顯示的位置。同樣,可以通過修改View的left,top,right,bottom四個屬性來控制View的座標。與前面提供的模板程式碼一樣,在每次回撥onTouchEvent的時候,我們都來獲取一下觸控點的座標,程式碼如下所示。
int x = (int) event.getX();
int y = (int) event.getY();
接著,在ACTION_DOWN事件中記錄觸控點的座標,程式碼如下所示。
case MotionEvent.ACTION_DOWN:
// 記錄觸控點座標
lastX = x;
lastY = y;
break;
最後,可以在ACTION_MOVE事件中計算偏移量,並將偏移量作用到Layout的left,top,right,bottom基礎上,增加計算出來的偏移量,程式碼如下所示。
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;
這樣每次移動後,View都會呼叫Layout方法來對自己重新佈局,從而達到移動View的效果。
在上面的程式碼中,使用的是getX()、getY()方法來獲取座標值,即通過檢視座標來獲取偏移量。當然,同樣可以使用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;
}
return true;
}
使用絕對座標系,有一點非常需要注意的地方,就是在每次執行完ACTION_MOVE的邏輯後,一定要重新設定初始座標,這樣才能準確地獲取偏移量,兩種方式的不同點一定要自己想清楚原因哦。
offsetLeftAndRight()與offsetTopAndBottom()
這個方法相當於系統提供的一個對左右、上下移動的API的封裝。當計算出偏移量後,只需要使用如下程式碼就可以完成View的重新佈局,效果與使用Layout方法一樣,程式碼如下所示。
// 同時對left和right進行偏移
offsetLeftAndRight(offsetX);
// 同時對top和bottom進行偏移
offsetTopAndBottom(offsetY);
這裡的offsetX、offSetY與在Layout方法中計算offset方法一樣,這裡就不重複了。
LayoutParams
LayoutParams儲存了一個View的佈局引數。因此可以在程式中,通過改變LayoutParams來動態地修改一個佈局的位置引數,從而達到改變View位置的效果。我們可以很方便地在程式中使用getLayoutParams()來獲取一個View的LayouParams。當然,計算偏移量的方法與在Layout方法中計算offset也是一樣。當獲取到偏移量之後,就可以通過setLayoutParams來改變其LayoutParams,程式碼如下所示。
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);
不過這裡需要注意的是,通過getLayoutParams()獲取LayoutParams時,需要根據View所在父佈局的型別來設定不同的型別,比如這裡將View放在LinearLayout中,那麼就可以使用LinearLayout.LayoutParams。類似地,如果在RelativeLayout中,就要使用RelativeLayout.LayoutParams。當然,這一切的前提是你必須要有一個父佈局,不然系統不法獲取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更加的方便,不需要考慮父佈局的型別,當然他們的本質都是一樣的。
scrollTo與scrollBy
在一個View中,系統提供了scrollTo、scrollBy兩種方式來改變一個View的位置。這兩個方法的區別非常好理解,與英文的To與By的區別類似,scrollTo(x, y)表示移動到一個具體的座標點(x, y),而scrollBy(dx, dy) 表示移動的增量為dx、dy。
與前面幾種方式不同,在獲取偏移量後使用scrollBy來移動View,程式碼如下所示。
int offsetX = x - lastX;
int offsetY = y - lastY;
scrollBy(offsetX, offsetY);
但是,當我們拖動View的時候,你會發現View並沒有移動!難道是我們方法寫錯了嗎?其實,方法沒有寫錯,View也確實移動了,只是它移動的並不是我們想要移動的東西。scrollTo、scrollBy方法移動的都View的content,即讓View的內容移動,如果在ViewGroup中使用scrollTo、scrollBy方法,那麼移動的將是所有子View,但如果在View中使用,那麼移動的將是View的內容,例如TextView,content就是它的文字;ImageView,content就是它的drawable物件。
相信通過上面的分析,讀者朋友應該知道為什麼不能在View中使用這個兩個方法來拖動這個View了。那麼我們就該View所在的ViewGroup中來視同scrollBy方法,移動它的子View,程式碼如下所示。
((View) getParent()).scrollBy(offsetX, offsetY);
但是,當再次拖動View的時候,你會發現View雖然移動了,但卻在亂動,並不是我們想要的跟隨觸控點的移動而移動。這裡需要先了解一下檢視移動的一些知識。大家在理解這個問題的時候,不妨這樣想象手機螢幕是一箇中空的蓋板,蓋板下面是一個巨大的畫布,也就是我們想要顯示的檢視。當把這個蓋板蓋在畫布上的某一處時,透過中間空的矩形,我們看見了手機螢幕上的檢視,而畫布在其他地方的檢視,則被蓋板蓋住了無法看見。我們的檢視與這個例子非常類似,我們沒有看見檢視,並不代表它就不存在,有可能只是在螢幕外面而已。當呼叫scrollBy方法時,可以想象為外面的蓋板在移動,這麼說比較抽象,來看一個具體的例子,如下圖所示。
在上圖中,中間的矩形相當於螢幕,即可視區域。後面的content就相當於畫布,代表檢視。大家可以看到,只有檢視的中間部分目前是可視的,其他部分都不可見。在可見區域中,我們設定了一個Button,它的座標是(20,10)。
下面使用scrollBy方法,將蓋板(螢幕、可視區域),在水平方向上向X軸正方向(右方)平移20,在豎直方向上向Y軸正方向(下方)平移10,那麼平移後的可視區域如下圖所示。
我們可以發現,雖然設定scrollBy(20, 10),偏移量均為X軸、Y軸正方向上的正數,但是在螢幕的可視區域內,Button卻向X軸、Y軸負方向上移動了。這就是因為參考系選擇的不同,而產生的不同效果。
通過上面的分析可以發現,如果將scrollBy中的引數dx和dy設定為正數,那麼content將向座標軸負方向移動;如果將scrollBy中的引數dx和dy設定為負數,那麼content將向座標軸正方向移動。因此回到前面的例子,要實現跟隨手指移動而滑動的效果,就必須將偏移量改為負值,程式碼如下所示。
int offsetX = x - lastX;
int offsetY = y - lastY;
((View) getParent()).scrollBy(-offsetX, -offsetY);
再去試驗一下,大家就可以發現,效果與前面幾種方式的效果相同了。類似地,在使用絕對座標時,也可以通過scrollTo方法來實現這一效果。
Scroller
既然提到了scrollTo、scrollBy方法,就不得不再來說一說Scroller類。Scroller類與scrollTo、scrollBy方法十分相似,有著千絲萬縷的聯絡。那麼它們之間具體有什麼區別呢?要解答這個問題,首先來看一個小例子。假如要完成這樣一個效果:通過點選按鈕,讓一個ViewGroup的子View向右移動100個畫素。問題看似非常簡單,只要在按鈕的點選事件中使用前面講的scrollBy方法設定下偏移量不就可以了嗎?的確,通過這樣一個方法可以讓ViewGroup中的子View平移。但是讀者朋友可以發現,不管使用scrollTo還是scrollBy方法,子View的平移都是瞬間發生的,在事件執行的時候平移就已經完成了,這樣的效果會讓人感覺非常突兀。Google建議使用自然的過度動畫來實現移動效果,當然也要遵循這一原則。因此,Scroller類就這樣應運而生了,通過Scroller類可以實現平滑移動的效果,而不再是瞬間完成的移動。
說到Scroller類的實現原理,其實它與前面使用scrollTo和scrollBy方法來實現子View跟隨手指移動的原理基本類似。雖然scrollBy方法是讓子View瞬間從某點移動到另一個點,但是由於在ACTION_MOVE事件中不斷獲取手指移動的微小的偏移量,這樣就將一段距離劃分成了N個非常小的偏移量。雖然在每個偏移量裡面,通過scrollBy方法進行了瞬間移動,但是在整體上卻可以獲得一個平滑移動的效果。這個原理與動畫的實現原理基本類似,他們都是利用了人眼的視覺暫留特性。
下面我們就演示一下如何使用Scroller類實現平滑移動。在這個例項中,同樣讓子View跟隨手指的滑動而滑動,但是在手指離開螢幕時,讓子View平滑地移動到初始位置,即螢幕左上角。一般情況下,使用Scroller類需要如下三個步驟。
◆ 初始化Scroller
首先,通過它的構造方法來建立一個Scroller物件,程式碼如下所示。
// 初始化Scroller
mScroller = new Scroller(context);
◆ 重寫computeScroll()方法,實現模擬滾動
下面我們需要重寫computeScroll()方法,它是使用Scroller類的核心,系統在繪製View的時候會在draw()方法中呼叫該方法。這個方法實際上就是使用scrollTo方法。再結合Scroller物件,幫助獲取到當前的滾動值。我們可以通過不斷地瞬間移動一個小的距離來實現整體上的平滑移動效果。通常情況下,computeScroll的程式碼可以利用如下模板程式碼來實現。
@Override
public void computeScroll() {
super.computeScroll();
// 判斷Scroller是否執行完畢
if (mScroller.computeScrollOffset()) {
((View) getParent()).scrollTo(
mScroller.getCurrX(),
mScroller.getCurrY());
// 通過重繪來不斷呼叫computeScroll
invalidate();
}
}
Scroller類提供了computeScrollOffset()方法來判斷是否完成了整個滑動,同時也提供了getCurrX()、getCurrY()方法來獲取當前滑動座標。在上面的程式碼中,唯一需要注意的是invalidate()方法,因為只能在computeScroll()方法中獲取模擬過程中的scrollX和scrollY座標。但computeScroll()方法是不會自動呼叫的,只能通過invalidate()→draw()→computeScroll()來間接呼叫computeScroll()方法,所以需要在模板程式碼中呼叫invalidate()方法,實現迴圈獲取scrollX和scrollY的目的。而當模擬過程結束後,scroller.computeScrollOffset()方法會返回false,從而中斷迴圈,完成整個平滑移動過程。
◆ 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)
可以看到他們的區別就是一個具有指定的持續時長,而另一個沒有。這個非常好理解,與在動畫中設定durarion和使用預設的顯示時長是一個道理。而其他四個座標,則與它們的命名含義相同,就是起始座標與偏移量。在獲取座標時,通常可以使用getScrollX()和getScrollerY()方法來獲取父檢視中content所滑動到的電的座標,不過要注意的是這個值的正負,它與在scrollBy和scrollTo中講解的情況是一樣的。
通過上面三個步驟,我們就可以使用Scroller類來實現平滑移動了,下面回到例項中,在構造方法中初始化Scroller物件,並重寫View的computeScroll()方法。最後,需要監聽手指離開螢幕的事件,並在該事件中通過呼叫startScroll()方法完成平滑移動。那麼要監聽手指離開螢幕的事件,只需要在onTouchEvent中增加一個ACTION_UP監聽選項即可,程式碼如下所示。
case MotionEvent.ACTION_UP:
// 手指離開時,執行滑動過程
View viewGroup = ((View) getParent());
mScroller.startScroll(
viewGroup.getScrollX(),
viewGroup.getScrollY(),
-viewGroup.getScrollX(),
-viewGroup.getScrollY());
invalidate();
在startScroll()方法中,我們獲取子View移動的距離——getScrollX()、getScrollY(),並將偏移量設定為其相反數,從而將子View滑動到原來位置。這裡需要注意的還是invalidate()方法,需要使用這個方法來通知View進行重繪,從而來呼叫conputeScroll()的模擬過程。當然,也可以給startScroll()方法增加一個duration的引數來設定滑動的持續時長。
屬性動畫
為檢視增加位移動畫,檢視進行位移偏移後,利用檢視動畫在鬆手後檢視回到原處,具體程式碼如下所示。
@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:
// 手指離開時,執行滑動過程
ObjectAnimator animator1 = ObjectAnimator.ofFloat(this, "translationX", -getLeft());
ObjectAnimator animator2 = ObjectAnimator.ofFloat(this, "translationY", -getTop());
AnimatorSet set = new AnimatorSet();
set.playTogether(animator1, animator2);
set.start();
break;
}
return true;
}
ViewDragHelper
Google在其support庫中為我們提供了DrawerLayout和SlidingPaneLayout兩個佈局來幫助開發者實現側邊欄滑動的效果。這兩個新的佈局,大大方便了我們建立自己的滑動佈局介面。然而,這兩個功能強大的佈局背後,卻隱藏著一個鮮為人知卻功能強大的類——ViewDragHelper。通過ViewDragHelper,基本可以實現各種不同的滑動、拖放需求,因此這個方法也是各種滑動方案中的終極絕招。
ViewDragHelper雖然功能強大,但其使用方法也是這次最複雜的。讀者朋友需要在理解ViewDragHelper基本使用方法的基礎上,通過不斷練習來掌握它的技巧。下面通過一個例項,來演示一下如何使用ViewDragHelper建立一個滑動佈局。在這個例子中,準備實現類似QQ滑動側邊欄的佈局,初始時顯示內容介面,當用戶手指滑動超過一段距離時,內容介面側滑顯示選單介面,整個過程如下圖所示。
下面來看具體的程式碼是如何實現的。
◆ 初始化ViewDragHelper
首先,自然是需要初始化ViewDragHelper。ViewDragHelper通常定義在一個ViewGroup的內部,並通過其靜態工廠方法進行初始化,程式碼如下所示。
mViewDragHelper = ViewDragHelper.create(this, callback);
它的第一個引數是要監聽的View,通常需要是一個ViewGroup,即parentView;第二個引數是一個Callback回撥,這個回撥就是整個ViewDragHelper的邏輯核心,後面再來詳細講解。
◆ 攔截事件
接下來,要重寫事件攔截方法,將事件傳遞給ViewDragHelper進行處理,程式碼如下所示。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// 將觸控事件傳遞給ViewDragHelper,此操作必不可少
mViewDragHelper.processTouchEvent(event);
return true;
}
這一點我們在講Android事件機制的時候已經進行了詳細講解,這裡就不再重複了。
◆ 處理computeScroll
沒錯,使用ViewDragHelper同樣需要重寫下computeScroll()方法,因為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 dx) {
return left;
}
clampViewPositionVertical(View child, int top, int dy)中的引數top,代表在垂直方向上child移動的距離,而dy則表示比較前一次的增量。同理,clampViewPositionHorizontal(View child, int left, int dx)也是類似的含義。通常情況下,只需要返回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類來實現的,這也是前面重寫computeScroll()方法的原因,這部分程式碼如下所示。
// 拖動結束後呼叫
@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);
} else {
// 開啟選單
mViewDragHelper.smoothSlideViewTo(mMainView, 300, 0);
}
ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
}
設定讓MainView移動後左邊距小於500畫素的時候,就是用smoothSlideViewTo()方法來將MainView還原到初始狀態,即座標為(0, 0)的點。而當其左邊距大於500的時候,則將MainView移動到(300, 0)座標,即顯示MenuView。讀者朋友可以發現如下所示的這兩行程式碼,與在使用Scroller類的時候使用的startScroll()方法是不是非常像呢?
// ViewDragHelper
mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
// Scroll
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側滑功能的完整程式碼參考專案地址即可。
當然,這裡只是非常簡單地模擬了QQ側滑選單這個功能。ViewDragHelper的很多強大功能還沒能夠得到展示。在ViewDragHelper.Callback中,系統定義了大量的監聽事件來幫助我們吹各種事件,下面就列舉一些事件。
◆ onViewCaptured()
這個事件在使用者觸控到View後呼叫。
◆ onViewDragStateChanged()
這個事件在拖拽狀態改變時回撥,比如idle,dragging等狀態。
◆ onViewPositionChanged()
這個事件在位置改變時回撥,常用於滑動時更改scale進行縮放等效果。
這個ViewDragHelper可以幫助我們非常好地處理程式中的滑動效果。但同時ViewDragHelper的使用也比較複雜,需要開發者對事件攔截、滑動處理都有比較清楚的認識。所以建議初學者循序漸進,在掌握前面幾種解決方案的基礎上,再來學習ViewDragHelper,以實現更加豐富的滑動效果。