內容是博主照著書敲出來的,博主碼字挺辛苦的,轉載請注明出處,后序內容陸續會碼出。
當了解了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,以實現更加豐富的滑動效果。
項目地址→AchieveScroll
原文地址實現滑動的七種方法(Android群英傳)
我的自媒體博客blankj小站(OJ、LeetCode、Android開發),歡迎來逛逛。