Android 源码分析 - 嵌套滑动机制的实现原理

  好久没有写博客了,感觉自己的手变得生疏了,今天来记录一下自己对Android里面的嵌套滚动的理解。
  本文参考资料:
  1.NestedScrollingParent, NestedScrollingChild 详解
  2.针对 CoordinatorLayout 及 Behavior 的一次细节较真

1.什么是嵌套滑动?

  在这里,楼主先贴出一个Demo图片,来直观的展示一下,什么是嵌套滑动。


  我们发现,当我们向下滑动时,首先是外部的布局向下滑动,然后才是RecyclerView滑动,向上滑动也是如此。这就是嵌套滑动的效果。
  我们认真的想一想,如果使用传统的事件分发机制来实现这个功能,应该怎么实现?是使用传统的事件分发机制来实现,还是不是很难的。但是又没有更加优秀的方法来实现这种效果呢?当然有咯,不然今天说什么。这个答案就是嵌套滑动机制。
  可能有些老哥没有听过嵌套滑动机制,其实不是很难,楼主觉得比传统的事件分发机制简单的多。其中我们需要注意一点就是,传统的事件分发是从上向下分发,而嵌套滑动事件是从下到上,也就是说,当一个View会产生了一个嵌套滑动的事件,首先会报告给他的父View,询问他的父View是否处理这个事件,如果处理的话,那么子View就不处理(实际上存在父View只处理处理部分滑动距离的情况)。这里解释的比较简单,待会会详细的解释这些细节。
  嵌套滑动机制,主要的用到的接口和类有:NestedScrollingChild,NestedScrollingParent,NestedScrollingParentHelper,NestedScrollingChildHelper
  这里先对这4个类做一个统一的解释:

类名 解释
NestedScrollingChild 如果一个View想要能够产生嵌套滑动事件,这个View必须实现NestedScrollChild接口,从Android 5.0开始,View实现了这个接口,不需要我们手动实现
NestedScrollingParent 这个接口通常用来被ViewGroup来实现,表示能够接收从子View发送过来的嵌套滑动事件
NestedScrollingChildHelper 这个类通常在实现NestedScrollChild接口的View里面使用,他通常用来负责将子View产生的嵌套滑动事件报告给父View。也就是说,如果一个子View想要将产生的嵌套滑动事件交给父View,这个过程不需要我们来实现,而是交给NestedScrollingChildHelper来帮助我们处理
NestedScrollingParentHelper 这个类跟NestedScrollingChildHelper差不多,也是帮助来传递事件的,不过这个类通常用在实现NestedScrollingParent接口的View。如果一个父View不想处理一个事件,通过NestedScrollingParentHelper类帮助我们传递就行了

  本文不对嵌套滑动的基本使用进行展开,只对其基本原理进行解释。

2. 子View事件的产生和传递

  如果想要了解嵌套滑动机制的原理,必须得知道,一个嵌套事件是怎么产生的,是怎么传递到父View里面的。这些都必须知道NestedScrollingChild的工作原理。

(1).NestedScrollingChild的接口

  在了解NestedScrollingChild的工作原理,我们先来看看NestedScrollChild接口里面的方法,然后在结合RecyclerView的源码来分析时事件是怎么传递到父View里面的。

public interface NestedScrollingChild {
    /**
     * 设置当前View是否能够产生嵌套滑动的事件
     * @param enabled true表示能够产生嵌套滑动的事件,反之则不能
     */
    void setNestedScrollingEnabled(boolean enabled);

    /**
     * 判断当前View是否能够产生嵌套滑动的事件
     * @return
     */
    boolean isNestedScrollingEnabled();

    /**
     * 当嵌套事件开始产生时会调用这个方法,这个方法通常是在ACTION_DOWN里面被调用
     * @param axes axes表示方向,如果(nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 表示当前滑动方向是垂直方向
     *            ,水平方向也是如此
     * @return 返回true表示有父View能够处理传递传递上去的嵌套滑动事件,实际上这个这个方法里面调用NestedScrollingParent的onStartNestedScroll
     * 方法来判断是否有父View能够处理,这个在后面源码分析时,我们具体讲解
     */
    boolean startNestedScroll(@ViewCompat.ScrollAxis int axes);

