浅析NestedScrolling嵌套滑动机制之基础篇

嵌套系列导航

本文已在公众号鸿洋原创发布。未经许可,不得以任何形式转载!java

概述

NestedScrolling是Android5.0推出的嵌套滑动机制,可以让父View和子View在滑动时相互协调配合能够实现连贯的嵌套滑动,它基于原有的触摸事件分发机制上为ViewGroup和View增长处理滑动的方法提供调用,后来为了向前兼容到Android1.6,在Revision 22.1.0的android.support.v4兼容包中提供了从View、ViewGroup抽取出NestedScrollingChild、NestedScrollingParent两个接口和NestedScrollingChildHelper、NestedScrollingParentHelper两个辅助类来帮助控件实现嵌套滑动,CoordinatorLayout即是基于这个机制实现各类神奇的滑动效果。android

处理同向滑动事件冲突

若是两个可滑动的容器嵌套,外部View拦截了内部View的滑动,可能形成滑动冲突,一般基于传统的触摸事件分发机制来解决:

1.外部拦截法

public class MyScrollView extends ScrollView {
    private int mLastY = 0;
    
    //此处省略构造方法
    ...

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int y = (int) ev.getY();
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                intercepted = false;
                //调用ScrollView的onInterceptTouchEvent()初始化mActivePointerId
                super.onInterceptTouchEvent(ev);
                break;
            case MotionEvent.ACTION_MOVE:
                int detY = y - mLastY;
                //这里要找到子ScrollView
                View contentView = findViewById(R.id.my_scroll_inner);
                if (contentView == null) {
                    return true;
                }
                //判断子ScrollView是否滑动到顶部或者顶部
                boolean isChildScrolledTop = detY > 0 && !contentView.canScrollVertically(-1);
                boolean isChildScrolledBottom = detY < 0 && !contentView.canScrollVertically(1);
                if (isChildScrolledTop || isChildScrolledBottom) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
        }
        mLastY = y;
        return intercepted;
    }
}
复制代码

2.内部拦截法

public class MyScrollView extends ScrollView {
    private int mLastY = 0;
    
    //此处省略构造方法
    ...

   @Override
   public boolean dispatchTouchEvent(MotionEvent ev) {
       int y = (int) ev.getY();
       switch (ev.getActionMasked()) {
           case MotionEvent.ACTION_DOWN:
               getParent().requestDisallowInterceptTouchEvent(true);
               break;
           case MotionEvent.ACTION_MOVE:
               int detY = y - mLastY;
               boolean isScrolledTop = detY > 0 && !canScrollVertically(-1);
               boolean isScrolledBottom = detY < 0 && !canScrollVertically(1);
               //根据自身是否滑动到顶部或者顶部来判断让父View拦截触摸事件
               if (isScrolledTop || isScrolledBottom) {
                   getParent().requestDisallowInterceptTouchEvent(false);
               }
               break;
       }
       mLastY = y;
       return super.dispatchTouchEvent(ev);
   }

   @Override
   public boolean onInterceptTouchEvent(MotionEvent ev) {
       if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
           super.onInterceptTouchEvent(ev);
           return false;
       }
       return true;
   }
}
复制代码

3.小结

上面经过两种经典的解决方案,在内部View能够滑动时,外部View不拦截,当内部View滑动到底部或者顶部时,让外部消费滑动事件进行滑动。通常而言,外部拦截法和内部拦截法不能公用。 不然内部容器可能并无机会调用 requestDisallowInterceptTouchEvent方法。在传统的触摸事件分发中,若是不手动调用分发事件或者去发出事件,外部View最早拿到触摸事件,一旦它被外部View拦截消费了,内部View没法接收到触摸事件,同理,内部View消费了触摸事件,外部View也没有机会响应触摸事件。 而接下介绍的NestedScrolling机制,在一次滑动事件中外部View和内部View都有机会对滑动进行响应,这样处理滑动冲突就相对方便许多。数组

NestedScrolling机制原理

NestedScrollingChild(下图简称nc)、NestedScrollingParent(下图简称np)逻辑上分别对应以前内部View和外部View的角色,之因此称之为逻辑上是由于View能够同时扮演NestedScrollingChild和NestedScrollingParent,下面图片就是NestedScrolling的交互流程。 app

NestedScrolling交互 流程示意图.png

