Android NestedScrolling嵌套滑动机制

Android在发布5.0之后加入了嵌套滑动机制NestedScrolling,为嵌套滑动提供了更方便的处理方案。在此对嵌套滑动机制进行详细的分析。

嵌套滑动的常见用法比如在滑动列表的时候隐藏相关的TopBar和BottomBar,增加列表的信息展示范围,让用户聚焦于App想展示的内容上等。官方出的Design包里也有很多支持该机制的炫酷控件,比如CoordinatorLayout,AppBarLayout等,在用户体验上有很大的进步。

说道嵌套滑动,离不开以下几个内容:

  • NestedScrollingChild
  • NestedScrollingParent
  • NestedScrollingChildHelper
  • NestedScrollingParentHelper

在具体说明之前,先来看看我们的Sample,这是一个仿携程机票首页的Demo

这里用到了一个实现了NestedScrollingParent的CollaspingLayout作为父View和一个实现了NestedScrollingChild的NestedScrollView作为子View进行嵌套滑动,布局可以简单的描述成:


具体的布局结构大致如下:

<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来实现携程机票首页的交互,敬请期待。

附上仿携程机票首页交互:
三分钟带你仿携程机票首页炫酷交互