×

Android NestedScrolling全面解析 - 带你实现一个支持嵌套滑动的下拉刷新(上篇)

96
諸星団
2017.04.08 23:49* 字数 3059

转载请注明出处 : http://www.jianshu.com/p/f09762df81a5 谢谢

自从Lollipop开始,谷歌霸霸给我们带来了一套全新的嵌套滑动机制 - NestedScrolling来实现一些普通情况下不容易办到的滑动效果。Lollipop及以上版本的所有View都已经支持了这套机制,Lollipop之前版本可以通过Support包进行向前兼容。
那么我们先提出来三个问题:

  1. 什么是NestedScrolling?
  2. 怎么运作的?
  3. 我们怎么去使用?

让我们带着问题一起来深入了解下这神奇的嵌套滑动。
首先,什么是NestedScrolling呢,它和我们已熟知的dispatchTouchEvent不太一样。
我们先来看传统的事件分发,它是由父View发起,一旦父View需要自己做滑动效果就要拦截掉事件并通过自己的onTouch进行消耗,这样子View就再没有机会接手此事件,如果自己不拦截交给子View消耗,那么不使用特殊手段的话父View也没法再处理此事件。
NestedScrolling不一样,它是由子View发起的,它的过程是这样的:

场景一:

  • 子View:爸爸,我准备在x轴方向滑动50px,有什么吩咐没
  • 父View:好的,没什么吩咐的,你滑吧。
  • 子View:遵命!滑动ing...... 爸爸,我滑完了,总共滑了50px。
  • 父View:好的,记得每次都要提前汇报!

场景二:

  • 子View:爸爸,我准备在x轴方向滑动50px,有什么吩咐没
  • 父View:你x轴的50px我要全部没收,你别动了
  • 子View:纳尼 w(゚Д゚)w 好吧谁让你是爸爸...
ni men dong le me

经过如上的场景分析,其实可以看出来NestedScrolling就是父View和子View之间的一套滑动交互机制。简单点来说就是要求子View在准备滑动之前将滑动的细节信息传递给父View,父View可以决定是否部分或者全部消耗掉这次滑动,并使用消耗掉的值在子View滑动之前做自己想做的事情,子View会在父View处理完后收到剩余的没有被父View消耗掉的值,然后再根据这个值进行滑动。滑动完成之后如果子View没有完全消耗掉这个剩余的值就再告知一下父View,我滑完了,但是还有剩余的值你还要不要?
仔细想想就能发现,这套机制很有意思,那么我们直接进入问题2:怎么办到的?我们看一下Lollipop及以上版本的View源码就可以看到它多了这么几个方法:

public void setNestedScrollingEnabled(boolean enabled);
public boolean isNestedScrollingEnabled();
public boolean startNestedScroll(int axes);
public void stopNestedScroll();
public boolean hasNestedScrollingParent();
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow);
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
public boolean dispatchNestedPreFling(float velocityX, float velocityY);

而ViewGroup中多了这些方法:

public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);
public void onStopNestedScroll(View target);
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);
public boolean onNestedPreFling(View target, float velocityX, float velocityY);
public int getNestedScrollAxes();

新东西还挺多的,看着有点晕

先不着急去理解这些方法,开篇时就在说Lollipop及以上版本的所有View都已经支持了NestedScrolling,Lollipop之前版本可以通过Support包进行向前兼容,那怎么向前兼容呢?
这就需要Support包里的以下4个类出场了

  • NestedScrollingParent
  • NestedScrollingParentHelper
  • NestedScrollingChild
  • NestedScrollingChildHelper

其中NestedScrollingParentNestedScrollingChild都是接口,分别对应ViewGroup和View中新增的方法,在Lollipop以下版本中,我们需要手动添加这两个接口的实现。
那怎么实现接口中辣么多的方法呢?这就需要上面的Helper类登场了,Helper类中已经写好了实现,只需要调用就可以了。

for example

