嵌套滾動設計和源碼分析

VerticalNestedScrollLayout的使用

簡介

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

兩個子View通常寫在佈局中,以下:VerticalNestedScrollLayout有兩個直接子View,NestedScrollViewh 和 FrameLayout。android

<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組件進行。git

佈局介紹中:

  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);github

VerticalNestedScrollLayout實現原理

VerticalNestedScrollLayout是繼承LinearLayout實現NestedScrollingParent的父嵌套滾動組件,在initFromAttributes方法裏設置其方向爲垂直,而且獲取佈局中的屬性。三個屬性也能夠經過set⋯⋯方法進行設置bash

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)ide

@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);
}
複製代碼

而後是比較重要的測量方法,主要有如下幾步:ui

  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的高度其實是變高了,若是沒測量就進行嵌套滾動,往上滑動時,底部會出現空白區域this

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

@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

你也能夠訪問咱們的博客找到咱們,感謝閱讀~

相關文章
相關標籤/搜索