滑動效果的產(chǎn)生
滑動一個 View ,其實就是移動一個 View,本質(zhì)上是對 View 的坐標(biāo)位置進(jìn)行不停的改變。那么要實現(xiàn)這個效果,就必須要監(jiān)聽用戶的觸摸事件,根據(jù)傳入的事件類型和坐標(biāo),動態(tài)且不斷的改變 View 的坐標(biāo)。
以下Demo 在 github 均能獲取
首先,我們需要了解坐標(biāo)系的概念。
Android 坐標(biāo)系
在現(xiàn)實中,要描述一個物體的運(yùn)動,就需要一個參考系。所謂的滑動,就是相對于參考系的運(yùn)動。在 Android 中,將屏幕的左上角頂點(diǎn)作為 Android 坐標(biāo)系的原點(diǎn),從這個原點(diǎn)往右為 X 軸的正方向,從這個點(diǎn)往下是 Y 軸的正方向。

在觸控事件中,使用 getRawX() 以及 getRawY() 可以獲取到當(dāng)前觸摸點(diǎn)相對于 Android 坐標(biāo)系的坐標(biāo)。
視圖坐標(biāo)系
除了上面說的這種坐標(biāo)系之外,還有一個視圖坐標(biāo)系。跟 Android 坐標(biāo)系類似,也是從這個原點(diǎn)往右為 X 軸的正方向,從這個點(diǎn)往下是 Y 軸的正方向,但是原點(diǎn)的位置不再是屏幕的左上角頂點(diǎn),而是父視圖的左上角坐標(biāo)原點(diǎn),找觸控事件中可以使用 getX() 以及 getY() 獲取到當(dāng)前觸摸點(diǎn)相對于視圖坐標(biāo)系中的坐標(biāo)。

觸控事件 -- MotionEvent
觸控事件 MotionEvent 在用戶交互中十分重要的,MotionEvent 中封裝了一些事件常量:
觸摸按下動作
ACTION_DOWN
觸摸移動動作
ACTION_MOVE
觸摸動作取消
ACTION_CANCEL
觸摸動作離開
ACTION_UP
一般我們會在 onTouchEvent(MotionEvent event) 方法中通過傳進(jìn)來的 MotionEvent 引用的 getAction 方法來獲取時間的類型,并用 switch-case 的方法來進(jìn)行篩選,根據(jù)不同的時間進(jìn)行不同的邏輯操作。
通常模板如下:
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
在 Android 中提供了很多的方法來獲取坐標(biāo)值,相對距離等,下面總結(jié)了一些 api 來看看在不同的坐標(biāo)系下面應(yīng)該如何使用。
這些方法可以分成如下兩個類型:
- View 提供的獲取坐標(biāo)方法
- getTop:獲取到的是 View 自身的頂邊到父布局頂邊的距離
- getLeft:獲取到的是 View 自身的左邊到父布局左邊的距離
- getRight:獲取到的是 View 自身的右邊到父布局右邊的距離
- getBottom:獲取到的是 View 自身的底邊到父布局底邊的距離
- MotionEvent 提供的獲取坐標(biāo)方法
- getX : 獲取觸摸點(diǎn)距離當(dāng)前控件左邊的距離,也就是視圖坐標(biāo)
- getY : 獲取觸摸點(diǎn)距離當(dāng)前控件頂邊的距離,也就是視圖坐標(biāo)
- getRawX : 獲取觸摸點(diǎn)距離屏幕左邊的距離,也就是絕對坐標(biāo)
- getRawY : 獲取觸摸點(diǎn)距離屏幕頂邊的距離,也就是絕對坐標(biāo)

實現(xiàn)滑動的幾種辦法
現(xiàn)在已經(jīng)了解關(guān)于坐標(biāo)系和觸控事件了,再來看看如何實現(xiàn)動態(tài)的修改一個 View 的坐標(biāo),即實現(xiàn)滑動效果。不管采用哪一種方式,實現(xiàn)的思路其實都是大致相同的。就是當(dāng)觸摸 View 的時候,系統(tǒng)記下當(dāng)前觸摸點(diǎn)的坐標(biāo);當(dāng)手指一動的時候,系統(tǒng)記下移動后的觸摸點(diǎn)坐標(biāo),兩次的相差就是這次移動的偏移量,然后通過偏移量來修改 View 的坐標(biāo)。這樣不斷重復(fù),就實現(xiàn)了滑動的過程。

下面通過一個簡單的實例來實現(xiàn)這個效果,就是 View 隨著手指的滑動而滑動。這里我們需要自定義一個 View 并且重寫他的 onTouchEvent 方法。