    /**
     * 这个方法表示本次嵌套滑动的行为结束了,通常在ACTION_UP或者ACTION_CANCEL里面调用
     */
    void stopNestedScroll();

    /**
     * 判断是否能够处理嵌套滑动的父View
     * @return true表示有,反之则没有
     */
    boolean hasNestedScrollingParent();

    /**
     * 本方法在产生嵌套滑动的View已经滑动完成之后调用,该方法的作用是将剩余没有消耗的距离继续分发到父View里面去
     * @param dxConsumed 表示该View在x轴上消耗的距离
     * @param dyConsumed 表示该View在y轴上消耗的距离
     * @param dxUnconsumed 表示该View在x轴上未消耗的距离
     * @param dyUnconsumed 表示该View在y轴未消耗的距离
     * @param offsetInWindow 表示该该View在屏幕上滑动的距离,包括x轴上的距离和y轴上的距离
     * @return true表示父View消耗这部分的未消耗的距离,反之表示父View不消耗
     */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    /**
     * 这个方法在方法调用之前调用,也就是调用这个方法时,滑动距离产生了,但是该View还未滑动。
     * 这个方法的作用是将滑动的距离报给父View,看看父View是否优先消耗这个这部分距离
     * @param dx x轴上产生的距离
     * @param dy y轴上产生的距离
     * @param consumed index为0的值表示父View在x轴消耗的的距离,index为1的值表示父View在y轴上消耗的距离
     * @param offsetInWindow 该View在屏幕滑动的距离
     * @return true表示父View有消耗距离,false表示父View不消耗
     */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);

    /**
     * 如果父View不对fling事件做任何处理,那么子View会调用这个方法,这个方法的作用是报告父View,子View此时在fling
     * 然而具体是否在fling,还要consumed为true还是false,在这方法里面会调用NestedScrollingParent的onNestedFling
     * @param velocityX x轴上的速度
     * @param velocityY y轴的速度
     * @param consumed true表示子View对这个fling事件有所行动,false表示没有行动
     * @return
     */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    /**
     * 在子View对fling有所行动之前,会调用这个方法。这个方法的作用是,用来询问父View是否对fling事件有所行动
     * @param velocityX
     * @param velocityY
     * @return
     */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

  我相信,可能很多老哥看了每个方法的注释还是一头雾水。哎,能力所致!!现在我在对整个做一个小小的总结。
  整个事件传递过程中,首先能保证传统的事件能够到达该View,当一个事件序列开始时,首先会调用startNestedScroll方法来告诉父View,马上就要开始一个滑动事件了,请问爸爸需要处理,如果处理的话,会返回true,不处理返回fasle。跟传统的事件传递一样,如果不处理的话,那么该事件序列的其他事件都不会传递到父View里面。
  然后就是调用dispatchNestedPreScroll方法,这个方法调用时,子View还未真正滑动,所以这个方法的作用是子View告诉它的爸爸,此时滑动的距离已经产生,爸爸你看看能消耗多少,然后父View会根据情况消耗自己所需的距离,如果此时距离还未消耗完,剩下的距离子View来消耗,子View滑动完毕之后,会调用dispatchNestedScroll方法来告诉父View,爸爸,我已经滑动完毕,你看看你有什么要求没?这个过程里面可能有子View未消耗完的距离。
  其次就是fling事件产生,过程跟上面也是一样,也是先调用dispatchNestedPreFling方法来询问父View是否有所行动,然后调用dispatchNestedFling告诉父View,子View已经fling完毕。
  最后就是调用stopNestedScroll表示本次事件序列结束。
  整个过程中,我们会发现子View开始一个动作时,会询问父View是否有所表示,结束一个动作时,也会告诉父View,自己的动作结束了,父View是否有所指示。

