和我一個超級要好的朋友聊起自定義view和手勢滑動,正好群裡好多小夥伴總是問關於onTouchEvent()與Scroller的處理,所以就正好寫一篇這樣的部落格,希望可以幫到需要的朋友。
今天的效果非常非常的簡單,所以只能說是入門級,重在理解其中的精髓,今天主要講兩個東西,一個是View#onTouchEvent(MotionEvent)
方法,另一個是Scroller
類,一般涉及到手勢操作的都離不開它倆。
本demo原始碼下載:download.csdn.net/detail/yanz…
效果預覽
原理分析與知識普及
不講道理的說,我們不是要做這兩個才分析,而是因為分析了View#onTouchEvent(MotionEvent)
和Scroller
才做出的這兩個,所以且聽我細細道來。
scrollTo(int, int)與scrollBy(int, int)
我們要發生滾動就的知道View
的兩個方法:View#scrollTo(int, int)
和View#scrollBy(int, int)
,這兩個方法都是讓View來發生滾動的,他們有什麼區別呢?
View#scrollTo(int, int)
讓View
的content
滾動到相對View
初始位置的(x, y)
處。View#scrollBy(int, int)
讓View
的content
滾動到相對於View
當前位置的(x, y)
處。
不知道你理解了木有?什麼,還沒理解?好那我們來一個sample,先來看看佈局:
複製程式碼
這是java程式碼:
ViewGroup mContentRoot;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
...
mContentRoot = (ViewGroup) findViewById(R.id.content_scroll_method);
findViewById(R.id.btn_scroll_to).setOnClickListener(this);
findViewById(R.id.btn_scroll_by).setOnClickListener(this);
}
@Override
private void onClick(View v) {
int id = v.getId();
switch (id) {
case R.id.btn_scroll_to: {
mContentRoot.scrollTo(100, 100);
break;
}
case R.id.btn_scroll_by: {
mContentRoot.scrollBy(10, 20);
break;
}
}
}複製程式碼
這個很好理解了,點選scrollTo()
按鈕的時候呼叫Layout
的scrollTo(int, int)
放,讓Layout
的content
滾動到相對Layout
初始位置的(100, 100)
處;點選scrooBy()
按鈕的時候呼叫Layout
的scrollBy(int, int)
讓Layout
的content
滾動到相對Layout
當前位置的(10, 20)
處,來看看效果吧:
我們發現點選scrollTo()
按鈕的時候,滾動了一下,然後再點就不動了,因為此時Layout
的content
已經滾動到相對於它初始位置的(100,100)
處了,所以再點它還是到這裡,所以再次點選就看起來不動了。
點選scrollBy()
按鈕的時候,發現Layout
的content
一直有在滾動,是因為無論何時,content
的相對位置與當前位置都是不同的,所以它總是會去到一個新的位置,所以再次點選會一直滾動。
注意:這裡我們也發現scrollTo(int, int)
與scrollBy(int, int)
傳入的值都是正數,經過我實驗得出,x傳入正數則向左移動,傳入負數則向右移動;y傳入正數則向上移動,傳入負數則向下移動,且這個xy的值是畫素。這裡和Android座標系是相反的,不日我將新開一篇部落格來專門講這個問題。
我們理解了View#scrollTo(int, int)
和View#scrollBy(int, int)
後結合View#onTouchEvent(MotionEvent)
就可以做很多事了。
View#onTouchEvent(MotionEvent)
對於View#onTouchEvent(MotionEvent)
方法,它是當View
接受到觸控事件時被呼叫(暫不關心事件分發),第一我們從它可以拿到DOWN
、MOVE
、UP
、CANCEL
幾個關鍵事件,第二我們可以拿到每個DOWN
等事件發生時手指在螢幕上的位置和手指在View
內的位置。基於此我們可以想到做很多事,假如我們在手指DOWN
時記錄手指的xy,在MOVE
時根據DOWN
時的xy來計算手指滑動的距離,然後讓View
發生一個移動,在手指UP/CANCEL
時讓View回到最開始的位置,因此我們做了第一個效果,下面來做具體的程式碼分析。
我們定義一個ScrollLayout,然後繼承自LinearLayout
,在xml中引用,然後在ScrollLayout
中放一個TextView
,並讓內容居中:
複製程式碼
佈局就是這樣的,根據上面的分析我們實現ScrollLayout
的具體程式碼,請看:
// 手指最後在View中的座標。
private int mLastX;
private int mLastY;
// 手指按下時View的相對座標。
private int mDownViewX;
private int mDownViewY;
@Override
public boolean onTouchEvent(MotionEvent event) {
// 第一步,記錄手指在view的座標。
int x = (int) event.getRawX();
int y = (int) event.getRawY();
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN: {
// 記錄View相對於初始位置的滾動座標。
mDownViewX = getScrollX();
mDownViewY = getScrollY();
// 更新手指此時的座標。
mLastX = x;
mLastY = y;
return true;
}
case MotionEvent.ACTION_MOVE: {
// 計算手指此時的座標和上次的座標滑動的距離。
int dy = y - mLastY;
int dx = x - mLastX;
// 更新手指此時的座標。
mLastX = x;
mLastY = y;
// 滑動相對距離。
scrollBy(-dx, -dy);
return true;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
scrollTo(mDownViewX, mDownViewY);
return true;
}
}
return super.onTouchEvent(event);
}複製程式碼
那麼這裡再來說明兩個方法:
View#getScrollX()
獲取View
相對於它初始位置X方向的滾動量。View#getScrollY()
獲取View
相對於它初始位置Y方向的滾動量。
根據我們上面的分析,這裡處理了四個事件,分別是:
- MotionEvent.ACTION_DOWN
- MotionEvent.ACTION_MOVE
- MotionEvent.ACTION_UP
- MotionEvent.ACTION_CANCEL
第一步,因為
ACTION_DOWN
、ACTION_MOVE
中都需要記錄手指當前座標,所以一進入就記錄了event.getRawX()
和event.getRawY()
。第二步,
ACTION_DOWN
手指按下時被呼叫,在一次觸控中只會被呼叫一次,在ACTION_DOWN
的時候記錄了content
相對於最開始滾動的座標getScrollX()
和getScrollY()
,在我們我們手指鬆開時它滾動了多少getScrollX()
和多少getScrollY()
,那麼我們就呼叫scrollTo(int, int)
滾動多少-getScrollX()
和多少-getScrollY()
,這樣它不就回到初始位置了嗎?同時記錄了手指此時的座標,用來在ACTION_MOVE
的時候計算第一次ACTION_MOVE
時的移動距離。第三步,
ACTION_MOVE
會在手指移動的時候呼叫,所以它會呼叫多次,所以每次需要計算與上次的手指座標的滑動距離,並且更新本次的手指座標,然後呼叫scrollBy(int, int)
去滑動當前手指與上次手指的座標(當前View
的位置)的距離。第四步,
ACTION_UP
在手指抬起時被呼叫,ACTION_CANCEL
在手指滑動這個View
的區域時被呼叫,此時我們呼叫scrollTo(int, int)
回到最初的位置。
我們來看看效果:
嗯效果已經實現了,但是我們發現和開頭演示的效果有點出入,就是手指鬆開時View
一下子就回去了而不是平滑的回到最初的位置,因此我們需要用到Scroller
。
Scroller
Scroller
是手指滑動中比較重要的一個輔助類,可以輔助我們完成一些動畫引數的計算等,下面把它的幾個重要的方法做個簡單解釋。
Scroller#startScroll(int startX, int startY, int dx, int dy)
Scroller#startScroll(int startX, int startY, int dx, int dy, int duration)
這倆方法幾乎是一樣的,用來標記一個View
想要從哪裡移動到哪裡。startX
,x方向從哪裡開始移動。startY
,y方向從哪裡開始移動。dx
,x方向移動多遠。dy
,y方向移動多遠。duration
,這個移動操作需要多少時間執行完,預設是250毫秒。
當然光這個方法是不夠的,它只是標記一個位置和時間,那麼怎麼計算呢?
Scroller#computeScrollOffset()
這個方法用來計算當前你想知道的一個新位置,Scroller
會自動根據標記時的座標、時間、當前位置計算出一個新位置,記錄到內部,我們可以通過Scroller#getCurrX()
和Scroller#getCurrY()
獲取的新的位置。要知道的是,它計算出的新位置是一個閉區間
[x, y]
,而且會在你呼叫startScroll
傳入的時間內漸漸從你指定的int startX
和int startY
移動int dx
和int dy
的距離,所以我們每次呼叫Scroller#computeScrollOffset()
後再呼叫View
的scrollTo(int, int)
然後傳入Scroller#getCurrX()
和Scroller#getCurrY()
就可以得到一個漸漸移動的效果。同時這個方法有一個返回值是
boolean
型別的,內部是用一個boolean
來記錄是否完成的,在呼叫Scroller#startScroll)
時會把這個boolean
引數置為false
。內部邏輯是先判斷startScroll()
動畫是否還在繼續,如果沒有完成則計算最新位置,計算最新位置前會對duration
做判斷,第一如果時間沒到,則真正的計算位置,並且返回true,第二如果時間到了,把記錄是否繼續的boolean
成員變數標記完成,並直接賦值最新位置為最終目的位置,並且返回true;如果startScroll()
已經完成則直接返回false。我們判斷Scroller#computeScrollOffset()
是true時說明還沒完成,此時拿到Scroller#getCurrX()
和Scroller#getCurrY()
做一個滾動,待會程式碼中可以看到這個邏輯。Scroller#getCurrX()
Scroller#getCurrY()
這兩個方法就是拿到通過Scroller#computeScrollOffset()
計算出的新的位置,上面也解釋過了。Scroller.isFinished()
上次的動畫是否完成。Scroller.abortAnimation()
取消上次的動畫。
這裡要強調的是Scroller.isFinished()
和一般是配套使用的,一般咋ACTION_DWON
的時候判斷是否完成,如果沒有完成咋取消動畫。
基於此,我們完善上面的效果,讓它平滑滾動,所以我們來完善一下。
View#onTouchEvent(MotionEvent)與Scroller結合完善動畫
private Scroller mScroller;
private int mLastX;
private int mLastY;
public ScrollLayout(Context context) {
this(context, null, 0);
}
public ScrollLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScrollLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mScroller = new Scroller(context);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getRawX();
int y = (int) event.getRawY();
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN: {
if (!mScroller.isFinished()) { // 如果上次的呼叫沒有執行完就取消。
mScroller.abortAnimation();
}
mLastX = x;
mLastY = y;
return true;
}
case MotionEvent.ACTION_MOVE: {
int dy = y - mLastY;
int dx = x - mLastX;
mLastX = x;
mLastY = y;
scrollBy(-dx, -dy);
return true;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
// XY都從滑動的距離回去,最後一個引數是多少毫秒內執行完這個動作。
mScroller.startScroll(getScrollX(), getScrollY(), -getScrollX(), -getScrollY(), 1000);
invalidate();
return true;
}
}
return super.onTouchEvent(event);
}
/**
* 這個方法在呼叫了invalidate()後被回撥。
*/
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) { // 計算新位置,並判斷上一個滾動是否完成。
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();// 再次呼叫computeScroll。
}
}複製程式碼
- 第一步,在構造方法中初始化
Scroller
。 - 第二步,在
ACTION_DOWN
時去掉最開始記錄的content
的初始位置,下面講為什麼。並且判斷Scroller
的動畫是否完成,沒有完成則取消。 - 第三步,在
ACTION_MOVE
的時候呼叫滾動,讓View
跟著手指走。 第四步,在
ACTION_UP
和ACTION_CANCEL
時讓View
平滑滾動到最初位置。
根據上面Scroller
的分析,這裡可以呼叫Scroller#startScroll(startX, startY, dx, dy, duration)
記錄開始位置,和滑動的距離以及指定動畫完成的時間。(startX, startY)
傳入當前content
的相對與最開始滾動的位置(getScrollX(), getScrollY())
。(dx, dy)
要傳入要平滑滑動的距離,那麼傳什麼呢?既然它滾動了(getScrollX(), getScrollY())
,那麼我們就讓它滾這麼多的距離回去不久行了?所以我們傳入(-getScrollX(), -getScrollY())
。- duration滾動時間,我們傳個800毫秒,1000毫秒的都可以,預設是250毫秒。
第五步,呼叫
invalidate()/postInvalidate()
重新整理View
,最底層View
會呼叫一系列方法,這裡我們重寫其中computeScroll()
方法。- 我們看到
invalidate()
和postInvalidate()
,invalidate()
在當前執行緒呼叫,也就是主執行緒,這裡我們使用invalidate()
;postInvalidate()
一般在子執行緒需要重新整理View
時呼叫。 computeScroll()
方法是用來計算滾動的,我們平滑滾動時不就是要它麼。
- 我們看到
- 第六步,根據上面
Scroller
的分析,在computeScroll()
中此時呼叫Scroller.computeScrollOffset()
再好不過了,計算出一個新的相對位置,然後呼叫scrollTo(int, int)
滑動過去。 - 第七步,在
computeScroll()
中scrollTo(int, int)
後呼叫invalidate()
computeScroll重新整理檢視,呈現出一個動畫的效果。
View#onTouchEvent(MotionEvent)與Scroller再升級
View#onTouchEvent(MotionEvent)
與Scroller
結合再升級,這一節是基於上一節的,如果你沒看上一節,那麼最好看完再看這個,不然非常可能看不懂。下面我們來完成文中開頭的第二個效果,一個模擬ViewPager
翻頁且加彈性動畫的效果。
上面的自定義ScrollLayout
是繼承LinearLayout
的,下面我們新建一個ScrollPager
的繼承ViewGroup
,來完成目標:
public class ScrollPager extends ViewGroup {
public ScrollPager(Context context) {
this(context, null, 0);
}
public ScrollPager(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScrollPager(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
}複製程式碼
然後我們把佈局寫好,放三個Layout
,高度為100dp
,寬度都為match_parent
:
複製程式碼
佈局蠻簡單了,就是一個ViewGroup
中三個高度為100dp
,寬度都為match_parent
的LinearLayout
,寬度為match_parent
是為了佔滿一屏的寬。然後每個LinearLayout
中一個TextView
,分別為第一頁、第二頁、第三頁。
分析一下,ViewPager
首先要每一屏一個Layout/View
,加上繼承ViewGroup
必須要重寫ViewGroup#onLayout()
,ViewGroup#onLayout()
是用來佈局子View
的,也就是在它裡面決定哪個View
放在哪裡。
為了新建的ScrollPager
中的View
橫向鋪開,所以我們接著實現ScrollPager#onLayout()
,但是要想佈局子View
,就得知道子View
的寬高,所以先要測量寬高,因此還得重寫ScrollPager#onMeasure
方法測量View大小
,因此我們有了下面的程式碼:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = getChildCount();
// 在Layout 子view之前測量子view大小,在layout的時候才能呼叫getMeasuredWidth()和getMeasuredHeight()。
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (changed) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
int childW = childView.getMeasuredWidth();
// 把所有子view放在水平方向,依次排開。
// left: 0, w, 2w, 3w..
// top: 0...
// right: w, 2w, 3w...
// topL h...
childView.layout(i * childW, 0, childW * i + childW, childView.getMeasuredHeight());
}
}
}複製程式碼
onMeasure()
沒神馬好解釋的,就是挨個測量子View
的大小,如果細節不懂可以自行搜尋。那麼onLayout()
中呼叫子View
的View#layout()
方法把子View
佈局到ScrollPager
上,並且依次橫向排開。
然後我們把'onTouchEvent()'中的滑動處理一下:
// 手指每次移動時需要更新xy,記錄上次手指所處的座標。
private float mLastX;
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getRawX();
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastX = x;
return true;
case MotionEvent.ACTION_MOVE:
int dxMove = (int) (mLastX - x);
scrollBy(dxMove, 0);
mLastX = x;
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
// 鬆開時處理慣性滑動。
break;
}
}
return super.onTouchEvent(event);
}複製程式碼
這裡我們只是沒有處理ACTION_UP
和ACTION_CANCEL
事件,我們來執行一把看看:
哦喲,出來了,可是沒有像ViewPager
那樣鬆開時自動動切換到某一頁,所以我們還要處理ACTION_UP
和ACTION_CANCEL
事件。
要想有鬆開時平滑滑動到某一頁,我們分析一下,肯定是需要Scroller
的,然後還要重寫View#computeScroll()
方法,下面是完成的程式碼:
private Scroller mScroller;
// 手指每次移動時需要更新xy,記錄上次手指所處的座標。
private float mLastX;
public ScrollPager(Context context) {
this(context, null, 0);
}
public ScrollPager(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ScrollPager(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mScroller = new Scroller(context);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getRawX();
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) { // 如果上次的呼叫沒有執行完就取消。
mScroller.abortAnimation();
}
mLastX = x;
return true;
case MotionEvent.ACTION_MOVE:
int dxMove = (int) (mLastX - x);
scrollBy(dxMove, 0);
mLastX = x;
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
int sonIndex = (getScrollX() + getWidth() / 2) / getWidth();
// 如果滑動超過最後一頁,就退回到最後一頁。
int childCount = getChildCount();
if (sonIndex >= childCount)
sonIndex = childCount - 1;
// 現在滑動的相對距離。
int dx = sonIndex * getWidth() - getScrollX();
// Y方向不變,X方向到目的地。
mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate();
break;
}
}
return super.onTouchEvent(event);
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = getChildCount();
// 在Layout 子view之前測量子view大小,在onLayout的時候才能呼叫getMeasuredWidth()和getMeasuredHeight()。
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (changed) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
int childW = childView.getMeasuredWidth();
// 把所有子view放在水平方向,依次排開。
// left: 0, w, 2w, 3w..
// top: 0...
// right: w, 2w, 3w...
// topL h...
childView.layout(i * childW, 0, childW * i + childW, childView.getMeasuredHeight());
}
}
}複製程式碼
這裡需要解釋的只有這一段程式碼:
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
int sonIndex = (getScrollX() + getWidth() / 2) / getWidth();
// 如果滑動頁面超過當前頁面數,那麼把屏index定為最大頁面數的index。
int childCount = getChildCount();
if (sonIndex >= childCount)
sonIndex = childCount - 1;
// 現在滑動的相對距離。
int dx = sonIndex * getWidth() - getScrollX();
// Y方向不變,X方向到目的地。
mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate();
break;
}複製程式碼
當手指鬆開的時候怎麼平滑過度到某一頁呢?
先來看
int sonIndex = (getScrollX() + getWidth() / 2) / getWidth();
,這句話的意思是拿到從最開始滑動到當前位置的距離 加上Layout
一半的Layout
寬 除以Layout
寬,得到的結果是在螢幕上顯示的較多區域的這一屏的子View
的index。是什麼意思呢?,舉個例子來說,當前向左滑動了一屏,那麼
getScrollX()
的距離和getWidth
的寬度就是相等的,因為滑動了一屏的距離,這個時候如果直接用getScrollX()/getWidth()
那麼得到的結果是1沒有問題。如果現在從0屏開始滑,滑了小半屏,此時的
getScrollX() < getWidth()
,那麼計算出的int必將是0,假如我滑了大半屏,此時計算出的結果又是0,但是根據慣性和四捨五入,我們滑動大半屏的時候,應該跑到下一屏,所以我們在getScrollX()/getWidth()
之前給getScrollX()
加了getWidth()/2
的距離,這樣不滿一屏的將會自動補滿一屏。然後
int dx = sonIndex * getWidth() - getScrollX();
,目標位置的距離sonIndex * getWidth()
減掉已經滑動的距離getScrollX()
得出的現在要滑動的相對距離。
此時執行一把,我們將得到正確的效果: