巢狀滾動設計和原始碼分析

網易考拉移動端團隊發表於2018-04-03

VerticalNestedScrollLayout的使用

簡介

VerticalNestedScrollLayout實現了垂直巢狀滾動的通用元件。其內部有且僅有兩個直接子View: 頭部主體

兩個子View一般寫在佈局中,如下:VerticalNestedScrollLayout有兩個直接子View,NestedScrollViewh 和 FrameLayout。

<com.kaola.base.ui.scroll.VerticalNestedScrollLayout
        xmlns:vnsl="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        vnsl:isScrollDownWhenFirstItemIsTop="true"
        >

        <android.support.v4.widget.NestedScrollView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            >
           ⋯⋯
           
        </android.support.v4.widget.NestedScrollView>


        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            >
            
            ⋯⋯
            
            <android.support.v7.widget.RecyclerView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:overScrollMode="never"
                />
            
            ⋯⋯
    
        </FrameLayout>
    </com.kaola.base.ui.scroll.VerticalNestedScrollLayout>
複製程式碼

VerticalNestedScrollLayout作為巢狀滾動的父元件,需要配合支援巢狀滾動的子View元件進行。

佈局介紹中:

  1. 第一個子View為NestedScrollViewh,是系統實現了巢狀滾動的元件,本質是繼承了FrameLayout實現了NestedScrollingParent和NestedScrollingChild介面的元件。因此NestedScrollViewh是既可以做父元件,也可以做子元件。
  2. 第二個子View是FrameLayout,是不支援巢狀滾動的,但是FrameLayout的子View裡有RecyclerView,RecyclerView實現了NestedScrollingChild。巢狀滾動不需要直接子View或者父View支援巢狀滾動,間接也可以,內部有遍歷尋找的邏輯

VerticalNestedScrollLayout支援的屬性和介面回撥:

  1. isScrollDownWhenFirstItemIsTop 在往下滑的時候,是否只用當主體置頂時,頭部才能下來
  2. isAutoScroll 頭部是否支援自動滾動到最上或者最下
  3. headerRetainHeight 頭部保留的高度,常見的使用比如頭部佈局最下方有個SmartTabLayout,為了讓SmartTabLayout吸附在頂部,設定headerRetainHeight為SmartTabLayout的高度。
  4. VerticalNestedScrollLayout還支援滾動中、滾動到頂部、底部的回撥。
public interface OnScrollYListener {
        void onScrolling(int scrollY, boolean isTop);

        void onScrollToTop();

        void onScrollToBottom();
    }
複製程式碼

常見的問題:

  1. 第一個子View用了普通的ViewGroup(如LinearLayout),導致頭部不能滑動,只能滑動下方主體部分。此時需要使用NestedScrollView來代替普通的ViewGroup
  2. 第一個子View中有支援橫向滑動的RecyclerView,橫向滑動和豎向滑動產生巢狀滾動,導致橫向滑動時也可以豎向滑動,此時需要禁用橫向滑動的RecyclerView的巢狀滾動。

mRecyclerView.setNestedScrollingEnabled(false);

VerticalNestedScrollLayout實現原理

VerticalNestedScrollLayout是繼承LinearLayout實現NestedScrollingParent的父巢狀滾動元件,在initFromAttributes方法裡設定其方向為垂直,並且獲取佈局中的屬性。三個屬性也可以通過set⋯⋯方法進行設定

private void initFromAttributes(Context context, AttributeSet attrs, int defStyleAttr) {
    setOrientation(LinearLayout.VERTICAL);
    mParentHelper = new NestedScrollingParentHelper(this);

    TypedArray a = context.obtainStyledAttributes(attrs, com.kaola.base.R.styleable.VerticalNestedScrollLayout,
            defStyleAttr, 0);
    mIsScrollDownWhenFirstItemIsTop =
            a.getBoolean(R.styleable.VerticalNestedScrollLayout_isScrollDownWhenFirstItemIsTop, false);
    mIsAutoScroll = a.getBoolean(R.styleable.VerticalNestedScrollLayout_isAutoScroll, false);
    mHeaderRetainHeight = (int) a.getDimension(R.styleable.VerticalNestedScrollLayout_headerRetainHeight, 0);
    a.recycle();
}
複製程式碼

