深入了解ScrollView

做android開發也有很長一段時間類,一直沒有仔細想過ScrollView是怎么實現的,如何實現滾動的,所以就去研究類一下其源碼,順便做一下筆記,望日后好查閱。俗話說好記性不如爛筆頭嘛。小弟不才,哪里理解錯了還望大神指教,再此先謝過。

理論上弄清楚源碼是怎么做的,我們按照這個邏輯也可以寫出一個的ScrollView的,所有我也寫了一個ScrollView,留作參考。這個ScrollView對于滑動到邊界的處理,只做了回彈的處理。所以支持邊界阻尼回彈的ScrollView。

原理請參考:實現一個ScrollView

項目地址:https://github.com/cyuanyang/ScrollView.git

FillViewport

眾所周知ScrollView有一個FillViewport屬性,而他的實現也很簡單,下面是源碼,注釋是依照我的理解自己加上去的。

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //1.如果是false 按照父視圖的測量方式測量ScrollView的子View的寬高 
        // 即使你的子View設置math_parant 也只當者wrap_content處理
        if (!mFillViewport) {
            return;
        }

        final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        if (heightMode == MeasureSpec.UNSPECIFIED) {
            return;
        }

        //2.如果設置mFillViewport=true 則會走這里開始測量子View的寬高
        if (getChildCount() > 0) {
            //3.因為ScrollView有且只有一個子View所以直接取第一個
            final View child = getChildAt(0);
            final int widthPadding;
            final int heightPadding;
            final int targetSdkVersion = getContext().getApplicationInfo().targetSdkVersion;
            //4.拿到布局參數
            final FrameLayout.LayoutParams lp = (LayoutParams) child.getLayoutParams();
            //5.計算padding
            if (targetSdkVersion >= VERSION_CODES.M) {
                widthPadding = mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin;
                heightPadding = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin;
            } else {
                widthPadding = mPaddingLeft + mPaddingRight;
                heightPadding = mPaddingTop + mPaddingBottom;
            }

            //6. desiredHeight 是scrollView的高度減去上下margin剩下的高度 如果child的高度小于這個才去測量 
            // 如果大于的話已經充滿里沒必要再折騰一次 源碼的水平還是很有質量的
            final int desiredHeight = getMeasuredHeight() - heightPadding;
            if (child.getMeasuredHeight() < desiredHeight) {
                //7. 計算寬高 調用child的measure  完成
                final int childWidthMeasureSpec = getChildMeasureSpec(
                        widthMeasureSpec, widthPadding, lp.width);
                final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                        desiredHeight, MeasureSpec.EXACTLY);
                child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
            }
        }
    }

這里細心的人可能會有疑問,scrollView是FrameLayout的子類,而mFillViewport=false,會調用 super.onMeasure()測量子View的寬高,這樣我們也會得到一個正確的值的。事實并不是這么簡單的,再FrameLayout的測量View的方法中,測量child是有一個額外條件

if (mMeasureAllChildren || child.getVisibility() != GONE)

mMeasureAllChildren再mFillViewport為false的時候就是false

onInterceptTouchEvent

這個方法對于ScrollView是很關鍵的。如果想要滑動,肯定得返回true的,但是又不能全部返回true要不子View就接受不到事件了。這個方法就是處理何時該攔截事件。還是拿關鍵的源碼說話。如果不懂mScroller或者VelocityTracker請參考實現一個ScrollView

case MotionEvent.ACTION_DOWN: {
                // 1. 如果按下的位置在不在 子View上
                final int y = (int) ev.getY();
                if (!inChild((int) ev.getX(), (int) y)) {
                    mIsBeingDragged = false;
                    recycleVelocityTracker();
                    break;
                }

                /*
                 * 2. 記住down事件 取第一個手指
                 * Remember location of down touch.
                 * ACTION_DOWN always refers to pointer index 0.
                 */
                mLastMotionY = y;
                mActivePointerId = ev.getPointerId(0);

                initOrResetVelocityTracker();
                //3. 這個是計算速率的 主要用來計算手指離開后的fling的速率
                mVelocityTracker.addMovement(ev);
                /*
                 * If being flinged and user touches the screen, initiate drag;
                 * otherwise don't. mScroller.isFinished should be false when
                 * being flinged. We need to call computeScrollOffset() first so that
                 * isFinished() is correct.
                 *
                */
                //4. 下面是如何區分是點擊子View還是拖動ScrollView 原因上面源碼注釋也很清楚
                //如果mScroller再滾動 即認為是拖動 直接賦值true
                mScroller.computeScrollOffset();
                mIsBeingDragged = !mScroller.isFinished();
                if (mIsBeingDragged && mScrollStrictSpan == null) {
                    mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
                }
                startNestedScroll(SCROLL_AXIS_VERTICAL);
                break;
            }

這個是move事件的處理

                //6.如果先點擊沒有滑動,攔截事件中為false,ScrollView中的button也能接受到事件,這是再根據滑動的距離來決定是不是需要攔截事件
                //mTouchSlop(這個值是一個系統值,判斷滑動的一個閾值)
                final int y = (int) ev.getY(pointerIndex);
                final int yDiff = Math.abs(y - mLastMotionY);
                if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
                    mIsBeingDragged = true;
                    //7.  賦值down后的y的位置
                    mLastMotionY = y;
                    //8. 初始化速率軌跡計算 主要用來計算手指離開后的fling的速率
                    initVelocityTrackerIfNotExists();
                    mVelocityTracker.addMovement(ev);
                    mNestedYOffset = 0;
                    if (mScrollStrictSpan == null) {
                        mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
                    }
                    final ViewParent parent = getParent();
                    if (parent != null) {
                        parent.requestDisallowInterceptTouchEvent(true);
                    }
                }

onTouchEvent