接下来详细说明一下上图的交互流程:ide

  • 1.当NestedScrollingChild接收到触摸事件MotionEvent.ACTION_DOWN时,它会往外层布局遍历寻找最近的NestedScrollingParent请求配合处理滑动。因此它们之间层级不必定是直接上下级关系。函数

  • 2.若是NestedScrollingParent不配合NestedScrollingChild处理滑动就没有接下来的流程,不然就会配合处理滑动。工具

  • 3.NestedScrollingChild要滑动以前,它先拿到MotionEvent.ACTION_MOVE滑动的dx,dy并将一个有两个元素的数组(分别表明NestedScrollingParent要滑动的水平和垂直方向的距离)做为输出参数一同传给NestedScrollingParent。布局

  • 4.NestedScrollingParent拿到上面【3】NestedScrollingChild传来的数据,将要消费的水平和垂直方向的距离传进数组,这样NestedScrollingChild就知道NestedScrollingParent要消费滑动值是多少了。post

  • 5.NestedScrollingChild将【2】里拿到的dx、dy减去【4】NestedScrollingParent消费滑动值,计算出剩余的滑动值;若是剩余的滑动值为0说明NestedScrollingParent所有消费了NestedScrollingChild不该进行滑动;不然NestedScrollingChild根据剩余的滑动值进行消费,而后将本身消费了多少、还剩余多少汇报传递给NestedScrollingParent。动画

  • 6.若是NestedScrollingChild在滑动期间发生的惯性滑动,它会将velocityX,velocityY传给NestedScrollingParent,并询问NestedScrollingParent是否要所有消费。

  • 7.NestedScrollingParent收到【6】NestedScrollingChild传来的数据,告诉NestedScrollingChild是否所有消费惯性滑动。

  • 8.若是在【7】NestedScrollingParent没有所有消费惯性滑动,NestedScrollingChild会将velocityX,velocityY、自身是否须要消费所有惯性滑动传给NestedScrollingParent,并询问NestedScrollingParent是否要所有消费。

  • 9.NestedScrollingParent收到【8】NestedScrollingChild传来的数据,告诉NestedScrollingChild是否所有消费惯性滑动。

  • 10.NestedScrollingChild中止滑动时通知NestedScrollingParent。

PS:

  • A.上面的【消费】是指可滑动View调用自身的滑动方法进行滑动来消耗滑动数值,好比scrollBy()、scrollTo()、fling()、offsetLeftAndRight()、offsetTopAndBottom()、layout()、Scroller、LayoutParams等,View实现NestedScrollingParent、NestedScrollingChild只仅仅是能将数值进行传递,须要配合Touch事件根据需求去调用NestScrolling的接口和辅助类,而自己不支持滑动的View即便有嵌套滑动的相关方法也不能进行嵌套滑动。
  • B.在【1】中外层实现NestedScrollingParent的View不应拦截NestedScrollingChild的MotionEvent.ACTION_DOWN;在【2】中若是NestedScrollingParent配合处理滑动时,实现NestedScrollingChild的View应该经过getParent().requestDisallowInterceptTouchEvent(true)往上递归关闭外层View的事件拦截机制,这样确保【3】中NestedScrollingChild先拿到MotionEvent.ACTION_MOVE。具体能够参考RecyclerView和NestedScrollView源码的触摸事件处理。

类与接口

前面提到Android 5.0及以上的View、ViewGroup自身分别就有NestedScrollingChild和NestedScrollingParent的方法,而方法逻辑就是对应的NestedScrollingChildHelper和NestedScrollingParentHelper的具体方法实现,因此本小节不讲解View、ViewGroup的NestedScrolling机制相关内容,请自行查看源码。

1.NestedScrollingChild

public interface NestedScrollingChild {
    /** * @param enabled 开启或关闭嵌套滑动 */
    void setNestedScrollingEnabled(boolean enabled);

    /** * @return 返回是否开启嵌套滑动 */    
    boolean isNestedScrollingEnabled();

    /** * 沿着指定的方向开始滑动嵌套滑动 * @param axes 滑动方向 * @return 返回是否找到NestedScrollingParent配合滑动 */
    boolean startNestedScroll(@ScrollAxis int axes);

    /** * 中止嵌套滑动 */
    void stopNestedScroll();

    /** * @return 返回是否有配合滑动NestedScrollingParent */
    boolean hasNestedScrollingParent();

    /** * 滑动完成后,将已经消费、剩余的滑动值分发给NestedScrollingParent * @param dxConsumed 水平方向消费的距离 * @param dyConsumed 垂直方向消费的距离 * @param dxUnconsumed 水平方向剩余的距离 * @param dyUnconsumed 垂直方向剩余的距离 * @param offsetInWindow 含有View今后方法调用以前到调用完成后的屏幕坐标偏移量, * 可使用这个偏移量来调整预期的输入坐标(即上面4个消费、剩余的距离)跟踪,此参数可空。 * @return 返回该事件是否被成功分发 */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    /** * 在滑动以前,将滑动值分发给NestedScrollingParent * @param dx 水平方向消费的距离 * @param dy 垂直方向消费的距离 * @param consumed 输出坐标数组,consumed[0]为NestedScrollingParent消耗的水平距离、 * consumed[1]为NestedScrollingParent消耗的垂直距离,此参数可空。 * @param offsetInWindow 同上dispatchNestedScroll * @return 返回NestedScrollingParent是否消费部分或所有滑动值 */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow);

    /** * 将惯性滑动的速度和NestedScrollingChild自身是否须要消费此惯性滑动分发给NestedScrollingParent * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @param consumed NestedScrollingChild自身是否须要消费此惯性滑动 * @return 返回NestedScrollingParent是否消费所有惯性滑动 */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    /** * 在惯性滑动以前,将惯性滑动值分发给NestedScrollingParent * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @return 返回NestedScrollingParent是否消费所有惯性滑动 */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
