乾貨:五分鐘帶你看懂NestedScrolling巢狀滑動機制

Maydaaaa發表於2016-11-03

Android NestedScrolling巢狀滑動機制

Android在釋出5.0之後加入了巢狀滑動機制NestedScrolling,為巢狀滑動提供了更方便的處理方案。在此對巢狀滑動機制進行詳細的分析。

巢狀滑動的常見用法比如在滑動列表的時候隱藏相關的TopBar和BottomBar,增加列表的資訊展示範圍,讓使用者聚焦於App想展示的內容上等。官方出的Design包裡也有很多支援該機制的炫酷控制元件,比如CoordinatorLayout,AppBarLayout等,在使用者體驗上有很大的進步。

說道巢狀滑動,離不開以下幾個內容:

  • NestedScrollingChild
  • NestedScrollingParent
  • NestedScrollingChildHelper
  • NestedScrollingParentHelper

在具體說明之前,先來看看我們的Sample,這是一個仿攜程機票首頁的Demo

乾貨:五分鐘帶你看懂NestedScrolling巢狀滑動機制

這裡用到了一個實現了NestedScrollingParent的CollaspingLayout作為父View和一個實現了NestedScrollingChild的NestedScrollView作為子View進行巢狀滑動,佈局可以簡單的描述成:

乾貨:五分鐘帶你看懂NestedScrolling巢狀滑動機制

具體的佈局結構大致如下:

<com.lycc.flight.fastproject.widget.search.CollaspingLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:id="@+id/pl_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="160dp">
        <com.yyydjk.library.BannerLayout
            android:id="@+id/banner"
            android:layout_width="match_parent"
            android:layout_height="160dp"
            app:autoPlayDuration="5000"
            app:indicatorMargin="50dp"
            app:indicatorPosition="centerBottom"
            app:indicatorShape="oval"
            app:indicatorSpace="3dp"
            app:scrollDuration="1100"
            app:defaultImage="@mipmap/ic_launcher"
            app:selectedIndicatorColor="?attr/colorPrimary"
            app:selectedIndicatorHeight="6dp"
            app:selectedIndicatorWidth="6dp"
            app:unSelectedIndicatorColor="#99ffffff"
            app:unSelectedIndicatorHeight="6dp"
            app:unSelectedIndicatorWidth="6dp"
            app:layout_collapseMode="parallax"
            app:layout_collapseParallaxMultiplier="0.7"/>
        <View
            android:id="@+id/view"
            android:layout_width="match_parent"
            android:layout_height="40dp"
            android:background="@drawable/gradient" />
        <FrameLayout
            android:id="@+id/search_tab_container"
            android:layout_width="match_parent"
            android:layout_height="43dp"
            android:layout_marginBottom="-4dp"
            android:layout_alignParentBottom="true">
            <View
                android:layout_width="match_parent"
                android:layout_height="40dp"
                android:background="#5a000000"
                android:layout_marginLeft="5dp"
                android:layout_marginTop="3dp"
                android:layout_marginBottom="-4dp"
                android:layout_marginRight="5dp"/>
            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:gravity="bottom"
                android:layout_marginBottom="-4dp"
                android:layout_marginLeft="5dp"
                android:layout_marginRight="5dp"
                android:orientation="horizontal">

                <View
                    android:id="@+id/slide_bg"
                    android:layout_width="120dp"
                    android:layout_height="43dp"
                    android:background="@drawable/ctrip_slide_tab"/>
            </LinearLayout>
            <RadioGroup
                android:id="@+id/rg_slide"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="horizontal"
                android:gravity="center"
                android:layout_centerInParent="true">

                <RadioButton
                    android:id="@+id/rb_left"
                    android:background="@null"
                    android:textColor="@color/top_layout_sliide_text_color_selector"
                    android:gravity="center"
                    android:button="@null"
                    android:textSize="16dp"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:checked="false"
                    android:text="單程" />

                <RadioButton
                    android:id="@+id/rb_center"
                    android:background="@null"
                    android:textColor="@color/top_layout_sliide_text_color_selector"
                    android:gravity="center"
                    android:textSize="16dp"
                    android:button="@null"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:text="往返" />

                <RadioButton
                    android:id="@+id/rb_right"
                    android:background="@null"
                    android:button="@null"
                    android:textColor="@color/top_layout_sliide_text_color_selector"
                    android:gravity="center"
                    android:textSize="16dp"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:singleLine="true"
                    android:text="多程"
                    android:visibility="visible" />
            </RadioGroup>


        </FrameLayout>
        <LinearLayout
            android:id="@+id/top_container"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:minHeight="?attr/actionBarSize"
            android:orientation="horizontal"
            android:visibility="gone"
            android:gravity="center"
            android:layout_alignParentTop="true"
            app:layout_collapseMode="pin"
            android:background="@color/ctirp_color_primary">
            <RadioGroup
                android:layout_width="261dp"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:gravity="center"
                android:layout_centerInParent="true">

                <RadioButton
                    android:background="@drawable/title_left_shape"
                    android:padding="6dp"
                    android:textColor="@color/top_layout_text_color_selector"
                    android:gravity="center"
                    android:button="@null"
                    android:textSize="16dp"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:checked="true"
                    android:text="單程" />

                <RadioButton
                    android:background="@drawable/title_center_shape"
                    android:padding="6dp"
                    android:textColor="@color/top_layout_text_color_selector"
                    android:gravity="center"
                    android:textSize="16dp"
                    android:button="@null"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:layout_marginLeft="-1dp"
                    android:layout_marginRight="-1dp"
                    android:text="往返" />

                <RadioButton
                    android:background="@drawable/title_right_shape"
                    android:padding="6dp"
                    android:button="@null"
                    android:textColor="@color/top_layout_text_color_selector"
                    android:gravity="center"
                    android:textSize="16dp"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:singleLine="true"
                    android:text="多程"
                    android:visibility="visible" />
            </RadioGroup>
        </LinearLayout>
    </RelativeLayout>
    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:src="@drawable/search_bg"
            android:scaleType="fitStart"/>

    </android.support.v4.widget.NestedScrollView>

</com.lycc.flight.fastproject.widget.search.CollaspingLayout>複製程式碼

從佈局可以看到其實在實現了NestedScrollingParent之後就能很方便的完成子View和父View的巢狀滑動,下面就來簡單看看上面的四個類是如何使用的,在系統為我們提供的控制元件中,NestedScrollView是實現了這個機制的控制元件,以它的實現為例,首先看作為巢狀滑動的子View:

        // NestedScrollingChild
        @Override
        public void setNestedScrollingEnabled(boolean enabled) {
                mChildHelper.setNestedScrollingEnabled(enabled);
        }
        @Override
        public boolean isNestedScrollingEnabled() {
                return mChildHelper.isNestedScrollingEnabled();
        }
        @Override
        public boolean startNestedScroll(int axes) {
                return mChildHelper.startNestedScroll(axes);
        }
        @Override
        public void stopNestedScroll() {
                mChildHelper.stopNestedScroll();
        }
        @Override
        public boolean hasNestedScrollingParent() {
                return mChildHelper.hasNestedScrollingParent();
        }
        @Override
        public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
                                            int dyUnconsumed, int[] offsetInWindow) {
                return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                        offsetInWindow);
        }
        @Override
        public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
                return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
        }
        @Override
        public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
                return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
        }
        @Override
        public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
                return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
        }複製程式碼

再來看看同樣作為巢狀滑動父View的CollaspingLayout的實現

    // NestedScrollingParent
    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

    @Override
    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
        mParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
    }

    @Override
    public void onStopNestedScroll(View target) {
        if(mHeaderController.getScrollPercentage() == 1.0f){
            mHeaderState = STATE_IDLE_TOP;
        }else if(mHeaderController.getScrollPercentage() == 0.0f){
            mHeaderState = STATE_IDLE_BOTTOM;
        }
        computeScroll();
    }

    @Override
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
                               int dyUnconsumed) {
        final int myConsumed = moveBy(dyUnconsumed);
        final int myUnconsumed = dyUnconsumed - myConsumed;
    }

    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        if (dy > 0 && mHeaderController.canScrollUp()) {
            final int delta = moveBy(dy);
            consumed[0] = 0;
            consumed[1] = delta;
        }
    }

    @Override
    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
        if (!consumed) {
            flingWithNestedDispatch((int) velocityY);
            return true;
        }
        return false;
    }複製程式碼

從上面的實現可以看出,基本上都是通過mParentHelper和mChildHelper來完成滑動的,沒接觸過這方面的同學看著肯定覺得很難理解,的確有些跳躍性,在說清楚這個問題之前必須先把這幾個類之間的互動邏輯理清楚才能不至於不知所云。
先來梳理一下子View和父View的接中都有哪些方法。這種套路一般都是子View發起的然後父View進行回撥從而完成配合。

子View 父View
startNestedScroll onStartNestedScroll、onNestedScrollAccepted
dispatchNestedPreScroll onNestedPreScroll
dispatchNestedScroll onNestedScroll
stopNestedScroll onStopNestedScroll

這裡的子View指的是實現了NestedScrollingChild的View,例如我們的NestedScrollView,父View指的是實現了NestedScrollingParent的View,比如我們上面寫的CollaspingLayout。

首先在子View滑動還未開始之前將呼叫startNestedScroll,對應NestedScrollView中的ACTION_DOWN:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    case MotionEvent.ACTION_DOWN: {
    ......
    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);//在接到點選事件之初呼叫
    break;                           
    }    
}複製程式碼

那麼呼叫 startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)寓意何在?跟進去看到其實是呼叫mChildHelper.startNestedScroll(axes)的實現

public boolean startNestedScroll(int axes) {
        if (hasNestedScrollingParent()) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
                //重點在這-------> 在子View開始滑動前通知父View,回撥到父View的onStartNestedScroll(),
                //父View需要滑動則返回true:
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
                    mNestedScrollingParent = p;
                    //---------> 如果父View決定要和子View一塊滑動,呼叫父ViewonNestedScrollAccepted()
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }複製程式碼

大家仔細看我在程式碼里加的註釋,需要關心的就是父View在此時需要決定是否跟隨子View滑動,看看父View的實現:

 @Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
    return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}複製程式碼

ViewCompat.SCROLL_AXIS_VERTICAL的值是2(10),所以當nestedScrollAxes 也為2的時候,返回true,回到上面可以看到只要是豎直方向的 滑動,父View就會和子View進行巢狀滑動。而在父View的
onNestedScrollAccepted中,則把滑動的方向給儲存下來了。這樣父View和子View的第一次合作關係就結束了,再看看接下來是如何配合的。
當子View在滑動的Move事件中,又開始了巢狀滑動

 @Override
public boolean onTouchEvent(MotionEvent ev) {
    case MotionEvent.ACTION_MOVE:
        final int y = (int) MotionEventCompat.getY(ev, activePointerIndex);
        int deltaY = mLastMotionY - y;
        if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
            deltaY -= mScrollConsumed[1];
            vtev.offsetLocation(0, mScrollOffset[1]);
            mNestedYOffset += mScrollOffset[1];
         }
}複製程式碼

在子View決定滑動的時候,再次在進行自己的滑動前呼叫dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)

     public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            if (dx != 0 || dy != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }

                if (consumed == null) {
                    if (mTempNestedScrollConsumed == null) {
                        mTempNestedScrollConsumed = new int[2];
                    }
                    consumed = mTempNestedScrollConsumed;
                }
                //--------->重點在這,首先把consume封裝好,consumed[0]表示X方向父View消耗的距離,
                // consumed[1]表示Y方向上父View消耗的距離,在父View處理前當然都是0
                consumed[0] = 0;
                consumed[1] = 0;
                //然後呼叫父View的onNestedPreScroll並把當前的dx,dy以及消耗距離的consumed傳遞過去
                ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }複製程式碼

看看父View是怎麼處理的,也是實現了這套機制的,看看他是怎麼處理的:

    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        if (dy > 0 && mHeaderController.canScrollUp()) {
            final int delta = moveBy(dy);
            consumed[0] = 0;
            consumed[1] = delta;
        }
    }複製程式碼

通過moveby計算父View滑動的距離,並將父ViewY方向消耗的距離記錄下來

繼續來看子View,在通知了父View並且父View消耗了滑動距離之後,剩下的就是自己進行滑動了