layout 方法
在 View 繪制的過程中,會調(diào)用 onLayout 方法來定位,同樣,我們也可以手動調(diào)用此方法來對 View 進(jìn)行手動的坐標(biāo)定位。根據(jù)前面提供的思路,在按下的時候先保存一次觸摸按下時的坐標(biāo)。
@Override public boolean onTouchEvent(MotionEvent event) {
//檢測到觸摸事件后 第一時間得到相對于父控件的觸摸點(diǎn)坐標(biāo) 并賦值給x,y
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
//觸摸事件中繞不開的第一步,必然執(zhí)行,將按下時的觸摸點(diǎn)坐標(biāo)賦值給 lastX 和 last Y
case MotionEvent.ACTION_DOWN:
lastX = x;
lastY = y;
break;
//觸摸事件的第二步,這時候的x,y已經(jīng)隨著滑動操作產(chǎn)生了變化,用變化后的坐標(biāo)減去首次觸摸時的坐標(biāo)得到 相對的偏移量
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
//使用 layout 進(jìn)行重新定位
layout(getLeft() + offsetX, getTop() + offsetY, getRight() + offsetX, getBottom() + offsetY);
break;
}
return true;
}
以上,通過代碼中的注釋,應(yīng)該都能明白操作的步驟了。
offsetLeftAndRight 與 offsetTopAndBottom
這兩個方法相當(dāng)于系統(tǒng)提供的一個對左右上下移動的 API 封裝,得到偏移量之后使用如下代碼就可以完成移動。
//使用 offsetLeftAndRight 和 offsetLeftAndRight 進(jìn)行偏移,從而移動view
offsetLeftAndRight(offsetX);
offsetTopAndBottom(offsetY);
LayoutParams
LayoutParams 保存了一個 View 的布局參數(shù),通過改變 LayoutParams 來動態(tài)的修改一個布局的位置參數(shù),從而達(dá)到改變 View 位置的效果。我們可以很方便的在程序中使用 getLayoutParams 來獲取一個 View 的 LayoutParams。得到偏移量后,就可以通過 setLayoutParams 來改變。
RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin =getLeft()+offsetX;
layoutParams.topMargin = getTop()+ offsetY;
setLayoutParams(layoutParams);
這里的 RelativeLayout.LayoutParams 是根據(jù)你的父布局而定的 如果是 LinearLayout 的話就用 LinearLayout 的 LayoutParams。 當(dāng)然了 如果你連父布局都沒有,當(dāng)我沒說,那樣是不能用這個方法的。
scrollTo 與 scrollBy
這里其實 scrollTo 和 scrollBy 使用起來很簡單,但是理解起來稍微復(fù)雜一點(diǎn)。scrollTo 是直接移動到指定的坐標(biāo),而 scrollBy 是根據(jù)偏移量進(jìn)行相對移動。但是需要注意的是,這兩個都不是直接移動 View ,而是移動 View 中的 content ,比如 textView 中移動的是文字,imagView 中移動的是圖片,移動的是內(nèi)容,而不是本體。所以,我們應(yīng)該在想要移動的 View 的父布局中去使用它,用它來移動 ViewGroup 中的子 View。
上面說的只是其中一個難點(diǎn),還有一個難點(diǎn)就是參考系不同。
這樣理解吧,ViewGroup 是一個長方形的相框,在相框背后是一塊巨大的幕布,那么我們看到的內(nèi)容,就是相框中所能囊括下的內(nèi)容,在使用 scrollBy 進(jìn)行移動的時候,移動的是整個相框,而相框里的內(nèi)容沒動,但是因為相框移動了,所以內(nèi)容的位置也發(fā)生了變化。我們按照 X 軸將相框左邊移動的話,那相框中的內(nèi)容是在往右移動,所以在使用 scrollBy 的時候,內(nèi)容是往反方向運(yùn)動的,這里如果需要改為符合我們預(yù)期的移動方式,那么只需要將 scrollBy 的參數(shù)設(shè)置為負(fù)數(shù)即可。

看看圖中星星的位置就能理解了,我們往正方向右下角移動了坐標(biāo),但是左圖中星星的位置在可視區(qū)域中是在往左上角移動,這是相反的。
((View) getParent()).scrollBy(-offsetX, -offsetY);
以上,就是使用 scrollBy 來進(jìn)行移動,注意參數(shù)使用負(fù)數(shù)即可。
Scroller
前面我們使用的不管是 scrollBy 還是 scrollTo ,移動其實都是在一瞬間完成的,只是因為我們的觸摸動作不斷觸發(fā),View 不斷改變位置,造成了一個過度動畫的假象。如果使用一個按鈕,比如點(diǎn)擊按鈕就會把 View 往右移動 100 像素,那么你會發(fā)現(xiàn)此時的 View 是瞬間變換位置,并不是慢慢移動到指定位置。這時候我們就需要 Scroller , Scroller 的內(nèi)部其實也是用 scrollTo 方法來實現(xiàn)的,但是它可以根據(jù)需要移動的總距離,以及設(shè)置的移動時間,計算出每一次需要移動的距離,然后不斷的進(jìn)行移動,這樣就實現(xiàn)了一個動畫的效果。