复制代码

2.NestedScrollingParent

public interface NestedScrollingParent {
    /** * 对NestedScrollingChild发起嵌套滑动做出应答 * @param child 布局中包含下面target的直接父View * @param target 发起嵌套滑动的NestedScrollingChild的View * @param axes 滑动方向 * @return 返回NestedScrollingParent是否配合处理嵌套滑动 */
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);

    /** * NestedScrollingParent配合处理嵌套滑动回调此方法 * @param child 同上 * @param target 同上 * @param axes 同上 */
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
   
    /** * 嵌套滑动结束 * @param target 同上 */
    void onStopNestedScroll(@NonNull View target);

    /** * NestedScrollingChild滑动完成后将滑动值分发给NestedScrollingParent回调此方法 * @param target 同上 * @param dxConsumed 水平方向消费的距离 * @param dyConsumed 垂直方向消费的距离 * @param dxUnconsumed 水平方向剩余的距离 * @param dyUnconsumed 垂直方向剩余的距离 */
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);

    /** * NestedScrollingChild滑动完以前将滑动值分发给NestedScrollingParent回调此方法 * @param target 同上 * @param dx 水平方向的距离 * @param dy 水平方向的距离 * @param consumed 返回NestedScrollingParent是否消费部分或所有滑动值 */
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);

    /** * NestedScrollingChild在惯性滑动以前,将惯性滑动的速度和NestedScrollingChild自身是否须要消费此惯性滑动分 * 发给NestedScrollingParent回调此方法 * @param target 同上 * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @param consumed NestedScrollingChild自身是否须要消费此惯性滑动 * @return 返回NestedScrollingParent是否消费所有惯性滑动 */
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
    
    /** * NestedScrollingChild在惯性滑动以前,将惯性滑动的速度分发给NestedScrollingParent * @param target 同上 * @param velocityX 同上 * @param velocityY 同上 * @return 返回NestedScrollingParent是否消费所有惯性滑动 */
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);

    /** * @return 返回当前嵌套滑动的方向 */
    int getNestedScrollAxes();
}
复制代码

3.方法调用流程图:

4.NestedScrollingChildHepler

NestedScrollingChildHepler对NestedScrollingChild的接口方法作了代理,您能够结合实际状况借助它来实现,如:

public class MyScrollView extends View implements NestedScrollingChild{
    ...
    @Override
    public boolean startNestedScroll(int axes) {
        return mChildHelper.startNestedScroll(axes);
    }
}
复制代码

这里只分析关键的方法,具体代码请参考源码。

4.1 startNestedScroll()