通過onFinishInflate方法獲取頭部(mHeaderView)和主體(mBodyView)

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    mHeaderView = getChildAt(0);
    mBodyView = getChildAt(1);
}
複製程式碼

並且在addView方法中限制了新增View。

@Override
public void addView(View child, int index, ViewGroup.LayoutParams params) {
    if (getChildCount() > 1) {
        throw new IllegalStateException("VerticalNestedScrollLayout can host only two direct child");
    }
    super.addView(child, index, params);
}
複製程式碼

然後是比較重要的測量方法,主要有以下幾步:

  1. 測量頭部的高度
  2. 獲取最大滾動距離,為頭部自動滾動做準備
  3. 測量主體的高度
  4. 設定VerticalNestedScrollLayout的高度
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    //如果不設定無限制高度,mHeaderView高度如果大於螢幕的高,將只會顯示螢幕的高
    mHeaderView.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
    //最大滾動距離:頭部減去保留的高度
    mMaxScrollHeight = mHeaderView.getMeasuredHeight() - mHeaderRetainHeight;
    //設定主體的高度:程式碼中設定match_parent
    if (mBodyView.getLayoutParams().height < getMeasuredHeight() - mHeaderRetainHeight) {
        mBodyView.getLayoutParams().height = getMeasuredHeight() - mHeaderRetainHeight;
    }
    //設定自身的高度
    setMeasuredDimension(getMeasuredWidth(), mBodyView.getLayoutParams().height + mHeaderView.getMeasuredHeight());
}
複製程式碼

測量前後.png

紅框表示螢幕,測量後VerticalNestedScrollLayout的高度實際上是變高了,如果沒測量就進行巢狀滾動,往上滑動時,底部會出現空白區域

下面就是NestedScrollingParent介面中方法的實現了,重點介紹onNestedPreScroll 和 onNestedPreFling方法。

@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {

    if (canScroll(target, dy)) {
        scrollBy(0, dy);
        consumed[1] = dy;
        ⋯⋯
    }
    ⋯⋯
}
複製程式碼

該方法是子View開始滾動之前,呼叫的,就是子View滾動前讓父View先滾,這裡需要判斷父View是否要滾動。程式碼中 hiddenTop是隱藏頭部的行為、showTop是展示頭部的行為,滿足其中一個,就需要滾動父View。 程式碼如下:

private boolean canScroll(View target, int dy) {
    boolean hiddenTop = dy > 0 && getScrollY() < mMaxScrollHeight;
    boolean showTop = dy < 0 && getScrollY() > 0;
    if (mIsScrollDownWhenFirstItemIsTop) {
        showTop = showTop && !target.canScrollVertically(-1);
    }
    return hiddenTop || showTop;
}
複製程式碼

如果執行consumed[1] = dy;說明父View消費了所有的垂直滑動距離,如果consumed[1] = dy * 0.5f;則父View消費一半,這樣使用者看到的就是頭部和主體部分同時滾動的視覺效果。

@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
    if (mIsScrollDownWhenFirstItemIsTop && target.canScrollVertically(-1)) {
        return false;
    }

    if (mScrollAnimator != null && mScrollAnimator.isStarted()) {
        mScrollAnimator.cancel();
    }
    mIsFling = true;
    if (velocityX == 0 && velocityY != 0) {
        if (velocityY < 0) {
            autoDownScroll();
        } else {
            autoUpScroll();
        }
        if (mIsScrollDownWhenFirstItemIsTop) {
            return true;
        }
    }
    return false;
}
複製程式碼

上面是Fling時的處理邏輯,主要實現了自動滾動,如果沒有這段,則頭部看起來沒有慣性,使用者體檢較差。

方法中canScrollVertically(-1)判斷了target是否可以往下拉。比如RecyclerView沒有置頂,還可以往下拉,mRecyclerView.canScrollVertically(-1)返回true

然後通過velocityY判斷是自動滾到頂部還是底部;返回true表示父View消費了Fling事件,false則不消費。

巢狀滾動原理篇·內部實現

巢狀滾動中的兩個介面,在上文中已經提到。NestedScrollingParent和NestedScrollingChild 介面中的方法如下:

NestedScrollingChild

  • startNestedScroll : 起始方法, 主要作用是找到接收滑動距離資訊的外控制元件.
  • dispatchNestedPreScroll : 在內控制元件處理滑動前把滑動資訊分發給外控制元件.
  • dispatchNestedScroll : 在內控制元件處理完滑動後把剩下的滑動距離資訊分發給外控制元件.
  • stopNestedScroll : 結束方法, 主要作用就是清空巢狀滑動的相關狀態
  • setNestedScrollingEnabled和isNestedScrollingEnabled : 一對get&set方法, 用來判斷控制元件是否支援巢狀滑動.
  • dispatchNestedPreFling和dispatchNestedFling : 跟Scroll的對應方法作用類似,

NestedScrollingParent

  • onStartNestedScroll : 對應startNestedScroll, 內控制元件通過呼叫外控制元件的這個方法來確定外控制元件是否接收滑動資訊.
  • onNestedScrollAccepted : 當外控制元件確定接收滑動資訊後該方法被回撥, 可以讓外控制元件針對巢狀滑動做一些前期工作.
  • onNestedPreScroll : 關鍵方法, 接收內控制元件處理滑動前的滑動距離資訊, 在這裡外控制元件可以優先響應滑動操作, 消耗部分或者全部滑動距離.
  • onNestedScroll : 關鍵方法, 接收內控制元件處理完滑動後的滑動距離資訊, 在這裡外控制元件可以選擇是否處理剩餘的滑動距離.
  • onStopNestedScroll : 對應stopNestedScroll, 用來做一些收尾工作.
  • getNestedScrollAxes : 返回巢狀滑動的方向, 區分橫向滑動和豎向滑動, 作用不大
  • onNestedPreFling和onNestedFling : 同上略

巢狀滾動的過程:

子view接受到滾動事件後發起巢狀滾動,詢問父View是否要先滾動,父View處理了自己的滾動需求後,回到子View處理自己的滾動需求,假如父View消耗了一些滾動距離,子View只能獲取剩下的滾動距離做處理。子View處理了自己的滾動需求後又回到父View,剩下的滾動距離做處理。慣性fling的類似。

將上面過程用原始碼來解釋(子View為RecyclerView,父View為繼承了NestedScrollingParent的檢視)大體如下:

NestedScrollingChild 的 startNestedScroll是巢狀滾動的發起,檢視RecyclerView中該方法的呼叫地方,在onInterceptTouchEvent和onTouchEvent的action ==MotionEvent.ACTION_DOWN時,忽略onInterceptTouchEvent,直接看onTouchEvent。

檢視RecyclerView的startNestedScroll,發現是調了NestedScrollingChildHelper裡的startNestedScroll方法,檢視startNestedScroll,發現有個遍歷的過程,找到onStartNestedScroll返回true的父View,再執行onNestedScrollAccepted後停止遍歷。到目前巢狀滾動執行的方法順序如下:

(子)startNestedScroll → (父)onStartNestedScroll → (父)onNestedScrollAccepted

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
    if (hasNestedScrollingParent(type)) {
        // Already in progress
        return true;
    }
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        while (p != null) {
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                setNestedScrollingParentForType(type, p);
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

複製程式碼

接下來在RecyclerView的onTouchEvent的 MotionEvent.ACTION_MOVE裡呼叫了dispatchNestedPreScroll和scrollByInternal

case MotionEvent.ACTION_MOVE: {
   ⋯⋯
    if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
        dx -= mScrollConsumed[0];
        dy -= mScrollConsumed[1];
        vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
        // Updated the nested offsets
        mNestedOffsets[0] += mScrollOffset[0];
        mNestedOffsets[1] += mScrollOffset[1];
    }
    ⋯⋯

    if (mScrollState == SCROLL_STATE_DRAGGING) {
        mLastTouchX = x - mScrollOffset[0];
        mLastTouchY = y - mScrollOffset[1];

        if (scrollByInternal(
                canScrollHorizontally ? dx : 0,
                canScrollVertically ? dy : 0,
                vtev)) {
            getParent().requestDisallowInterceptTouchEvent(true);
        }
       ⋯⋯
    }
} break;
複製程式碼

看dispatchNestedPreScroll原始碼:發現調了父View的onNestedPreScroll,並且傳入dy 和 consumed。用於做消費計數。

