Android 開發(fā)中經(jīng)常涉及 View 的滾動础嫡,例如類似于 ScrollView 的滾動手勢和滾動動畫指么,例如用 ListView 模仿 iOS 上的左滑刪除 item,例如 ListView 的下拉刷新榴鼎。這些都是常見的需求伯诬,同時也都涉及 View 滾動的相關知識。
本文將解析 Android 中 View 的滾動原理檬贰,并介紹與滾動相關的兩個輔助類 Scroller
和 VelocityTracker
姑廉,并通過 3 個逐漸深入的例子來加深理解。
注:
- 本文沒有嘗試實現(xiàn)上述幾種功能翁涤,只闡述基本原理和基礎類的使用方法桥言。
- 文中的例子只是截取了與 View 相關的代碼,完整的示例代碼請見DEMO
- 本文的源碼分析基于 Android API Level 21葵礼,并省略掉部分與本文關系不大的代碼号阿。
View 的滾動原理
在了解 View 的滾動原理之前,我們先來想象一個場景:我們坐在一個房間里鸳粉,透過一扇窗戶看窗外的風景扔涧。窗戶是有大小限制的,而風景是沒有大小限制的届谈。
把上述的場景對應到 Android 的 View 顯示原理上來:當一個 View 顯示在界面上枯夜,它的上下左右邊緣就圍成了這個 View 的可視區(qū)域,我們可以稱這個區(qū)域為“可視窗口”艰山,我們平時看到的 View 的內容湖雹,都是透過這個可視窗口中看到的“風景”。View 的大小內容可以無窮大曙搬,不受可視窗口大小的限制摔吏。
另外,如果在窗外的風景中纵装,有一個人出現(xiàn)在窗戶右邊很遠的地方征讲,那么我們在房間里就看不到那個人;如果那個人站在窗戶正對著出去的地方橡娄,那么我們就可以透過窗戶看到他诗箍。對應到 View 上面來,只有出現(xiàn)在“可視窗口”中的那部分內容可以被看到挽唉。
View 的 scroll 相關
在 View 類中扳还,有兩個變量 mScrollX
和 mScrollY
才避,它們記錄的是 View 的內容的偏移值。mScrollX
和 mScrollY
的默認值都是 0氨距,即默認不偏移。另外我們需要知道一點棘劣,向左滑動俏让,mScrollX
為正數(shù),反正為負數(shù)茬暇。假設我們令 mScrollX = 10
首昔,那么該 View 的內容會相對于原來向左偏移 10px。 看看系統(tǒng)的 View 類中的源碼:
// View.java
public class View {
/**
* The offset, in pixels, by which the content of this view is scrolled
* horizontally.
* {@hide}
*/
protected int mScrollX;
/**
* The offset, in pixels, by which the content of this view is scrolled
* vertically.
* {@hide}
*/
protected int mScrollY;
// ...
}
通常我們比較少直接設置 mScrollX
和 mScrollY
糙俗,而是通過 View 提供的兩個方法來設置勒奇。
// 瞬時滾動到某個位置
public void scrollTo(int x, int y)
// 瞬時滾動某個距離
public void scrollBy(int x, int y)
看看兩個方法的源碼:
// View.java
/**
* Set the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
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();
}
}
}
/**
* Move the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the amount of pixels to scroll by horizontally
* @param y the amount of pixels to scroll by vertically
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
首先看 scrollTo(int x, int y)
方法,它除了設置 mScrollX
和 mScrollY
兩個變量巧骚,還會觸發(fā)自己重新繪制赊颠,另外還會通過 onScrollChanged
觸發(fā)回調。而 scrollBy
方法其實也是調用 scrollTo
方法劈彪。
明顯竣蹦,兩個方法的區(qū)別在于 scrollTo
方法是滾動到特定位置,參數(shù) x
沧奴、y
代表“絕對位置”痘括,而 scrollBy
方法是在當前位置基礎上滾動特定距離,參數(shù) x
滔吠、y
代表“相對位置”纲菌。
另外,View 還提供了 mScrollX
和 mScrollY
的 getter:
// 獲取 mScrollX
public final int getScrollX()
// 獲取 mScrollY
public final int getScrollY()
看看源碼中這兩個方法的注釋疮绷,可以更好地理解 scroll 的概念翰舌。
// View.java
/**
* Return the scrolled left position of this view. This is the left edge of
* the displayed part of your view. You do not need to draw any pixels
* farther left, since those are outside of the frame of your view on
* screen.
*
* @return The left edge of the displayed part of your view, in pixels.
*/
public final int getScrollX() {
return mScrollX;
}
/**
* Return the scrolled top position of this view. This is the top edge of
* the displayed part of your view. You do not need to draw any pixels above
* it, since those are outside of the frame of your view on screen.
*
* @return The top edge of the displayed part of your view, in pixels.
*/
public final int getScrollY() {
return mScrollY;
}
例子1
為了更好地理解 mScrollX
和 mScrollY
,也為后續(xù)介紹的知識做準備矗愧,我們先看一個例子:
/**
* 示例:自定義 ViewGroup灶芝,包含幾個一字排開的子 View,
* 每個子 View 都與該 ViewGroup 一樣大唉韭。
* 調用 moveToIndex 方法會調用 scrollTo 方法夜涕,從而瞬時滾動到某一位置
*/
public class Case1ViewGroup extends ViewGroup {
public static final int CHILD_NUMBER = 6;
private int mCurrentIndex = 0;
public Case1ViewGroup(Context context) {
super(context);
init();
}
public Case1ViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public Case1ViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
// 添加幾個子 View
for (int i = 0; i < CHILD_NUMBER; i++) {
TextView child = new TextView(getContext());
int color;
switch (i % 3) {
case 0:
color = 0xffcc6666;
break;
case 1:
color = 0xffcccc66;
break;
case 2:
default:
color = 0xff6666cc;
break;
}
child.setBackgroundColor(color);
child.setGravity(Gravity.CENTER);
child.setTextSize(TypedValue.COMPLEX_UNIT_SP, 46);
child.setTextColor(0x80ffffff);
child.setText(String.valueOf(i));
addView(child);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
// 每個子 View 都與自己一樣大
for (int i = 0, childCount = getChildCount(); i < childCount; i++) {
View childView = getChildAt(i);
childView.measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
}
setMeasuredDimension(width, height);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 子 View 一字排開
for (int i = 0, childCount = getChildCount(); i < childCount; i++) {
View childView = getChildAt(i);
childView.layout(getWidth() * i, 0, getWidth() * (i + 1), b - t);
}
}
/**
* 瞬時滾動到第幾個子 View
* @param targetIndex 要移動到第幾個子 View
*/
public void moveToIndex(int targetIndex) {
if (!canMoveToIndex(targetIndex)) {
return;
}
scrollTo(targetIndex * getWidth(), getScrollY());
mCurrentIndex = targetIndex;
invalidate();
}
/**
* 判斷移動的子 View 下標是否合法
* @param index 要移動到第幾個子 View
* @return index 是否合法
*/
public boolean canMoveToIndex(int index) {
return index < CHILD_NUMBER && index >= 0;
}
public int getCurrentIndex() {
return mCurrentIndex;
}
}
將以上這個自定義的 ViewGroup 放到 Activity 中,調用它的 moveToIndex(int targetIndex)
就可以實現(xiàn)瞬時滾動到第 n 個子 View 了属愤。(完整示例代碼見DEMO)
Scroller 類 —— 計算滾動位置的輔助類
到目前為止女器,我們已經(jīng)能通過 View 提供的方法設置 mScrollX
、mScrollY
住诸,來使 View “滾動”驾胆。但這種滾動都是瞬時的涣澡,換句話說,這種滾動都是無動畫的丧诺。實際上我們想要做到的滾動是平滑的入桂、有動畫的,就像我們不希望窗戶外面的那個人突然出現(xiàn)在窗戶中間驳阎,這樣會嚇到我們抗愁,我們更希望那個人能有一個“慢慢走進視覺范圍”的過程。
Scroller 類就是幫助我們實現(xiàn) View 平滑滾動的一個輔助類呵晚,使用方法通常是在 View 中作為一個成員變量蜘腌,用 Scroller 類來記錄/計算 View 的滾動位置,再從 Scroller 類中讀取出計算結果饵隙,設置到 View 中撮珠。這里注意一點:在 Scroller 中設置和計算 View 的滾動位置并不會影響 View 的滾動,只有從 Scroller 中取出計算結果并設置到 View 中時金矛,滾動才會實際生效芯急。
Scroller 提供了一系列方法來執(zhí)行滾動、計算滾動位置绷柒,以下列出幾個重要方法:
// 開始滾動志于,并記下當前時間點作為開始滾動的時間點
public void startScroll(int startX, int startY, int dx, int dy, int duration)
// 停止?jié)L動
public void abortAnimation()
// 計算當前時間點對應的滾動位置,并返回動畫是否還在進行
public boolean computeScrollOffset()
// 獲取上一次 computeScrollOffset 執(zhí)行時的滾動 x 值
public final int getCurrX()
// 獲取上一次 computeScrollOffset 執(zhí)行時的滾動 y 值
public final int getCurrY()
// 根據(jù)當前的時間點废睦,判斷動畫是否已結束
public final boolean isFinished()
有了這幾個方法榴都,我們容易想到如何實現(xiàn) View 的平滑滾動動畫:
- 在開始動畫時調用
startScroll
方法断凶,傳入動畫開始位置、移動距離、動畫時長录平; - 每隔一段時間前硫,調用
computeScrollOffset
方法听哭,計算當前時間點對應的滾動位置痊远; - 如果上一步返回 true,代表動畫仍在進行刚陡,則調用
getCurrX
和getCurrY
方法獲取當前位置惩妇,并調用 View 的scrollTo
方法使 View 滾動; - 不斷循環(huán)進行第 2 步筐乳,直到返回 false歌殃,代表動畫結束。
這里提到“每隔一段時間”蝙云,從直覺上我們可能覺得應該有個循環(huán)氓皱,但實際上我們可以借助 View 的 computeScroll
方法來實現(xiàn)。先看看 computeScroll
方法的源碼:
// View.java
/**
* Called by a parent to request that a child update its values for mScrollX
* and mScrollY if necessary. This will typically be done if the child is
* animating a scroll using a {@link android.widget.Scroller Scroller}
* object.
*/
public void computeScroll() {
}
看注釋可知該方法天生就是用來計算 View 的 mScrollX
和 mScrollY
值,該方法會在父 View 調用該 View 的 draw 方法之前被自動調用波材,View 類中默認沒有實現(xiàn)任何內容股淡,我們需要自己實現(xiàn)。所以我們只需要在該方法中廷区,用 Scroller 計算并設置 mScrollX
和 mScrollY
的值唯灵,并判斷如果動畫沒結束則讓該 View 失效(調用 postInvalidate()
方法),觸發(fā)下一次 computeScroll
隙轻,就可以實現(xiàn)上述循環(huán)早敬。
例子2
這個例子的 ViewGroup 繼承自例子 1 的 ViewGroup,擁有同樣的子 View大脉,區(qū)別只在于例子 2 是通過 Scroller 來滾動,實現(xiàn)了滾動的動畫水孩,而不再是瞬時滾動镰矿。
/**
* 示例:自定義一個 ViewGroup,包含幾個一字排開的子 View俘种,
* 每個子 View 都與該 ViewGroup 一樣大秤标。
* 通過 Scroller 實現(xiàn)滾動。
* 調用 moveToIndex 方法會觸發(fā) Scroller 的 startScroller宙刘,開始動畫苍姜,并使 View 失效。
* 并在 computeScroll 方法中判斷動畫是否在進行悬包,進而計算當前滾動位置衙猪,并觸發(fā)下一次 View 失效。
*/
public class Case2ViewGroup extends Case1ViewGroup {
// 滾動器
protected Scroller mScroller;
public Case2ViewGroup(Context context) {
super(context);
initScroller();
}
public Case2ViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
initScroller();
}
public Case2ViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initScroller();
}
private void initScroller() {
mScroller = new Scroller(getContext());
}
/**
* 通過動畫滾動到第幾個子 View
* @param targetIndex 要移動到第幾個子 View
*/
@Override
public void moveToIndex(int targetIndex) {
if (!canMoveToIndex(targetIndex)) {
return;
}
mScroller.startScroll(
getScrollX(), getScrollY(),
targetIndex * getWidth() - getScrollX(), getScrollY());
mCurrentIndex = targetIndex;
invalidate();
}
public void stopMove() {
if (!mScroller.isFinished()) {
int currentX = mScroller.getCurrX();
int targetIndex = (currentX + getWidth() / 2) / getWidth();
mScroller.abortAnimation();
this.scrollTo(targetIndex * getWidth(), 0);
mCurrentIndex = targetIndex;
}
}
/**
* 在 ViewGroup.dispatchDraw() -> ViewGroup.drawChild() -> View.draw(Canvas,ViewGroup,long) 時被調用
* 任務:計算 mScrollX & mScrollY 應有的值布近,然后調用scrollTo/scrollBy
*/
@Override
public void computeScroll() {
boolean isNotFinished = mScroller.computeScrollOffset();
if (isNotFinished) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
}
將以上這個自定義的 ScrollerViewGroup 放到 Activity 中垫释,調用它的 moveToIndex(int targetIndex)
就可以實現(xiàn)滾動到第 n 個子 View 了。(在 Activity 中使用的完整示例代碼見DEMO)
VelocityTracker —— 計算滾動速度的輔助類
到目前為止撑瞧,我們已經(jīng)可以實現(xiàn) View 平滑的滾動動畫棵譬,那么如果我們還想根據(jù)用戶手指在 View 上滑動的速度和距離來控制 View 的滾動,應該怎么做预伺?Android 系統(tǒng)提供了另一個輔助類 VelocityTracker 來實現(xiàn)類似功能订咸。
VelocityTracker 是一個速度跟蹤器,通過用戶操作時(通常在 View 的 onTouchEvent 方法中)傳進去一系列的 Event酬诀,該類就可以計算出用戶手指滑動的速度脏嚷,開發(fā)者可以方便地獲取這些參數(shù)去做其他事情×侠模或者手指滑動超過一定速度并松手然眼,就觸發(fā)翻頁。
看看 VelocityTracker 類提供的幾個常用的方法葵腹,這些方法分為幾類:
-
初始化和銷毀:
// 由系統(tǒng)分配一個 VelocityTracker 對象高每,而不是 new 一個 static public VelocityTracker obtain()
- // 使用完畢時調用該方法回收 VelocityTracker 對象 public void recycle()
-
添加 Event 以供追蹤:
// 不斷調用該方法傳入一系列 event屿岂,記錄用戶的操作 public void addMovement(MotionEvent event)
-
計算速度:
// 計算調用該方法的時刻對應的速度,傳入的是速度的計時單位 public void computeCurrentVelocity(int units)
// 調用 computeCurrentVelocity 方法后就可以通過該方法獲取之前計算的 x 方向速度 public float getXVelocity()
// 調用 computeCurrentVelocity 方法后就可以通過該方法獲取之前計算的 y 方向速度 public float getYVelocity()
例子3
下面通過一個例子來看看 VelocityTracker 的用法鲸匿。該例子的 ViewGroup 繼承自例子 2 的 ViewGroup爷怀,擁有同樣的子 View,區(qū)別在于除了可以用動畫來滾動带欢,還可以用手勢來拖動滾動运授。重點看該 ViewGroup 的 onTouchEvent 方法:
/**
* 示例:自定義一個 ViewGroup,包含幾個一字排開的子 View乔煞,
* 每個子 View 都與該 ViewGroup 一樣大吁朦。
* 通過 VelocityTracker 監(jiān)控手指滑動速度。
*/
public class Case3ViewGroup extends Case2ViewGroup {
// 速度監(jiān)控器
private VelocityTracker mVelocityTracker;
public Case3ViewGroup(Context context) {
super(context);
}
public Case3ViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
}
public Case3ViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
// 非滑動狀態(tài)
private static final int TOUCH_STATE_REST = 0;
// 滑動狀態(tài)
private static final int TOUCH_STATE_SCROLLING = 1;
// 表示當前狀態(tài)
private int mTouchState = TOUCH_STATE_REST;
// 上一次事件的位置
private float mLastMotionX;
// 觸發(fā)滾動的最小滑動距離渡贾,手指滑動超過該距離才認為是要拖動逗宜,防止手抖
private int mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
// 最小滑動速率,手指滑動超過該速度時才會觸發(fā)翻頁
private static final int VELOCITY_MIN = 600;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
//表示已經(jīng)開始滑動了空骚,不需要走該 ACTION_MOVE 方法了纺讲。
if ((action == MotionEvent.ACTION_MOVE) && (mTouchState != TOUCH_STATE_REST)) {
return true;
}
final float x = ev.getX();
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastMotionX = x;
mTouchState = mScroller.isFinished() ? TOUCH_STATE_REST : TOUCH_STATE_SCROLLING;
break;
case MotionEvent.ACTION_MOVE:
final int xDiff = (int) Math.abs(mLastMotionX - x);
//超過了最小滑動距離,就可以認為開始滑動了
if (xDiff > mTouchSlop) {
mTouchState = TOUCH_STATE_SCROLLING;
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mTouchState = TOUCH_STATE_REST;
break;
}
return mTouchState != TOUCH_STATE_REST;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
super.onTouchEvent(event);
// 速度監(jiān)控器囤屹,監(jiān)控每一個 event
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
// 觸摸點
final float eventX = event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 如果滾動未結束時按下熬甚,則停止?jié)L動
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
// 記錄按下位置
mLastMotionX = eventX;
break;
case MotionEvent.ACTION_MOVE:
// 手指移動的位移
int deltaX = (int)(eventX - mLastMotionX);
// 滾動內容,前提是不超出邊界
int targetScrollX = getScrollX() - deltaX;
if (targetScrollX >= 0 &&
targetScrollX <= getWidth() * (CHILD_NUMBER - 1)) {
scrollTo(targetScrollX, 0);
}
// 記下手指的新位置
mLastMotionX = eventX;
break;
case MotionEvent.ACTION_UP:
// 計算速度
mVelocityTracker.computeCurrentVelocity(1000);
float velocityX = mVelocityTracker.getXVelocity();
if (velocityX > VELOCITY_MIN && canMoveToIndex(getCurrentIndex() - 1)) {
// 自動向右邊繼續(xù)滑動
moveToIndex(getCurrentIndex() - 1);
} else if (velocityX < -VELOCITY_MIN && canMoveToIndex(getCurrentIndex() + 1)) {
// 自動向左邊繼續(xù)滑動
moveToIndex(getCurrentIndex() + 1);
} else {
// 手指速度不夠或不允許再滑
int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
moveToIndex(targetIndex);
}
// 回收速度監(jiān)控器
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
mVelocityTracker = null;
}
//修正 mTouchState 值
mTouchState = TOUCH_STATE_REST;
break;
case MotionEvent.ACTION_CANCEL:
mTouchState = TOUCH_STATE_REST;
break;
}
return true;
}
}
在該例子中肋坚,在 View 的 onTouchEvent
方法中乡括,在 ACTION_MOVE
手指移動中不斷調用 scrollTo
方法,實現(xiàn) View 跟隨手指移動冲簿;同時粟判,將 Event 不斷地添加到 mVelocityTracker
速度監(jiān)控器中,并在 ACTION_UP
手指抬起時從速度監(jiān)控器中獲取速度峦剔,當速度達到某一閾值時自動滾動到上一頁或下一頁档礁。
總結
至此,我們已經(jīng)了解了 View 的滾動原理吝沫,并兩個輔助類來幫助控制 View 的滾動位置和滾動速度呻澜。總結一下:
- View 的顯示可以理解為透過“視覺窗口”來看內容惨险,內容可以無限大羹幸,改變 View 的
mScrollX
和mScrollY
可以看到不同的內容,實現(xiàn)瞬時滾動辫愉。 - 調用 View 的
scrollTo
或scrollBy
方法可以瞬時滾動 View栅受。 - Scroller 輔助類可以協(xié)助實現(xiàn) View 的滾動動畫,實現(xiàn)方法是:調用
startScroll
方法開始滾動,并在 View 的computeScroll
方法中不斷改變mScrollX
和mScrollY
來滾動 View屏镊。 - VelocityTracker 輔助類可以協(xié)助追蹤 View 的滾動速度依疼,通常是在 View 的
onTouchEvent
方法中將 Event 傳進該類中來追蹤。調用該類的computeCurrentVelocity
方法之后而芥,就可以調用getXVelocity
和getYVelocity
方法分別獲取 x 方向和 y 方向的速度律罢。
有了上述的知識和工具后,我們就能實現(xiàn)很多與滾動相關的效果棍丐。
以上误辑,感謝閱讀。