public boolean startNestedScroll(int axes) {
        //判断是否找到配合处理滑动的NestedScrollingParent
        if (hasNestedScrollingParent()) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {//判断是否开启滑动嵌套
            ViewParent p = mView.getParent();
            View child = mView;
            //循环往上层寻找配合处理滑动的NestedScrollingParent
            while (p != null) {
                //ViewParentCompat.onStartNestedScroll()会判断p是否实现NestedScrollingParent,
                //如果则将p转为NestedScrollingParent类型调用onStartNestedScroll()方法
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
                    mNestedScrollingParent = p;
                    //经过ViewParentCompat调用p的onNestedScrollAccepted()方法
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }
复制代码

这个方法首先会判断是否已经找到了配合处理滑动的NestedScrollingParent、若找到了则返回true,不然会判断是否开启嵌套滑动,若开启了则经过构造函数注入的View来循环往上层寻找配合处理滑动的NestedScrollingParent,循环条件是经过ViewParentCompat这个兼容类判断p是否实现NestedScrollingParent,如果则将p转为NestedScrollingParent类型调用onStartNestedScroll()方法若是返回true则证实找配合处理滑动的NestedScrollingParent,因此接下来一样借助ViewParentCompat调用NestedScrollingParent的onNestedScrollAccepted()。

4.2 dispatchNestedPreScroll()

public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {//若是开启嵌套滑动并找到配合处理滑动的NestedScrollingParent
            if (dx != 0 || dy != 0) {//若是有水平或垂直方向滑动
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    //先记录View当前的在Window上的x、y坐标值
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
                //初始化输出数组consumed
                if (consumed == null) {
                    if (mTempNestedScrollConsumed == null) {
                        mTempNestedScrollConsumed = new int[2];
                    }
                    consumed = mTempNestedScrollConsumed;
                }
                consumed[0] = 0;
                consumed[1] = 0;
                //经过ViewParentCompat调用NestedScrollingParent的onNestedPreScroll()方法
                ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

                if (offsetInWindow != null) {
                    //将以前记录好的x、y坐标减去调用NestedScrollingParent的onNestedPreScroll()后View的x、y坐标,计算得出偏移量并赋值进offsetInWindow数组
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                //consumed数组的两个元素的值有其中一个不为0则说明NestedScrollingParent消耗的部分或者所有滑动值
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
复制代码

这个方法首先会判断是否开启嵌套滑动并找到配合处理滑动的NestedScrollingParent,若符合这两个条件则会根据参数dx、dy滑动值判断是否有水平或垂直方向滑动,如有滑动调用mView.getLocationInWindow()将View当前的在Window上的x、y坐标值赋值进offsetInWindow数组并以startX、startY记录,接下来初始化输出数组consumed、并经过ViewParentCompat调用NestedScrollingParent的onNestedPreScroll(),再次调用mView.getLocationInWindow()将调用NestedScrollingParent的onNestedPreScroll()后的View在Window上的x、y坐标值赋值进offsetInWindow数组并与以前记录好的startX、startY相减计算得出偏移量,接着以consumed数组的两个元素的值有其中一个不为0做为boolean值返回,若条件为true说明NestedScrollingParent消耗的部分或者所有滑动值。

4.3 dispatchNestedScroll()

public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {//若是开启嵌套滑动并找到配合处理滑动的NestedScrollingParent
            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {//若是有消费滑动值或者有剩余滑动值
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    //先记录View当前的在Window上的x、y坐标值
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
                //经过ViewParentCompat调用NestedScrollingParent的onNestedScroll()方法
                ViewParentCompat.onNestedScroll(mNestedScrollingParent, mView, dxConsumed,
                        dyConsumed, dxUnconsumed, dyUnconsumed);

                if (offsetInWindow != null) {
                    //将以前记录好的x、y坐标减去调用NestedScrollingParent的onNestedScroll()后View的x、y坐标,计算得出偏移量并赋值进offsetInWindow数组
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                //返回true代表NestedScrollingChild的dispatchNestedScroll事件成功分发NestedScrollingParent
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
复制代码

这个方法与上面的dispatchNestedPreScroll()方法十分相似,这里就不细说了。

4.3 dispatchNestedPreFling()、dispatchNestedFling()

public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            //经过ViewParentCompat调用NestedScrollingParent的onNestedPreFling()方法,返回值表示NestedScrollingParent是否消费所有惯性滑动
            return ViewParentCompat.onNestedPreFling(mNestedScrollingParent, mView, velocityX,
                    velocityY);
        }
        return false;
    }

    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            //经过ViewParentCompat调用NestedScrollingParent的onNestedFling()方法,返回值表示NestedScrollingParent是否消费所有惯性滑动
            return ViewParentCompat.onNestedFling(mNestedScrollingParent, mView, velocityX,
                    velocityY, consumed);
        }
        return false;
    }
复制代码

这两方法都是经过ViewParentCompat调用NestedScrollingParent对应的fling方法来返回NestedScrollingParent是否消费所有惯性滑动。

4.NestedScrollingParentHelper

public class NestedScrollingParentHelper {
    private final ViewGroup mViewGroup;
    private int mNestedScrollAxes;

    public NestedScrollingParentHelper(ViewGroup viewGroup) {
        mViewGroup = viewGroup;
    }

    public void onNestedScrollAccepted(View child, View target, int axes) {
        mNestedScrollAxes = axes;
    }

    public int getNestedScrollAxes() {
        return mNestedScrollAxes;
    }

    public void onStopNestedScroll(View target) {
        mNestedScrollAxes = 0;
    }
}
复制代码

NestedScrollingParentHelper只提供对应NestedScrollingParent相关的onNestedScrollAccepted()和onStopNestedScroll()方法,主要维护mNestedScrollAxes管理滑动的方向字段。

NestedScrolling机制的改进

惯性滑动不连续问题

在使用以前NestedScrolling机制的 系统控件 嵌套滑动,当内部View快速滑动产生惯性滑动到边缘就中止,而不将惯性滑动传递给外部View继续消费惯性滑动,就会出现下图两个NestedScrollView嵌套滑动这种 惯性滑动不连续 的状况:

惯性滑动不连续

这里以com.android.support:appcompat-v7:22.1.0的NestedScrollView源码做为分析问题例子:

@Override
    public boolean onTouchEvent(MotionEvent ev) {
        ...
        switch (actionMasked) {
            ...
            case MotionEvent.ACTION_UP:
                if (mIsBeingDragged) {
                    final VelocityTracker velocityTracker = mVelocityTracker;
                    velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                    int initialVelocity = (int) VelocityTrackerCompat.getYVelocity(velocityTracker,
                            mActivePointerId);

                    if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                        //分发惯性滑动
                        flingWithNestedDispatch(-initialVelocity);
                    }

                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                }
                break;
        }
        ...
    }

    private void flingWithNestedDispatch(int velocityY) {
        final int scrollY = getScrollY();
        final boolean canFling = (scrollY > 0 || velocityY > 0) &&
                (scrollY < getScrollRange() || velocityY < 0);
        if (!dispatchNestedPreFling(0, velocityY)) {//将惯性滑动分发给NestedScrollingParent,让它先对惯性滑动进行处理
            dispatchNestedFling(0, velocityY, canFling);//若惯性滑动没被消费,再次将惯性滑动分发给NestedScrollingParent,并带上自身是否能消费fling的canFling参数让NestedScrollingParent根据状况处理决定canFling是true仍是false
            if (canFling) {
                //执行fling()消费惯性滑动
                fling(velocityY);
            }
        }
    }

    public void fling(int velocityY) {
        if (getChildCount() > 0) {
            int height = getHeight() - getPaddingBottom() - getPaddingTop();
            int bottom = getChildAt(0).getHeight();
            //初始化fling的参数
            mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0,
                    Math.max(0, bottom - height), 0, height/2);
            //重绘会触发computeScroll()进行滚动
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            int oldX = getScrollX();
            int oldY = getScrollY();
            int x = mScroller.getCurrX();
            int y = mScroller.getCurrY();

            if (oldX != x || oldY != y) {
                final int range = getScrollRange();
                final int overscrollMode = ViewCompat.getOverScrollMode(this);
                final boolean canOverscroll = overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
                        (overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);

                overScrollByCompat(x - oldX, y - oldY, oldX, oldY, 0, range,
                        0, 0, false);

                if (canOverscroll) {
                    ensureGlows();
                    if (y <= 0 && oldY > 0) {
                        mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
                    } else if (y >= range && oldY < range) {
                        mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
                    }
                }
            }
        }
    }
复制代码

上面代码执行以下:

  • 1.当快速滑动并抬起手指时onTouchEvent()方法会命中MotionEvent.ACTION_UP,执行关键flingWithNestedDispatch()方法将垂直方向的惯性滑动值分发。

  • 2.flingWithNestedDispatch()方法先调用dispatchNestedPreFling()将惯性滑动分发给NestedScrollingParent,若NestedScrollingParent没有消费则调用dispatchNestedFling()并带上自身是否能消费fling的canFling参数让NestedScrollingParent能够根据状况处理决定canFling是true仍是false,若canFling值为true,执行fling()方法。

  • 3.fling()方法执行mScroller.fling()初始化fling参数,而后 调用ViewCompat.postInvalidateOnAnimation()重绘触发computeScroll()方法进行滚动。

  • 4.computeScroll()方法里面只让自身进行fling,并无在自身fling到边缘时将惯性滑动分发给NestedScrollingParent

NestedScrollingChild二、NestedScrollingParent2

在Revision 26.1.0的android.support.v4兼容包添加了NestedScrollingChild二、NestedScrollingParent2两个接口:

public interface NestedScrollingChild2 extends NestedScrollingChild {

    boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);

    void stopNestedScroll(@NestedScrollType int type);

    boolean hasNestedScrollingParent(@NestedScrollType int type);

    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);
            
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, @NestedScrollType int type);
}