我们随便找个方法

    @Override
    public boolean startNestedScroll(int axes) {
        return mNestedScrollingChildHelper.startNestedScroll(axes);
    }

是不是很简单,谷歌霸霸都替我们写好实现了,赞美霸霸。
那,那我们还需要做什么?


kong qi zhong mi man zhe

我们当然还有事情要做,一开始我们就说了这是一套机制,谷歌霸霸只是把基础的东西给我们铺垫好了,这些方法虽然实现好了但是空摆在这还是没什么效果的,具体还需要我们去在合适的时机调用,那什么时候是合适的时机呢?还记得一开始的场景分析么?我们来把分析的结论结合着这些方法走一遍。

  1. 首先子View需要找到一个支持NestedScrollingParent的父View,告知父View我准备开始和你一起处理滑动事件了,一般情况下都是在onTouchEvent的ACTION_DOWN中调用public boolean startNestedScroll(int axes),然后父View就会被回调public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes)public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes)。这里就产生了四个问题:
    1 int axes,int nestedScrollAxes这两个参数代表的是什么意思?
    2 为什么子View一调用startNestedScroll父View就会收到onStartNestedScroll和onStartNestedScroll回调?
    3 onStartNestedScroll和onNestedScrollAccepted有什么区别?
    4 这些方法的返回值代表什么意思?</br>
    问题1其实很简单,这个参数是一个常量,代表滑动的方向,比如ViewCompat.SCROLL_AXIS_VERTICAL就是代表纵向滑动。
    问题2更简单,因为这块是谷歌霸霸替我们写好了的╮(╯_╰)╭,不信我们打开NestedScrollingChildHelper的startNestedScroll方法看一下:
    public boolean startNestedScroll(int axes) {
        if (hasNestedScrollingParent()) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
                    mNestedScrollingParent = p;
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

代码不复杂,很容易看懂,同时也可以解答问题3和问题4,onStartNestedScroll可以理解是父View的一个验证机制,父View可以在此方法中根据滑动方向等信息决定是否要和子View一起处理此次滑动,只有在onStartNestedScroll返回true的时候才会接着调用onNestedScrollAccepted,这个判断是需要我们自己来处理的,所以NestedScrollingParentHelper并没有实现此方法。如果这个方法返回false,那么while循环就会继续寻找更上一级的父View让其接手,这里我们可以看出,NestedScrolling的交互不是直接的父子关系一样可以正常进行。至于onNestedScrollAccepted的作用就好说了,字面意思也可以理解出来父View接受了子View的邀请,可以在此方法中做一些初始化的操作。

  1. 然后每次子View在滑动前都需要将滑动细节传递给父View,一般情况下是在ACTION_MOVE中调用public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow),然后父View就会被回调public void onNestedPreScroll(View target, int dx, int dy, int[] consumed)。这里依然带着4个问题来分析:
    1 dx dy代表什么意思?
    2 int[] consumed代表什么意思?
    3 int[] offsetInWindow代表什么意思?
    4 返回值代表什么意思?</br>
    dx dy代表本次滑动 x y方向的距离,consumed这个数组就比较有意思了,需要子View创建并传递给父View,如果父View选择要消耗掉滑动的值就需要通过此数组传递给子View。比如以下伪代码表示父View要在x方向消耗10px,y方向消耗5px。
//子View中
int[] consumed = new int[2];
dispatchNestedPreScroll(50, -20, consumed, null);
//父View中@Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
      consumed[0] = 10;
      consumed[1] = -5;
      //....
    }

注意,如果是相反方向,需要写负值。
offsetInWindow这个数组也比较有意思,API文档中对其描述的是

     * Optional. If not null, on return this will contain the offset
     * in local view coordinates of this view from before this operation
     * to after it completes. View implementations may use this to adjust
     * expected input coordinate tracking.