@Override
public boolean onTouchEvent(MotionEvent ev) {
    case MotionEvent.ACTION_MOVE:
        final int y = (int) MotionEventCompat.getY(ev, activePointerIndex);
        int deltaY = mLastMotionY - y;
        if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
            deltaY -= mScrollConsumed[1];
             //重點在這:-------->父View滑動之後調整自己的Offset為父View滑動的距離
            vtev.offsetLocation(0, mScrollOffset[1]);
            mNestedYOffset += mScrollOffset[1];
         }
         .........
         if(mIsBeingDragged){
            mLastMotionY = y - mScrollOffset[1];
            final int oldY = getScrollY();
            final int range = getScrollRange();
            final int overscrollMode = ViewCompat.getOverScrollMode(this);
            boolean canOverscroll = overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
            (overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS &&
                range > 0);
                // Calling overScrollByCompat will call onOverScrolled, which
                // calls onScrollChanged if applicable.
                //重點在這:-------->父View消耗了部分滑動距離後,子View自己開始滑動,通過overScrollByCompat
                //把滑動距離的引數傳給mScroller進行彈性滑動
                if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0,
                    0, true) && !hasNestedScrollingParent()) {
                    // Break our velocity if we hit a scroll barrier.
                   mVelocityTracker.clear();
                }
         }
         ......
         //重點在這:-------->自己滑動完之後再呼叫dispatchNestedScroll通知父View滑動結束
         if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
             mLastMotionY -= mScrollOffset[1];
             vtev.offsetLocation(0, mScrollOffset[1]);
             mNestedYOffset += mScrollOffset[1];
         }
        break;
}複製程式碼

接下來又是父View的回撥了,來看看父View的處理:

 @Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
                        int dyUnconsumed) {
    final int myConsumed = moveBy(dyUnconsumed);
    final int myUnconsumed = dyUnconsumed - myConsumed;
}複製程式碼

父View在這裡將最後子View滑動完後剩餘的距離進行收尾處理,自我調整後第二輪的巢狀滑動也結束了。

那麼再看看最後一輪滑動:

@Override
public boolean onTouchEvent(MotionEvent ev) {
case MotionEvent.ACTION_UP:
    /* Release the drag */
    mIsBeingDragged = false;
    mActivePointerId = INVALID_POINTER;
    recycleVelocityTracker();
    if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) {
        ViewCompat.postInvalidateOnAnimation(this);
    }
    stopNestedScroll();
    break;
}複製程式碼

在觸控事件的最後一個階段,也就是ACTION_UP時,呼叫stopNestedScroll(),這時會通知父View的onStopNestedScroll()來對整個系列的滑動來收尾

    public void stopNestedScroll() {
        if (mNestedScrollingParent != null) {
            ViewParentCompat.onStopNestedScroll(mNestedScrollingParent, mView);
            mNestedScrollingParent = null;
        }
    }複製程式碼

父類最後在自己的onStopNestedScroll()實現相關的收尾處理,比如重置滑動狀態標記,完成動畫操作,通知滑動結束等。這樣,整個滑動巢狀流程就完成了。

最後來總結一下整個流程,分為三個步驟:

  • 步驟一:子View的ACTION_DOWN呼叫startNestedScroll---->父View的onStartNestedScroll判斷是否要一起滑動,父ViewonNestedScrollAccepted同意協同滑動
  • 步驟二:子View的ACTION_MOVE呼叫dispatchNestedPreScroll---->父View的onNestedPreScroll在子View滑動之前先進行滑動並消耗需要的距離---->父View完成該次滑動之後返回消耗的距離,子View在剩下的距離中再完成自己需要的滑動
  • 步驟三:子View滑動完成之後呼叫dispatchNestedScroll---->父View的onNestedScroll處理父View和子View之前滑動剩餘的距離
  • 步驟四:子View的ACTION_UP呼叫stopNestedScroll---->父View的onStopNestedScroll完成滑動收尾工作

這樣,子View和父View的一系列巢狀滑動就完成了,可以看出來整個巢狀滑動還是靠子View來推動父View進行滑動的,這也解決了在傳統的滑動事件中一旦事件被子View處理了就很難再分享給父View共同處理的問題,這也是巢狀滑動的一個特點。

結語

巢狀滑動作為官方推出的一套更加方便的處理滑動的工具,可以說是很大程度上減少了我們在出來這方面問題上的複雜性,當然,上面提到的僅僅是原理,真正的實現大家可以仔細地去看Design包一些控制元件的原始碼來進一步深入瞭解。同時,下一次還將繼續分享如何用三大利器:CoordinatorLayout,AppBarLayout,CollapsingToolbarLayout來實現攜程機票首頁的互動,敬請期待。

附上仿攜程機票首頁互動:
三分鐘帶你仿攜程機票首頁炫酷互動

相關文章