(2).RcyclerView的嵌套滑动机机制

  简单的了解NestedScrollingView的工作流程,我们结合RecyclerView的源码分析一下事件传递的原理。由于本文只分析嵌套滑动的原理,所以RecyclerView其他的知识这个不讲解,实际上我也不懂!
  我感觉我们以前真的是小看了RecyclerView,没想到他在背后帮我们做了这么事情,以后有机会一定好好看看RecyclerView的代码。现在来看看RecyclerView在嵌套滑动的实现。
  先来看看RecyclerView对ACTION_DOWN事件的处理:

            case MotionEvent.ACTION_DOWN: {
                mScrollPointerId = e.getPointerId(0);
                mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);

                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontally) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertically) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            } break;

  在ACTION_DOWN里面,首先是对nestedScrollAxis变量进行处理话。在前面提及到过,nestedScrollAxis表示滑动的方向,如果nestedScrollAxis & ViewCompat. SCROLL_AXIS_VERTICAL != 0,表示在垂直方向有滑动。初始化nestedScrollAxis变量之后,就会调用startNestedScroll方法来告诉父View滑动事件已经开始,你是否需要有所行动。这里就可以体现嵌套滑动的事件是从下到上传递的。
  我们再来看看RecyclerView是怎么将一个事件传递到父View的。

    @Override
    public boolean startNestedScroll(int axes, int type) {
        return getScrollingChildHelper().startNestedScroll(axes, type);
    }

    private NestedScrollingChildHelper getScrollingChildHelper() {
        if (mScrollingChildHelper == null) {
            mScrollingChildHelper = new NestedScrollingChildHelper(this);
        }
        return mScrollingChildHelper;
    }

  到这里,我们知道了,事件是依靠NestedScrollingChildHelper类帮助我们传递的。我们再来看NestedScrollingChildHelper是怎么帮我们传递的

        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    setNestedScrollingParentForType(type, p);
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

  整个方法比较简单,首先经过hasNestedScrollingParent方法来判断是否有父View能够处理该事件序列,这个的处理表示意思是,父View必须实现NestedScrollingParent接口,其次在onStartNestedScroll方法里面返回true。我们发现如果当View的父View不能够处理,那就会递归上去找,直到找到一个为止。
  同时,我们发现,NestedScrollingChildHelper有依靠ViewParentCompat类来帮助我们传递事件,实际上ViewParentCompat里面也是帮我们调用父View的onStartNestedScroll方法,这里做的目的是为了兼容不同版本的系统。在前面已经说过,从Android 5.0开始,View实现了NestScrollingChild接口,而5.0以下,需要我们自己来实现了。这里不对ViewParentCompat怎么进行系统兼容的实现进行讨论,待会再来讨论。
  在这里,对startNestedScroll方法的工作流程做一个简单的梳理。首先RecyclerView的ACTION_DOWN事件来到,RecyclerView的会调用startNestedScroll方法,在startNestedScroll方法里面,把具体的执行代理给NestedScrollingChildHelper的startNestedScroll方法,在NestedScrollingChildHelper的startNestedScroll方法里面,会不断的往上找能够处理该事件的父View,找到的话会调用父View的onStartNestedScroll方法。
  在整个事件传递过程中,我们还需要注意的一点就是:isNestedScrollingEnabled()方法,只要保证isNestedScrollingEnabled方法返回为true才能保证事件能够顺利往上的传递。这个方法的返回值取决于我们是否设置了setNestedScrollingEnabled方法。
  当一个ACTION_DOWN结束之后,通常来说,接下来就是ACTION_MOVE,会涉及到View的滑动的情况。让我们来看看滑动事件是怎么传递过来的,实现先贴出代码:


            case MotionEvent.ACTION_MOVE: {
                 ······
                if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
                    dx -= mScrollConsumed[0];
                    dy -= mScrollConsumed[1];
                    vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
                    // Updated the nested offsets
                    mNestedOffsets[0] += mScrollOffset[0];
                    mNestedOffsets[1] += mScrollOffset[1];
                }
                 ······
            } break;

  这里减省了很多无关的代码,只看dispatchNestedPreScroll方法。需要注意的是,此时RecyclerView还未滑动,因为RecyclerView真正滑动操作是在scrollByInternal方法里面进行的,所以dispatchNestedPreScroll只是用来表示此时滑动距离已经产生,询问父View是否要消耗距离。其中mScrollConsumed变量里面存储的就是父View消耗的距离。
  我们来看看子View是怎么将产生的滑动距离传递到父View里面的,这个还是结合NestedScrollingChildHelper来看,因为子View的dispatchNestedPreScroll方法最终会调用到NestedScrollingChildHelper的dispatchNestedPreScroll方法里面来。

    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            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(parent, mView, dx, dy, consumed, type);

                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;
    }

  整个事件传递能够顺利进行的前提还是isNestedScrollingEnabled返回为true。整个方法的执行比较简单,在这里面会调用会调用父View的onNestedPreScroll方法来询问父View是否消耗距离,其中父View消耗的距离保存在consumed数组,然后根据父View消耗的距离来计算,此时子View还有多少能够消耗,具体计算就是差值计算,比较简单。最后这个方法的返回值true表示父View消耗了距离,包括全部消耗和部分消耗两种情况。
  整个dispatchNestedPreScroll方法过程还是比较简单的。我们再来看看当RecyclerView消耗了父View未消耗的那部分距离之后,会发生什么。
  当RecyclerView滑动完毕之后,会调用dispatchNestedScroll方法来通知父View,自己已经滑动完毕了。具体来看看代码:

    boolean scrollByInternal(int x, int y, MotionEvent ev) {
         ······
        if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
                TYPE_TOUCH)) {
            // Update the last touch co-ords, taking any scroll offset into account
            mLastTouchX -= mScrollOffset[0];
            mLastTouchY -= mScrollOffset[1];
            if (ev != null) {
                ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
            }
            mNestedOffsets[0] += mScrollOffset[0];
            mNestedOffsets[1] += mScrollOffset[1];
        }
         ······
    }

  整个过程还是简单,事件传递通过NestedScrollingChildHelper来进行的,这里就不在进行分析了。
  剩下的fling事件,stop事件,这些都与上面类似,这里就不在多说了。