大致的意思是:这是一个可选的参数,可以传null值,但如果不传null,它将含有View从此方法调用之前到调用完成后的屏幕坐标偏移量,可以使用这个偏移量来调整预期的输入坐标跟踪。</br>
是不是不好理解,至于什么叫预期的输入坐标跟踪我也不知道,反正我小学英语水平只能翻成这样┑( ̄Д  ̄)┍。其实不用过于纠结,我们知道这个参数保存着子View在这个方法调用前后的坐标偏移量就足够了。
那返回值是怎么回事呢,我们来看一看NestedScrollingChildHelper的实现就明白了:

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;
                }
                consumed[0] = 0;
                consumed[1] = 0;
                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的屏幕坐标记录下来,如果consumed传的是null,会初始化一个临时的mTempNestedScrollConsumed,然后去调用父View的onNestedPreScroll,完事之后再取一次View的屏幕坐标和之前记录的相减把偏移量赋值到offsetInWindow,最后再检查下父View有没有消耗dx或dy,如果任意一项有消耗就返回true,否则返回false。</br>
那么返回true和false又有什么意义呢,目的是让子View知道父View是否有消耗,因为子View有可能传一个null的consumed,这样就只能根据返回值来判断父View是否有消耗。
父View处理完后,接下来子View就要进自己的滑动操作了,滑动完成后子View还需要调用public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow)将自己的滑动结果再次传递给父View,父View对应的会被回调public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed),但这步操作有一个前提,就是父View没有将滑动值全部消耗掉,因为父View全部消耗掉,子View就不应该再进行滑动了,这一步也就没有必要了。这一步中几个参数dxConsumed dxUnconsumed dyConsumed dyUnconsumed从字面意思就可以看出是x y方向消耗的和没有消耗的值,因为子View进行自己的滑动操作时也是可以不全部消耗掉这些滑动值的,剩余的可以再次传递给父View,使父View在子View滑动结束后还可以根据子View剩余的值再次执行某些操作。
接下来就是随着不停的滑动重复阶段2这个过程。

  1. 随着ACTION_UP或者ACTION_CANCEL的到来,子View需要调用public void stopNestedScroll()来告知父View本次NestedScrollig结束,父View对应的会被回调public void onStopNestedScroll(View target),可以在此方法中做一些对应停止的逻辑操作比如资源释放等。但这一步还有一个意外情况,就是当子View ACTION_UP时可能伴随着fling的产生,如果产生了fling,就需要子View在stopNestedScroll前调用public boolean dispatchNestedPreFling(View target, float velocityX, float velocityY)public boolean dispatchNestedFling(View target, float velocityX, float velocityY, boolean consumed),父View对应的会被回调public boolean onNestedPreFling(View target, float velocityX, float velocityY)public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed),这点和之前的scroll处理逻辑是一样的,返回值代表父View是否消耗掉了fling,参数consumed代表子View是否消耗掉了fling,fling不存在部分消耗,一旦被消耗就是指全部。

以上就是一次完整的NestedScrolling交互,Lollipop及以上版本的View大致就是这样的处理逻辑,我制作了一张简单的流程图:

xi wang mei hua cuo....

最后,让我们回归到一开始就提出的第三个问题:我们怎么去使用?

NestedScrollingChild的话,Lollipop及以上版本可滑动的View如ScrollView、ListView已经按此交互流程为我们处理好了一切,如果你需要自定义View或者要兼容Lollipop以下版本就需要自己实现上述所有逻辑,当然更好的办法是使用support包里为我们提供的,比如NestedScrollView,RecyclerView等。
NestedScrollingParent就需要我们自己去实现了,毕竟父View要实现什么酷炫的效果还是需要我们去定义的,当然,support包中也有一系列为我们准备好的Parent,就是design包中的CoordinatorLayout,下一章节,我将讲述下怎么实现一个NestedScrollingParent的下拉刷新。

最后的最后,祝大家鸡年大吉吧!o(////////)q

Android 心得体会
Web note ad 1