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

预览

嵌套系列导航

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

概述

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

处理同向滑动事件冲突

image

如果两个可滑动的容器嵌套,外部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的交互流程。


NestedScrolling交互 流程示意图.png

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

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

  • 2.如果NestedScrollingParent不配合NestedScrollingChild处理滑动就没有接下来的流程,否则就会配合处理滑动。

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

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

  • 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.方法调用流程图:

image

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

NestedScrollingChild2、NestedScrollingParent2

在Revision 26.1.0的android.support.v4兼容包添加了NestedScrollingChild2、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,就出现了上图的场景。

NestedScrollingChild3、NestedScrollingParent3

在androidx.core 1.1.0-alpha01开始引入NestedScrollingChild3、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,说明滑动值没有完全消费掉,此时实现NestedScrollingParent3、NestedScrollingChild3对应的父View、子View在同一方向都滑动到了边缘尽头,此时自身用EdgeEffect消费剩余滑动值并调用abortAnimatedScroll()来 停止mScroller滚动并结束嵌套滑动

NestedScrolling机制的使用

如果你最低支持android版本是5.0及其以上,你可以使用View、ViewGroup本身对应的NestedScrollingChild、NestedScrollingParent接口;如果你使用AndroidX那么你就需要使用NestedScrollingChild3、NestedScrollingParent3;如果你兼容Android5.0之前版本请使用NestedScrollingChild2、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;
    }

同时使用NestedScrollingChild2、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机制来实现,要能基于传统的触摸事件分发实现。

image

  • 1.饿了么商家详情页(v8.27.6)
image
  • 2.美团商家详情页(v10.6.203)
image
  • 3.腾讯课堂首页(v4.7.1)
image
  • 4.腾讯课堂课程详情页(v4.7.1)
image
  • 5.支付宝首页(v10.1.82)

总结

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

参考

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342