這個是ScrollView最關鍵,最關鍵,最關鍵的地方,重要的話說三遍。理解這個地方后自己就可以寫出一個ScrollView了。還是拿代碼說話吧

    //代碼不必要每一步都懂 只需要理解關鍵的地方即可,畢竟android是一個系統,考慮的很多很多,我們沒有必要理解每一句代碼的含義
    //所以這里列舉一下關鍵的地方
    public boolean onTouchEvent(MotionEvent ev) {
        //1. 如果沒有初始化速率軌跡 初始化它,這個還是用于手指離開后計算fling的
        initVelocityTrackerIfNotExists();

        MotionEvent vtev = MotionEvent.obtain(ev);

        final int actionMasked = ev.getActionMasked();

        if (actionMasked == MotionEvent.ACTION_DOWN) {
            mNestedYOffset = 0;
        }
        vtev.offsetLocation(0, mNestedYOffset);

        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN: {
                if (getChildCount() == 0) {
                    return false;
                }
                //2.請求父視圖不要攔截
                if ((mIsBeingDragged = !mScroller.isFinished())) {
                    final ViewParent parent = getParent();
                    if (parent != null) {
                        parent.requestDisallowInterceptTouchEvent(true);
                    }
                }

                /*
                 * If being flinged and user touches, stop the fling. isFinished
                 * will be false if being flinged.
                 */
                //3. 如果當前在fling 就是mScroller還沒有完成就觸摸了
                //立刻放棄當前的滾動
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                    if (mFlingStrictSpan != null) {
                        mFlingStrictSpan.finish();
                        mFlingStrictSpan = null;
                    }
                }

                // Remember where the motion event started
                //4. 記住觸摸的位置 mLastMotionY 這個值在move的時候用來計算手指移動的變化量,然后用來計算需要滾動的距離
                mLastMotionY = (int) ev.getY();
                mActivePointerId = ev.getPointerId(0);
                //5. 這個是處理內部滾動 可以先不用管這個
                //涉及到Nested的都可以先不用管它  這個好像是為了支持v4包內的某個功能做的處理
                startNestedScroll(SCROLL_AXIS_VERTICAL);
                break;
            }
            case MotionEvent.ACTION_MOVE:
                final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
                if (activePointerIndex == -1) {
                    Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
                    break;
                }

                final int y = (int) ev.getY(activePointerIndex);
                //6. deltaY 計算手指移動的距離 在4中記錄的 同時下面還會更新這個值 8中會用到這個值來計算需要滾動的距離
                int deltaY = mLastMotionY - y;
                if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
                    deltaY -= mScrollConsumed[1];
                    vtev.offsetLocation(0, mScrollOffset[1]);
                    mNestedYOffset += mScrollOffset[1];
                }
                //7. 如果先點擊沒有滑動,攔截事件中為false,ScrollView中的button也能接受到事件,這是再根據滑動的距離來決定是不是需要攔截事件
                if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
                    final ViewParent parent = getParent();
                    if (parent != null) {
                        parent.requestDisallowInterceptTouchEvent(true);
                    }
                    mIsBeingDragged = true;
                    if (deltaY > 0) {
                        deltaY -= mTouchSlop;
                    } else {
                        deltaY += mTouchSlop;
                    }
                }
                if (mIsBeingDragged) {
                    // Scroll to follow the motion event
                    //更新mLastMotionY 這個很關鍵 否則根本滑不懂
                    mLastMotionY = y - mScrollOffset[1];

                    final int oldY = mScrollY;
                    final int range = getScrollRange();
                    final int overscrollMode = getOverScrollMode();
                    boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS ||
                            (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);

                    // Calling overScrollBy will call onOverScrolled, which
                    // calls onScrollChanged if applicable.
                    //8. 調用overScrollBy方法計算滾動 這個方法就是計算一下滾動的距離然后回調給onOverScrolled()在這里調用scrollTo方法
                    // 到這里的時候 ScrollView還不會滾動,滾動的代碼在onOverScrolled()中,緊接著下面會出現
                    // 這里返回true表示滑動超出了內容區域 像滑倒頂部會有阻尼的那種效果就可以用這個實現
                    // 這個是最關鍵的地方 關鍵的源碼都有注釋 厲害了word
                    if (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true)
                            && !hasNestedScrollingParent()) {
                        // Break our velocity if we hit a scroll barrier.
                        mVelocityTracker.clear();
                    }

                    //9.下面就沒必要仔細去研究了 這里處理一下滑到邊界出的效果
                    final int scrolledDeltaY = mScrollY - oldY;
                    final int unconsumedY = deltaY - scrolledDeltaY;
                    if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
                        mLastMotionY -= mScrollOffset[1];
                        vtev.offsetLocation(0, mScrollOffset[1]);
                        mNestedYOffset += mScrollOffset[1];
                    } else if (canOverscroll) {
                        final int pulledToY = oldY + deltaY;
                        if (pulledToY < 0) {
                            mEdgeGlowTop.onPull((float) deltaY / getHeight(),
                                    ev.getX(activePointerIndex) / getWidth());
                            if (!mEdgeGlowBottom.isFinished()) {
                                mEdgeGlowBottom.onRelease();
                            }
                        } else if (pulledToY > range) {
                            mEdgeGlowBottom.onPull((float) deltaY / getHeight(),
                                    1.f - ev.getX(activePointerIndex) / getWidth());
                            if (!mEdgeGlowTop.isFinished()) {
                                mEdgeGlowTop.onRelease();
                            }
                        }
                        if (mEdgeGlowTop != null
                                && (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished())) {
                            postInvalidateOnAnimation();
                        }
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                if (mIsBeingDragged) {
                    //10. 速率軌跡終于要大顯神威了
                    // up后 8中的計算滾動就會停止,但是實際上ScrollView還會滾動一段距離
                    // 這里根據 VelocityTracker 得到手指離開這一瞬間的Velocity
                    final VelocityTracker velocityTracker = mVelocityTracker;
                    velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                    int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);

                    //11. 速錄很大 則會認為是一個fling 動作
                    // flingWithNestedDispatch()方法內部就是執行了mScroller.fling()方法
                    //else if 含義:速錄很小,例如我們滑動最后停下來,然后手指離開屏幕,這時的速率可能為0,就不需要fling
                    //但是若滑動到頂部就需要回彈動畫 ,直接動用 mScroller.springBack()即可
                    if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                        flingWithNestedDispatch(-initialVelocity);
                    } else if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0,
                            getScrollRange())) {
                        postInvalidateOnAnimation();
                    }

                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                }
                break;
            //12. 取消事件的處理 類似于up事件 理解上面的下面的多個觸摸點的處理就很簡單了
            case MotionEvent.ACTION_CANCEL:
                if (mIsBeingDragged && getChildCount() > 0) {
                    if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange())) {
                        postInvalidateOnAnimation();
                    }
                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                }
                break;
            case MotionEvent.ACTION_POINTER_DOWN: {
                final int index = ev.getActionIndex();
                mLastMotionY = (int) ev.getY(index);
                mActivePointerId = ev.getPointerId(index);
                break;
            }
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                mLastMotionY = (int) ev.getY(ev.findPointerIndex(mActivePointerId));
                break;
        }