public interface NestedScrollingParent2 extends NestedScrollingParent {

    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);

    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes, @NestedScrollType int type);

    void onStopNestedScroll(@NonNull View target, @NestedScrollType int type);

    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);

    void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed, @NestedScrollType int type);
}
复制代码

它们分别继承NestedScrollingChild、NestedScrollingParent,都为滑动相关的方法添加了int类型参数type,这个参数有两个值:TYPE_TOUCH值为0表示滑动由用户手势滑动屏幕触发;TYPE_NON_TOUCH值为1表示滑动不是由用户手势滑动屏幕触发;同时View、ViewGroup、NestedScrollingChildHelper、NestedScrollingParentHelper一样根据参数type作了调整。

前面说到由于系统控件在computeScroll()方法里面只让自身进行fling,并无在自身fling到边缘时将惯性滑动分发给NestedScrollingParent致使惯性滑动不连贯,因此这里以com.android.support:appcompat-v7:26.1.0的NestedScrollView源码看看如何使用改进后的NestedScrolling机制:

public void fling(int velocityY) {
            if (getChildCount() > 0) {
                //发起滑动嵌套,注意ViewCompat.TYPE_NON_TOUCH参数表示不是由用户手势滑动屏幕触发
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL,ViewCompat.TYPE_NON_TOUCH);
                mScroller.fling(getScrollX(), getScrollY(), 
                    0, velocityY, 0, 0,Integer.MIN_VALUE, Integer.MAX_VALUE,0, 0);
                mLastScrollerY = getScrollY();
                ViewCompat.postInvalidateOnAnimation(this);
            }
        }

    @Override
    public void computeScroll() {
            if (mScroller.computeScrollOffset()) {
                final int x = mScroller.getCurrX();
                final int y = mScroller.getCurrY();

                int dy = y - mLastScrollerY;

                // Dispatch up to parent(将滑动值分发给NestedScrollingParent2)
                if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null,ViewCompat.TYPE_NON_TOUCH)) {
                    //计算NestedScrollingParent2消费后剩余的滑动值
                    dy -= mScrollConsumed[1];
                }

                if (dy != 0) {//若滑动值没有NestedScrollingParent2所有消费掉,则自身进行消费滚动
                    final int range = getScrollRange();
                    final int oldScrollY = getScrollY();

                    overScrollByCompat(0, dy, getScrollX(), oldScrollY, 0, range, 0, 0, false);

                    final int scrolledDeltaY = getScrollY() - oldScrollY;
                    final int unconsumedY = dy - scrolledDeltaY;

                    if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null,ViewCompat.TYPE_NON_TOUCH)) {//若滚动值没有分发成功给NestedScrollingParent2,则本身用EdgeEffect消费
                        final int mode = getOverScrollMode();
                        final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
                                || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
                        if (canOverscroll) {
                            ensureGlows();
                            if (y <= 0 && oldScrollY > 0) {
                                mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
                            } else if (y >= range && oldScrollY < range) {
                                mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
                            }
                        }
                    }
                }

                // Finally update the scroll positions and post an invalidation
                mLastScrollerY = y;
                ViewCompat.postInvalidateOnAnimation(this);
            } else {
                // We can't scroll any more, so stop any indirect scrolling
                if (hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
                    stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
                }
                // and reset the scroller y
                mLastScrollerY = 0;
            }
        }