onNestedPreScroll事件在不同父View中有不同實現,具體可以看一下VerticalNestedScrollLayout裡該方法的實現

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
    if (isNestedScrollingEnabled()) {
        final ViewParent parent = getNestedScrollingParentForType(type);
        if (parent == null) {
            return false;
        }

        if (dx != 0 || dy != 0) {
            ⋯⋯
            consumed[0] = 0;
            consumed[1] = 0;
            ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

            ⋯⋯
            return consumed[0] != 0 || consumed[1] != 0;
        } else if (offsetInWindow != null) {
            offsetInWindow[0] = 0;
            offsetInWindow[1] = 0;
        }
    }
    return false;
}
複製程式碼

scrollByInternal讓RecyclerView自己滾動後又呼叫了dispatchNestedScroll

boolean scrollByInternal(int x, int y, MotionEvent ev) {
        ⋯⋯
        if (y != 0) {
            consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
            unconsumedY = y - consumedY;
        }
        ⋯⋯
    if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
            TYPE_TOUCH)) {
        // Update the last touch co-ords, taking any scroll offset into account
        mLastTouchX -= mScrollOffset[0];
        mLastTouchY -= mScrollOffset[1];
        if (ev != null) {
            ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
        }
        mNestedOffsets[0] += mScrollOffset[0];
        mNestedOffsets[1] += mScrollOffset[1];
    }⋯⋯
    return consumedX != 0 || consumedY != 0;
}
複製程式碼

看dispatchNestedScroll方法,最終呼叫了父View的onNestedScroll方法。

public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                ⋯⋯

                ViewParentCompat.onNestedScroll(parent, mView, dxConsumed,
                        dyConsumed, dxUnconsumed, dyUnconsumed, type);

                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
複製程式碼

到目前我們也可以看到父View的巢狀滾動方法都是子View調起來的,子View的介面都在TouchEvent事件裡。巢狀滾動執行的方法順序如下:

(子)startNestedScroll → (父)onStartNestedScroll → (父)onNestedScrollAccepted→ (子)dispatchNestedPreScroll → (父)onNestedPreScroll→ (子)dispatchNestedScroll→ (父)onNestedScroll

後面的MotionEvent.ACTION_UP中:

呼叫fling方法執行了巢狀滾動相關的fling事件 resetTouch();執行了stopNestedScroll事件

過程類似不在贅述。 巢狀滾動執行的方法順序如下:

(子)startNestedScroll → (父)onStartNestedScroll → (父)onNestedScrollAccepted→ (子)dispatchNestedPreScroll → (父)onNestedPreScroll→ (子)dispatchNestedScroll→ (父)onNestedScroll→ (子)dispatchNestedPreFling → (父)onNestedPreFling→ (子)dispatchNestedFling → (父)stopNestedScroll

輔助類NestedScrollingChildHelper和NestedScrollingParentHelper

從LOLLIPOP(SDK21)開始,巢狀滑動的相關邏輯作為普通方法直接寫進了View和ViewGroup類裡。而SDK21之前的版本 官方在android.support.v4相容包中提供了兩個介面NestedScrollingChild和NestedScrollingParent, 還有兩個輔助類NestedScrollingChildHelper和NestedScrollingParentHelper來幫助控制元件實現巢狀滑動。

相容的原理

兩個介面NestedScrollingChild和NestedScrollingParent分別定義上面提到的View和ViewParent新增的普通方法

在巢狀滑動中會要求控制元件要麼是繼承於SDK21之後的View或ViewGroup, 要麼實現了這兩個介面, 這是控制元件能夠進行巢狀滑動的前提條件。

那麼怎麼知道呼叫的方法是控制元件自有的方法, 還是介面的方法? 在程式碼中是通過ViewCompat和ViewParentCompat類來實現.

ViewCompat和ViewParentCompat通過當前的Build.VERSION.SDK_INT來判斷當前版本, 然後選擇不同的實現類, 這樣就可以根據版本選擇呼叫的方法.

例如如果版本是SDK21之前, 那麼就會判斷控制元件是否實現了介面, 然後呼叫介面的方法, 如果是SDK21之後, 那麼就可以直接呼叫對應的方法。

參考:https://www.jianshu.com/p/1806ed9737f6

你也可以訪問我們的部落格找到我們,感謝閱讀~

相關文章