先來瞅一眼看完本篇後最終要實作的效果:ScrollerTextView。普通的TextView是不具備滑動功能的,當文字的長度超出了TextView的寬高範圍後就會被截斷,是以我通過Scroller與View的scrollTo、scrollBy方法結合實作了一個可以跟随手指移動,并且帶有fling效果的TextView。
1. startScroll() 源碼解析
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
//設定目前的模式為 SCROLL
mMode = SCROLL_MODE;
//整個滑動的過程重置
mFinished = false;
//滑動的間隔
mDuration = duration;
//從調用這個方法的那一時刻起,記錄下目前的時間,這個參數很重要,是後續動态計算滑動距離的關鍵
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
//這個參數的含義是:整個滑動時間的倒數。例如,整個滑動時間是500ms,那麼這個值就是 1/500
//整個滑動過程分為500份,每一份的時間是 1/500 ms
mDurationReciprocal = 1.0f / (float) mDuration;
}
startX: 開始滑動時 X 坐标軸的初始坐标
startY: 開始滑動時 Y 坐标軸的初始坐标
dx: 整個滑動過程中,X 方向滑動的距離
dy: 整個滑動過程中,Y 方向滑動的距離
duration:整個滑動過程所消耗的時間
2. computeScrollOffset()源碼解析
在 startScroll 方法中,做的都是最簡單的指派操作,是以想要實作複雜的效果還需要搭配 computeScrollOffset 方法。當我們一旦調用了 startScroll 方法後,會記錄下當時的時間,後續随着時間的流逝,我們需要計算出某一時刻的 X、Y 坐标的值,就需要再次調用這個方法:
public boolean computeScrollOffset() {
// startScroll 方法中已經将這個值置為false了
if (mFinished) {
return false;
}
// 從調用 startScroll 方法到現在,時間已經流逝了多少
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
// 如果已經流逝的時間還在整個滑動時間的範圍内,則計算此時此刻的 X、Y 的值
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
// timePassed * mDurationReciprocal 就是流逝的時間占整個滑動時間的百分比
// mInterpolator 是一個 估值器,類似于屬性動畫中的估值器。用來控制時間流逝不同階段的快慢
//最終得到的 x 就是一個小于 1 的百分比
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
// 某一時刻的坐标值 = 開始點的值 + 百分比 乘以 整個滑動的距離
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
// 先暫時省略fling相關的代碼
}
//如果流逝的時間已經超過了整個滑動所需的時間了,那麼就認為此次滑動結束,并将X、Y的坐标設定為最終值
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
3.逃不開的scrollTo、scrollBy
剛一接觸這兩個方法的時候覺得很簡單,如果想在 Y 軸上将内容随手指向下滾動100像素,那就直接調用 scrollTo(0, 100) , 但是真的是這樣嗎?結果可能會與你想要的效果恰恰相反。
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
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()) {
invalidate();
}
}
}
View 中有兩個屬性: mScrollX 和 mScrollY , 可見當調用 scrollTo 方法後,實際就是将新的 x、y 的值指派給 mScrollX 和 mScrollY ,然後調用 invalidate 引起view重新繪制,這樣就産生了滑動的效果。是以實際上引起界面滑動的真正原因就在于 mScrollX 和 mScrollY,原因其實就在于 View 的 draw 方法中,在繪制内容的時候,考慮了 mScrollX 和 mScrollY :
public void invalidate(int l, int t, int r, int b) {
final int scrollX = mScrollX;
final int scrollY = mScrollY;
//這句代碼至關重要
invalidateInternal(l - scrollX, t - scrollY, r - scrollX, b - scrollY, true, false);
}
在引起View的重新繪制後,View在draw方法中會一步步最終再次調用到上邊這個方法,最後一行代碼: l - scrollX, t - scrollY, r - scrollX, b - scrollY 就是根據 mScrollX 和 mScrollY 重新确定 View 的邊界,由于這裡上、下、左、右都是減法,是以如果我們調用 scrollTo(0, 100),view在重新繪制的時候, left 和 right 的值不變, 而 top 和 bottom 的值都減小了 100像素,根據View坐标系這個時候View的位置是在 Y 軸方向整體向上移動了!!!!而并不是我們所想的向下移動!!!!
4.fling源碼解析
直接看 fling() 的源碼吧:
public void fling(int startX, int startY, int velocityX, int velocityY,
int minX, int maxX, int minY, int maxY) {
// 省略無關緊要的代碼
mMode = FLING_MODE;
mFinished = false;
//勾股定理,根據X Y方向計算出斜邊的速度
float velocity = (float) Math.hypot(velocityX, velocityY);
mVelocity = velocity;
//根據速度計算出此次完整的fling需要的時間
mDuration = getSplineFlingDuration(velocity);
//記錄下目前時間
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
float coeffX = velocity == 0 ? 1.0f : velocityX / velocity;
float coeffY = velocity == 0 ? 1.0f : velocityY / velocity;
double totalDistance = getSplineFlingDistance(velocity);
mDistance = (int) (totalDistance * Math.signum(velocity));
mMinX = minX;
mMaxX = maxX;
mMinY = minY;
mMaxY = maxY;
// 計算出X方向最終的坐标
mFinalX = startX + (int) Math.round(totalDistance * coeffX);
// Pin to mMinX <= mFinalX <= mMaxX
mFinalX = Math.min(mFinalX, mMaxX);
mFinalX = Math.max(mFinalX, mMinX);
//計算出Y方向最終的坐标
mFinalY = startY + (int) Math.round(totalDistance * coeffY);
// Pin to mMinY <= mFinalY <= mMaxY
mFinalY = Math.min(mFinalY, mMaxY);
mFinalY = Math.max(mFinalY, mMinY);
}
參數解釋:
startX:開始滑動點的X坐标
startY:開始滑動點的Y坐标
velocityX:X方向上的初始速度
velocityY:Y方向上的初始速度
minX:當fling結束後,view滑動到X方向上的最小坐标
maxX:當fling結束後,view滑動到X方向上的最大坐标
minY:當fling結束後,view滑動到Y方向上的最小坐标
maxY:當fling結束後,view滑動到Y方向上的最小坐标
fling方法和startScroll方法原理是一樣的,内部隻是一些指派操作,記錄下初始狀态的資料。後續随着時間的流逝,我們需要計算出某一時刻的 X、Y 坐标的值,就需要再次調用這個方法:
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
//現在是 FLING_MODE 模式了!!!
case FLING_MODE:
final float t = (float) timePassed / mDuration;
final int index = (int) (NB_SAMPLES * t);
float distanceCoef = 1.f;
float velocityCoef = 0.f;
if (index < NB_SAMPLES) {
final float t_inf = (float) index / NB_SAMPLES;
final float t_sup = (float) (index + 1) / NB_SAMPLES;
final float d_inf = SPLINE_POSITION[index];
final float d_sup = SPLINE_POSITION[index + 1];
velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
distanceCoef = d_inf + (t - t_inf) * velocityCoef;
}
mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
// Pin to mMinX <= mCurrX <= mMaxX
mCurrX = Math.min(mCurrX, mMaxX);
mCurrX = Math.max(mCurrX, mMinX);
mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
// Pin to mMinY <= mCurrY <= mMaxY
mCurrY = Math.min(mCurrY, mMaxY);
mCurrY = Math.max(mCurrY, mMinY);
if (mCurrX == mFinalX && mCurrY == mFinalY) {
mFinished = true;
}
break;
}
}
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
當 mMode 變為 FLING_MODE 的時候,這時再調用 computeScrollOffset 方法就是計算fling過程中某一個時刻的 X Y 坐标了。裡邊用了一堆數學上的算法,随着時間的流逝,根據初始速度,計算出某一時刻的X Y 坐标。這裡就不分析了(因為我也沒仔細看2333)。總之不管是 startScroll 還是 fling , computeScrollOffset 的究極奧義都是根據目前流逝的時間以及一些初始值,計算出某一時刻下的 X Y 的坐标值。
5.實作開頭的ScrollerTextView
5.1 實作文字跟随手指上下移動
這個比較簡單直接重寫 onTouchEvent 方法:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
float dy = mLastY - event.getY();
scrollBy(0, (int) dy);
mLastY = event.getY();
break;
}
return true;
}
首先,在 ACTION_DOWN 手機按下的時候,記錄下此時的 Y 坐标為 mLastY,當手指移動的時候,用上一次記錄的 mLastY - 目前的Y坐标,調用scrollBy滾動内容并且更新 mLastY 的坐标。這塊比較難了解的是為什麼是: mLastY - 目前的Y坐标 ,而不是 目前的Y坐标 - mLastY ?。試想下邊一種情況:在初始狀态沒有滾動的情況下,ScrollerTextView 的屬性值 mScrollY = 0 ,手指從上向下移動,文字也應該向下滾動。這時mLastY - 目前的Y坐标 得到的值 dy 一定是負值,然後把這個dy傳入scrollBy方法中進而使: mScrollY = 0 - dy,mScrollY 也從0 變為了一個負值,根據本文第三章所講當ScrollerTextView重新繪制的時候,由于 減法的存在 此時ScrollerTextView 的 top 和 bottom 的值都變大了!!!!而上下邊框都變大了也就導緻視圖在視覺上向下移動了,進而也就達到了我們的目的。
5.2 實作fling效果
fling效果隻是在 手指擡起的一瞬間觸發,是以我們隻處理 Touch 事件中的 ACTION_UP 就ok了
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
mVelocityTracker.computeCurrentVelocity(1000, mMaxV);
//手指擡起的時候,計算此時的Y方向的速度
int vy = (int) mVelocityTracker.getYVelocity();
if (Math.abs(vy) > mMinV) {
mInitFlingY = getScrollY();
mScroller.fling(0, getScrollY(), 0, vy, 0, 0, -Integer.MAX_VALUE, Integer.MAX_VALUE);
invalidate();
}
mVelocityTracker.recycle();
mVelocityTracker = null;
break;
}
return true;
}
首先在 onTouchEvent 事件的頂部将所有事件都加入到速度跟蹤器中,在手指擡起的時候計算目前的速度。這裡定義了觸發 fling 操作的最大和最小速度,隻有在這個範圍内滑動效果才最好。并且使用 getScrollY() 作為此次fling的初始Y坐标。并且記錄下開始fling的Y坐标mInitFlingY。然後調用invalidate方法立刻觸發View的重新繪制。
在速度這塊:
當手指從上向下滑動時,得到的Y方向的速度為 正值,随着時間的流逝當執行scroller.computeScrollOffset方法計算最新的坐标時,新的坐标會在 startY 的基礎上逐漸增大,但是不會超過 maxY(startY的值可能是正值、負值、0)。
當手指從下向上滑動時, 得到的Y方向的速度為負值。随着時間的流逝當執行scroller.computeScrollOffset方法計算最新的坐标時,新的坐标會在 startY 的基礎上逐漸減小,但是不會小于 minY(startY的值可能是正值、負值、0)。
當調用了invalidate方法後,會觸發View的重新繪制,View在重繪的過程中會回調 computeScroll 方法,預設這個方法是一個空實作,是以我們就可以利用這個方法,在這個方法中調用scroller.computeScrollOffset計算新的坐标:
@Override
public void computeScroll() {
//計算目前時刻最新的坐标值,如果整個過程還沒有結束,會傳回true
if (mScroller.computeScrollOffset()) {
//擷取目前最新的Y坐标
int currY = mScroller.getCurrY();
//用上一個時刻的Y坐标減去目前時刻的Y坐标得到一個內插補點
int diff = mInitFlingY - currY;
//滾動内容
scrollBy(0, diff);
//再次觸發invalidate使View不斷重繪,也就實作了在fling的整個過程中,随着時間的流逝
//View 不斷的回調computeScroll方法 -> 擷取新的坐标 -> 滾動内容 -> 再次出發重繪
//這樣在視覺上就出現了fling的效果
postInvalidate();
mInitFlingY = mScroller.getCurrY();
}
}
源碼在這裡:https://github.com/haoxinlei1994/studyPro
ok!大功告成!!