复制代码

代码分析以下:

  • 1.与以前的NestedScrollView相比,fling()方法里面用到了NestedScrollingChild2的startNestedScroll方法发起滑动嵌套。

  • 2.computeScroll()方法首先调用dispatchNestedPreScroll()将滑动值分发给NestedScrollingParent2,若滑动值没有被NestedScrollingParent2所有消费掉,则自身进行消费滚动,而后再调用dispatchNestedScroll()将自身消费、剩余的滑动值分发给NestedScrollingParent2,若分发失败则用EdgeEffect(这个用来滑动到顶部或者底部时会出现一个波浪形的边缘效果)消费掉,当mScroller滚动完成后调用stopNestedScroll()方法结束嵌套滑动。

OverScroller未终止滚动动画

Scroller未关闭

在使用以前NestedScrolling机制的 系统控件 嵌套滑动,当子、父View都在顶部时,首先快速下滑子View并抬起手指制造惯性滑动,而后立刻滑动父View,这时就会出现上图的两个NestedScrollView嵌套滑动现象,你手指往上滑视图内容往下滚一段距离,视图内容马上就会自动往上回滚。

这里仍是以com.android.support:appcompat-v7:26.1.0的NestedScrollView源码做为分析问题例子:

private void flingWithNestedDispatch(int velocityY) {
        final int scrollY = getScrollY();
        final boolean canFling = (scrollY > 0 || velocityY > 0)
                && (scrollY < getScrollRange() || velocityY < 0);
        if (!dispatchNestedPreFling(0, velocityY)) {
            dispatchNestedFling(0, velocityY, canFling);
            fling(velocityY);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (actionMasked) {
            ...
            case MotionEvent.ACTION_DOWN: {
                ...
                //中止mScroller滚动
                 if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
            }
            ...
        }
        ...
    }
复制代码

代码执行以下:

  • 1.这里分析场景是两个NestedScrollView嵌套滑动,因此dispatchNestedPreFling()返回值为false,子View执行就会fling()方法,前面分析过fling()方法调用mScroller.fling()触发computeScroll()进行实际的滚动。

  • 2.在子View调用computeScroll()方法期间,若是此时子View不命中MotionEvent.ACTION_DOWN,mScroller是不会中止滚动,只能等待它完成,因而就子View就不停调用dispatchNestedPreScroll()和dispatchNestedScroll()分发滑动值给父View,就出现了上图的场景。

NestedScrollingChild三、NestedScrollingParent3

在androidx.core 1.1.0-alpha01开始引入NestedScrollingChild三、NestedScrollingParent3,它们在androidx.core:core:1.1.0正式被添加:

public interface NestedScrollingChild3 extends NestedScrollingChild2 {
        void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);
    }

    public interface NestedScrollingParent3 extends NestedScrollingParent2 {
        void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);

    }