剛剛在8中調用overScrollBy用來計算滾動的距離然后回調給onOverScrolled來處理是否需要滾動,這里就是處理邏輯

    @Override
    protected void onOverScrolled(int scrollX, int scrollY,
                                  boolean clampedX, boolean clampedY) {
        // Treat animating scrolls differently; see #computeScroll() for why.
        //這個if是用來區別mScroller滾動調用的還是手指拖動滾動的
        //mScroller.isFinished()為true 就是手指拖動引起的滾動 直接調用super.scrollTo,這樣就完成了滾動  完美
        //if代碼塊其實就是一個和scrollTo的代碼差不多,這里并沒有直接調用我也不知道為什么,看注解也沒太明白,哪位大神知道麻煩告訴我一下,謝謝。
        if (!mScroller.isFinished()) {
            final int oldX = mScrollX;
            final int oldY = mScrollY;
            mScrollX = scrollX;
            mScrollY = scrollY;
            invalidateParentIfNeeded();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            if (clampedY) {
                mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange());
            }
        } else {
            super.scrollTo(scrollX, scrollY);
        }
        //顯示滾動條 滾動條是View的方法,其實每個View都有滾動的功能的。
        awakenScrollBars();
    }

到此,ScrollView就能滾動了。

總結

瀏覽源碼不是為了去寫一個ScrollView,而是在看完之后我們學到了啥。就像小時候學校組織看電影一樣,學校單純的只想讓你看完電影就算了,一般都會讓我們寫一篇讀后感。haha。。。

OverScroller

如果你要是想做一個滾動的View,這個一定能幫助你實現夢想。 自帶強大的滾動技能。一般配合VelocityTracker來計算fling滾動。


如何優雅的區分是點擊還是滑動操作

當我們做一個滑動的容器組件的時候,當我們快速的滑動的時候,并不想讓down事件傳遞下去,但同時又不影響點擊容器內的View。我們可以這么做。這是在onInterceptTouchEvent中哦!

 case MotionEvent.ACTION_DOWN:
       .....
       mScroller.computeScrollOffset();
       mIsBeingDragged = !mScroller.isFinished();
       ......
        break;
 return mIsBeingDragged;

可能會坑猿的地方

ScrollView會自動滾動到獲取焦點的View上面。例如我們在ScrollView中放一個WebView,就會發現總是會滾動到WebView那里。筆者有一次用WebView來加載MathJax來渲染數學符號的時候就遇到這個坑。解決辦法有很多。主要思路就是移除不必要的焦點。

scrollBy參數是Int 會丟失小數部分
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,983評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,772評論 3 422
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,947評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,201評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,960評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,350評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,406評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,549評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,104評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,914評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,089評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,647評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,340評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,753評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,007評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,834評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,106評論 2 375

推薦閱讀更多精彩內容