(3). ViewParentCompat

  在分析事件是如何传递到父View的时候,我们发现ViewParentCompat在这个过程中扮演着重要的角色,前面只是说了使用ViewParentCompat是为了系统的兼容。让我们来看看ViewParentCompat是如何来保证系统的兼容性的。这里就拿ViewParentCompat的startNestedScroll方法来进行分析,其他方法也是如此。

    public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes, int type) {
        if (parent instanceof NestedScrollingParent2) {
            // First try the NestedScrollingParent2 API
            return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes, type);
        } else if (type == ViewCompat.TYPE_TOUCH) {
            // Else if the type is the default (touch), try the NestedScrollingParent API
            return IMPL.onStartNestedScroll(parent, child, target, nestedScrollAxes);
        }
        return false;
    }

  我们看到的首先判断当前View是否实现了NestedScrollingParent2接口,如果实现的话了,直接回调到NestedScrollingParent2的onStartNestedScroll方法。之前我们说过NestedScrollingParent接口,而这个NestedScrollingParent2是什么东西?我们来看看NestedScrollingParent2的声明:

public interface NestedScrollingParent2 extends NestedScrollingParent {
  ······
}

  我们发现NestedScrollingParent2接口继承了NestedScrollingParent接口,相比于NestedScrollingParent接口,NestedScrollingParent2重载了NestedScrollingParent接口的几个方法,其他的就没有什么区别了。
  我们还是来看看这部分的含义吧:

        else if (type == ViewCompat.TYPE_TOUCH) {
            // Else if the type is the default (touch), try the NestedScrollingParent API
            return IMPL.onStartNestedScroll(parent, child, target, nestedScrollAxes);
        }
    ······
    static final ViewParentCompatBaseImpl IMPL;
    static {
        if (Build.VERSION.SDK_INT >= 21) {
            IMPL = new ViewParentCompatApi21Impl();
        } else if (Build.VERSION.SDK_INT >= 19) {
            IMPL = new ViewParentCompatApi19Impl();
        } else {
            IMPL = new ViewParentCompatBaseImpl();
        }
    }

  其中ViewParentCompatApi21ImplViewParentCompatApi19Impl都继承于ViewParentCompatBaseImpl,所以我们来看看ViewParentCompatBaseImplonStartNestedScroll方法。

        public boolean onStartNestedScroll(ViewParent parent, View child, View target,
                int nestedScrollAxes) {
            if (parent instanceof NestedScrollingParent) {
                return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                        nestedScrollAxes);
            }
            return false;
        }

  是不是瞬间来了一句卧了个槽?这么简单?就判断了一下是否实现了NestedScrollingParent接口。从这里得知,如果想要一个父View能够接受到子View传递过来的事件,实现NestedScrollingParent接口是必要的!
  最后,我们发现其实ViewParentCompat根本不是很神秘,其实就是在里面创建不同的对象来支持不同版本的系统。