复制代码

NestedScrollingChild3继承NestedScrollingChild2重载dispatchNestedScroll()方法,从返回值类型boolean改成void类型,添加了一个int数组consumed参数做为输出参数记录NestedScrollingParent3消费的滑动值,同理,NestedScrollingParent3继承NestedScrollingParent2重载onNestedScroll添加了一个int数组consumed参数来对应NestedScrollingChild3,NestedScrollingChildHepler、NestedScrollingParentHelper一样根据变化作了适配调整。

下面是androidx.appcompat:appcompat:1.1.0的NestedScrollView源码看看如何使用改进后的NestedScrolling机制:

@Override
    public void computeScroll() {
        if (mScroller.isFinished()) {
            return;
        }

        mScroller.computeScrollOffset();
        final int y = mScroller.getCurrY();
        int unconsumed = y - mLastScrollerY;
        mLastScrollerY = y;

        // Nested Scrolling Pre Pass(分发滑动值给NestedScrollingParent3)
        mScrollConsumed[1] = 0;
        dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
                ViewCompat.TYPE_NON_TOUCH);
        //计算剩余的滑动值
        unconsumed -= mScrollConsumed[1];

        final int range = getScrollRange();

        if (unconsumed != 0) {
            // Internal Scroll(自身滚动消费滑动值)
            final int oldScrollY = getScrollY();
            overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
            final int scrolledByMe = getScrollY() - oldScrollY;
            //计算剩余的滑动值
            unconsumed -= scrolledByMe;

            // Nested Scrolling Post Pass(分发滑动值给NestedScrollingParent3)
            mScrollConsumed[1] = 0;
            dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset,
                    ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
            //计算剩余的滑动值
            unconsumed -= mScrollConsumed[1];
        }

        if (unconsumed != 0) {
            //EdgeEffect消费剩余滑动值
            final int mode = getOverScrollMode();
            final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
                    || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
            if (canOverscroll) {
                ensureGlows();
                if (unconsumed < 0) {
                    if (mEdgeGlowTop.isFinished()) {
                        mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
                    }
                } else {
                    if (mEdgeGlowBottom.isFinished()) {
                        mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
                    }
                }
            }
            //中止mScroller滚动动画并结束滑动嵌套
            abortAnimatedScroll();
        }

        if (!mScroller.isFinished()) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    private void abortAnimatedScroll() {
        mScroller.abortAnimation();
        stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
    }
复制代码

代码分析以下:

  • 1.首先调用dispatchNestedPreScroll()将滑动值分发给NestedScrollingParent3并附带mScrollConsumed数组做为输出参数记录其具体消费多少滑动值,变量unconsumed表示剩余的滑动值,在调用dispatchNestedPreScroll()后,unconsumed减去以前的mScrollConsumed数组的元素从新赋值;

  • 2.此时unconsumed值不为0,说明NestedScrollingParent3没有消费掉所有滑动值,则自身掉用overScrollByCompat()进行滚动消费滑动值,unconsumed减去记录本次消费的滑动值scrolledByMe从新赋值;而后调用dispatchNestedScroll()相似于【1】将滑动值分发给NestedScrollingParent3的操做而后计算unconsumed;

  • 3.若unconsumed值还不为0,说明滑动值没有彻底消费掉,此时实现NestedScrollingParent三、NestedScrollingChild3对应的父View、子View在同一方向都滑动到了边缘尽头,此时自身用EdgeEffect消费剩余滑动值并调用abortAnimatedScroll()来 中止mScroller滚动并结束嵌套滑动

NestedScrolling机制的使用

若是你最低支持android版本是5.0及其以上,你可使用View、ViewGroup自己对应的NestedScrollingChild、NestedScrollingParent接口;若是你使用AndroidX那么你就须要使用NestedScrollingChild三、NestedScrollingParent3;若是你兼容Android5.0以前版本请使用NestedScrollingChild二、NestedScrollingParent2。下面的例子是伪代码,由于下面的自定义View没有实现相似Scroller的方式来消费滑动值,所以它运行也不能实现嵌套滑动进行滑动,只是提供给你们处理触摸事件调用NestedScrolling机制的思路。

使用NestedScrollingParent2

若是要兼容NestedScrollingParent则覆写其接口便可,能够借助NestedScrollingParentHelper结合需求做方法代理,你能够根据具体业务在onStartNestedScroll()选择在嵌套滑动的方向、在onNestedPreScroll()要不要消费NestedScrollingChild2的滑动值等等。

使用NestedScrollingChild2

若是要兼容NestedScrollingChild则覆写其接口便可,能够借助NestedScrollingChildHelper结合需求做方法代理。