以下的流程圖是從手指抬起后開始。
下面我們使用 Scroller 來實現(xiàn)松開手指后 View 回到原來的位置的效果

我們來看看以下代碼,基本所有的代碼的注釋我都已經(jīng)寫上,結(jié)合代碼理解應(yīng)該就能明白:
public class TestView1 extends View {
//定義兩個變量用于存儲按下view時所處的坐標(biāo)
int lastX = 0;
int lastY = 0;
//滑動~
Scroller scroller;
public TestView1(Context context, AttributeSet attrs) {
super(context, attrs);
scroller = new Scroller(context);
}
@Override public boolean onTouchEvent(MotionEvent event) {
//檢測到觸摸事件后 第一時間得到相對于父控件的觸摸點(diǎn)坐標(biāo) 并賦值給x,y
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
//觸摸事件中繞不開的第一步,必然執(zhí)行,將按下時的觸摸點(diǎn)坐標(biāo)賦值給 lastX 和 last Y
case MotionEvent.ACTION_DOWN:
lastX = x;
lastY = y;
break;
//觸摸事件的第二步,這時候的x,y已經(jīng)隨著滑動操作產(chǎn)生了變化,用變化后的坐標(biāo)減去首次觸摸時的坐標(biāo)得到 相對的偏移量
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
((View) getParent()).scrollBy(-offsetX, -offsetY);
break;
//觸摸事件的第三步,必然執(zhí)行,手指抬起時候觸發(fā),這里會將移動過的view還原到原來的位置,并且有過度效果不是突然移動
case MotionEvent.ACTION_UP:
//因為下面要使用父視圖的引用來得到偏移量 所以要獲得一個父視圖引用
View viewGroup = (View) getParent();
//調(diào)用 startScroll 方法,參數(shù)為 起始X坐標(biāo),起始Y坐標(biāo),目的X坐標(biāo),目的Y坐標(biāo),過度動畫持續(xù)時間
//這里使用了 viewGroup.getScrollX() 和 viewGroup.getScrollY() 作為起始坐標(biāo),ScrollY 和 ScrollX 記錄了使用 scrollBy 進(jìn)行偏移的量
//所以使用他們就等于是使用了現(xiàn)在的坐標(biāo)作為起始坐標(biāo),目的坐標(biāo)為他們的負(fù)數(shù),就是偏移量為0的位置,也是view在沒有移動之前的位置
scroller.startScroll(viewGroup.getScrollX(),
viewGroup.getScrollY(),
-viewGroup.getScrollX(),
-viewGroup.getScrollY(),
800);
//刷新view,這里很重要,如果不執(zhí)行,下面的 computeScroll 方法就不會執(zhí)行 computeScroll 方法是由 onDraw 方法調(diào)用的,而刷新 View 會調(diào)用 onDraw。
invalidate();
break;
}
return true;
}
@Override public void computeScroll() {
//在上面嘗試刷新視圖之后被調(diào)用,并且執(zhí)行了 computeScrollOffset 方法,
//此方法根據(jù)上面?zhèn)鬟M(jìn)來的起始坐標(biāo)和目的坐標(biāo)還有動畫時間,進(jìn)行計算每次移動的偏移量
//如果到達(dá)目的坐標(biāo) false ,如果不為零 說明沒有到達(dá)目的坐標(biāo)
if (scroller.computeScrollOffset()) {
//使用 scrollTo 方法進(jìn)行移動,參數(shù)是從 scroller 的 getCurrX 以及 getCurrY 方法得到的,
// 這兩個參數(shù)每次在執(zhí)行 computeScrollOffset 之后都會改變,會越來越接近目的坐標(biāo)。
((View) getParent()).scrollTo(scroller.getCurrX(), scroller.getCurrY());
// 再次刷新 view 也等于是在循環(huán)執(zhí)行此方法 直到 computeScrollOffset 判斷到達(dá)目的坐標(biāo)為止,
// 循環(huán)次數(shù)和每次移動的坐標(biāo)距離相關(guān),每次移動的坐標(biāo)距離又跟目的坐標(biāo)的距離和動畫時長有關(guān)
//通常距離越長,動畫時間越長,循環(huán)次數(shù)越多
invalidate();
}
}
}