3. 父View事件的接收和消耗

  讲解了子View产生和传递事件之后,可能对这个嵌套滑动还是一脸懵逼。不要着急,当我们将整个机制梳理通,就柳暗花明了。
  在系统中,没有特定ViewGroup用来接收和消耗子View传递的事件。因此,只能自己动手了。

public class NestedScrollLinearLayout extends LinearLayout implements NestedScrollingParent {
  private static final int OFFSET = 200;
  private NestedScrollingParentHelper mNestedScrollingParentHelper;

  public NestedScrollLinearLayout(Context context) {
    super(context);
  }

  public NestedScrollLinearLayout(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
  }

  public NestedScrollLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
  }

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

  @Override
  public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
    //向下
    if (dy < 0) {
      if (getTranslationY() >= 0) {
        consumed[0] = 0;
        consumed[1] = (int) Math.max(getTranslationY() - OFFSET, dy);
        setTranslationY(getTranslationY() - consumed[1]);
      }
    } else {
      if (getTranslationY() <= OFFSET) {
        consumed[0] = 0;
        consumed[1] = (int) Math.min(dy, getTranslationY());
        setTranslationY(getTranslationY() - consumed[1]);
      }
    }
  }

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

  @Override
  public void onStopNestedScroll(View child) {
    getNestedScrollingParentHelper().onStopNestedScroll(child);
  }


  @Override
  public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
  }

  @Override
  public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
    return false;
  }

  @Override
  public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
    return false;
  }

  private NestedScrollingParentHelper getNestedScrollingParentHelper() {
    if (mNestedScrollingParentHelper == null) {
      mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
    }
    return mNestedScrollingParentHelper;
  }
}

  如上的代码就是实现了上面Demo图片中的效果。在整个实现过程中,我们发现,我们只对onStartNestedScroll方法和onNestedPreScroll方法做了我们自己的实现,其他的要么空着,要么就是通过NestedScrollingParentHelper来帮助我们来实现。整个过程比较清晰和明了。
  不过,这其中,我们需要注意的是,每个方法的含义和调用的时机。onStartNestedScroll方法对应子View的startNestedScroll方法,当子View调用startNestedScroll方法会回调父View的onStartNestedScroll方法。其他方法也是类似的,不过需要注意的是,通常子View的方法都是以dispatch开头的,父View的方法都是以on开头的。
  对于NestedScrollingParnet这一块,感觉没有需要注意的,因为这部分需要咱们自己实现,而实现这部分的功能,需要了解子View的是怎么将事件传递到父View。

5. 总结

  最后来对Android里面的嵌套滑动做一个简单的总结。
  1.跟传统的事件分发不同,嵌套滑动是由子View传递给父View,是从下到上的,传统事件的分发是从上到下的。
  2.如果一个View想要传递嵌套滑动的事件,有两个前提:实现NestedScrollingChild接口;setNestedScrollingEnabled方法设置为true。如果一个ViewGroup想要接收和消耗子View传递过来的事件,必须实现NestedScrollingParent接口。

推荐阅读更多精彩内容