public class NSChildView extends FrameLayout implements NestedScrollingChild2 {
    private int mLastMotionY;
    private final int[] mScrollOffset = new int[2];
    private final int[] mScrollConsumed = new int[2];
    ...

  @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN: {
                //关闭外层触摸事件拦截,确保能拿到MotionEvent.ACTION_MOVE
                final ViewParent parent = getParent();
                if (parent != null) {
                    parent.requestDisallowInterceptTouchEvent(true);
                }
                mLastMotionY = (int) ev.getY();
                //开始嵌套滑动
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                break;
            }
            case MotionEvent.ACTION_MOVE:
                final int y = (int) ev.getY();
                int deltaY = mLastMotionY - y;
                //开始滑动以前,分发滑动值给NestedScrollingParent2
                if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                        ViewCompat.TYPE_TOUCH)) {
                    deltaY -= mScrollConsumed[1];
                }
                //模拟Scroller消费剩余滑动值
                final int oldY = getScrollY();
                scrollBy(0,deltaY);

                //计算自身消费的滑动值,汇报给NestedScrollingParent2
                final int scrolledDeltaY = getScrollY() - oldY;
                final int unconsumedY = deltaY - scrolledDeltaY;
                if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
                        ViewCompat.TYPE_TOUCH)) {
                    mLastMotionY -= mScrollOffset[1];
                }else {
                    //能够选择EdgeEffectCompat消费剩余的滑动值
                }
                break;
            case MotionEvent.ACTION_UP:
                //能够用VelocityTracker计算velocityY
                int velocityY=0;
                //根据需求判断是否能Fling
                boolean canFling=true;
                if (!dispatchNestedPreFling(0, velocityY)) {
                    dispatchNestedFling(0, velocityY, canFling);
                    //模拟执行惯性滑动,若是你但愿惯性滑动也能传递给NestedScrollingParent2,对于每次消费滑动距离,
                    // 与MOVE事件中处理滑动同样,按照dispatchNestedPreScroll() -> 本身消费 -> dispatchNestedScroll() -> 本身消费的顺序进行消费滑动值
                    fling(velocityY);
                }
                //中止嵌套滑动
                stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
            case MotionEvent.ACTION_CANCEL:
                //中止嵌套滑动
                stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
        }
        return true;
    }
复制代码

同时使用NestedScrollingChild二、NestedScrollingParent2

这种状况一般是ViewGroup支持布局嵌套如:

<android.support.v4.widget.NestedScrollView android:tag="我是爷爷">
    <android.support.v4.widget.NestedScrollView android:tag="我是爸爸">
        <android.support.v4.widget.NestedScrollView android:tag="我是儿子">
        </android.support.v4.widget.NestedScrollView >
    </android.support.v4.widget.NestedScrollView >
</android.support.v4.widget.NestedScrollView >    
复制代码

举个例子:当儿子NestedScrollView调用stopNestedScroll()中止嵌套滑动时,就会回调爸爸NestedScrollView的onStopNestedScroll(),这时爸爸NestedScrollView也该中止嵌套滑动而且爷爷NestedScrollView也应该收到爸爸NestedScrollView的中止嵌套滑动,故在NestedScrollingParent2的onStopNestedScroll()应该这么写达到嵌套滑动事件往外分发的效果:

//NestedScrollingParent2
    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        mParentHelper.onStopNestedScroll(target, type);
        //往外分发
        stopNestedScroll(type);
    }
    //NestedScrollingChild2
    @Override
    public void stopNestedScroll(int type) {
        mChildHelper.stopNestedScroll(type);
    }
复制代码

常见交互效果

除了下面的饿了么商家详情页外其余的效果能够用 CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout 实现折叠悬停效果,其实它们底层Behavior也是基于NestedScrolling机制来实现的,而像饿了么这样的效果若是使用自定View的话要么用NestedScrolling机制来实现,要能基于传统的触摸事件分发实现。

  • 1.饿了么商家详情页(v8.27.6)

  • 2.美团商家详情页(v10.6.203)

  • 3.腾讯课堂首页(v4.7.1)

  • 4.腾讯课堂课程详情页(v4.7.1)

  • 5.支付宝首页(v10.1.82)

总结

本文偏向概念性内容,不免有些枯燥,但若遇到稍微有点挑战要解决的问题,没有现成的工具能够利用,只能靠本身思考和分析或者借鉴其余现成的工具的原理,就离不开这些看不起眼的“细节知识”;因为本人水平有限仅给各位提供参考,但愿可以抛砖引玉,若是有什么能够讨论的问题能够在评论区留言或联系本人,下篇将带你们实战基于NestedScrolling机制自定义View实现饿了么商家详情页效果。

参考

1.【透镜系列】看穿 > NestedScrolling 机制 >

相关文章
相关标签/搜索