天天看點

Scroller源碼解析與實踐1. startScroll() 源碼解析2. computeScrollOffset()源碼解析3.逃不開的scrollTo、scrollBy4.fling源碼解析5.實作開頭的ScrollerTextView

先來瞅一眼看完本篇後最終要實作的效果:ScrollerTextView。普通的TextView是不具備滑動功能的,當文字的長度超出了TextView的寬高範圍後就會被截斷,是以我通過Scroller與View的scrollTo、scrollBy方法結合實作了一個可以跟随手指移動,并且帶有fling效果的TextView。

Scroller源碼解析與實踐1. startScroll() 源碼解析2. computeScrollOffset()源碼解析3.逃不開的scrollTo、scrollBy4.fling源碼解析5.實作開頭的ScrollerTextView

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!大功告成!!