自定义View事件之进阶篇(一)-NestedScrolling(嵌套滑动)机制

image.png

最近一段时间,一直都在忙于找工作。虽然花费了三个月的时间,但是结果并不是很美满。想去大厂、想去好公司、想遇见更厉害的人的愿望还是没有实现。或许是自己不够强大,或许自己不够努力,或许需要一定运气。生活总是需要经历一些波折。没有谁总是能一帆风顺。接下来一段时间内,会继续更新文章。希望大家能继续关注。Thanks~

前言

在Lollipop(Android 5.0)时,谷歌推出了NestedScrolling机制,也就是嵌套滑动。本文将带领大家一起去了解谷歌对该机制的设计。通过阅读该文,你能了解如下知识点:

  • 传统事件分发机制中嵌套滑动的实现与局限性。
  • 谷歌NestedScrolling机制的原理实现。
  • NestedScrollingChild与NestedScrollingParent接口的调用关系。
  • NestedScrollingChild2与NestedScrollingParent2接口出现的意义。

该博客中涉及到的示例,在NestedScrollingDemo项目中都有实现,大家可以按需自取。

传统事件机制处理嵌套滑动的局限性

在传统的事件分发机制中,当一个事件产生后,它的传递过程遵循如下顺序:父控件->子控件,事件总是先传递给父控件,当父控件不对事件拦截的时候,那么当前事件又会传递给它的子控件。一旦父控件需要拦截事件,那么子控件是没有机会接受该事件的。

因此当在传统事件分发机制中,如果有嵌套滑动场景,我们需要手动解决事件冲突。具体嵌套滑动例子如下图所示:

例子分析.gif

上述效果实现,请参看NestedTraditionLayout.java

想要实现上图效果,在传统滑动机制中,我们需要以下几个步骤:

  • 我们需要调用父控件中onInterceptTouchEvent方法来拦截向上滑动。
  • 当父控件拦截事件后,需要控制自身的onTouchEvent处理滑动事件,使其滑动至HeaderView隐藏。
  • 当HeaderView滑动至隐藏后,父控件就不拦截事件了,而是交给内部的子控件(RecyclerView或ListView)处理滑动事件。

使用传统的事件拦截机制来处理嵌套滑动,我们会发现一个问题,就是整个嵌套滑动是不连贯的。也就是当父控件滑动至HeaderView隐藏的时候,这个时候如果想要内部的(RecyclerView或ListView)处理滑动事件。只有抬起手指,重新向上滑动。

熟悉事件分发机制的朋友应该知道,之所以产生不连贯的原因,是因为父控件拦截了事件,所以同一事件序列的事件,仍然会传递给父控件,也就会调用其onTouchEvent方法。而不是调用子控件的onTouchEvent方法。

NestedScrolling机制简介

为了实现连贯的嵌套滑动,谷歌在Lollipop(Android 5.0)时,推出了NestedScrolling机制。该机制并没有脱离传统的事件分发机制,而是在原有的事件分发机制之上,为系统的自带的ViewGroup和View都增加了手势滑动与处理fling的方法。同时为了兼容低版本(5.0以下,View与ViewGroup是没有对应的API),谷歌也在support v4包中也提供了如下类与接口进行支撑:

父控件需要实现的接口与使用到的类:

  • NestedScrollingParent(接口)
  • NestedScrollingParent2(也是接口并继承NestedScrollingParent)
  • NestedScrollingParentHelper(类)

子控件需要实现的接口与使用到的类:

  • NestedScrollingChild(接口)
  • NestedScrollingChild2(也是接口并继承NestedScrollingChild)
  • NestedScrollingChildHelper(类)

需要注意的是,如果你的Android平台在5.0以上,那么你可以直接使用系统ViewGoup与View自带的方法。但是为了向下兼容,建议还是使用support v4包提供的相应接口来实现嵌套滑动。下文也会着重讲解这些接口的的使用方式与方法说明。

NestedScrollingParent与NestedScrollingChild接口介绍

在了解嵌套滑动具体的使用方式之前,我们需要了解父控件与子控件对应接口中方法的说明。这里大家可以先忽略掉NestedScrollingParent2与NestedScrollingChild2接口,因为这两个接口是为了解决之前对嵌套滑动处理fling效果的Bug。所以对于目前阶段的我们只需要了解基础的嵌套滑动规则就够了。关于NestedScrollingParent2与NestedScrollingChild2接口相关的知识点,会在下文具体描述。那现在我们就先看看基础的接口的方法介绍吧。

NestedScrollingParent

如果采用接口的方式实现嵌套滑动,我们需要父控件要实现NestedScrollingParent接口。接口具体方法如下:

   /**
     * 有嵌套滑动到来了,判断父控件是否接受嵌套滑动
     *
     * @param child            嵌套滑动对应的父类的子类(因为嵌套滑动对于的父控件不一定是一级就能找到的,可能挑了两级父控件的父控件,child的辈分>=target)
     * @param target           具体嵌套滑动的那个子类
     * @param nestedScrollAxes 支持嵌套滚动轴。水平方向,垂直方向,或者不指定
     * @return 父控件是否接受嵌套滑动, 只有接受了才会执行剩下的嵌套滑动方法
     */
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {}

    /**
     * 当onStartNestedScroll返回为true时,也就是父控件接受嵌套滑动时,该方法才会调用
     */
    public void onNestedScrollAccepted(View child, View target, int axes) {}

    /**
     * 在嵌套滑动的子控件未滑动之前,判断父控件是否优先与子控件处理(也就是父控件可以先消耗,然后给子控件消耗)
     *
     * @param target   具体嵌套滑动的那个子类
     * @param dx       水平方向嵌套滑动的子控件想要变化的距离 dx<0 向右滑动 dx>0 向左滑动
     * @param dy       垂直方向嵌套滑动的子控件想要变化的距离 dy<0 向下滑动 dy>0 向上滑动
     * @param consumed 这个参数要我们在实现这个函数的时候指定,回头告诉子控件当前父控件消耗的距离
     *                 consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离 好让子控件做出相应的调整
     */
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {}

    /**
     * 嵌套滑动的子控件在滑动之后,判断父控件是否继续处理(也就是父消耗一定距离后,子再消耗,最后判断父消耗不)
     *
     * @param target       具体嵌套滑动的那个子类
     * @param dxConsumed   水平方向嵌套滑动的子控件滑动的距离(消耗的距离)
     * @param dyConsumed   垂直方向嵌套滑动的子控件滑动的距离(消耗的距离)
     * @param dxUnconsumed 水平方向嵌套滑动的子控件未滑动的距离(未消耗的距离)
     * @param dyUnconsumed 垂直方向嵌套滑动的子控件未滑动的距离(未消耗的距离)
     */
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {}

    /**
     * 嵌套滑动结束
     */
    public void onStopNestedScroll(View child) {}

    /**
     * 当子控件产生fling滑动时,判断父控件是否处拦截fling,如果父控件处理了fling,那子控件就没有办法处理fling了。
     *
     * @param target    具体嵌套滑动的那个子类
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑动,反之向右滑动
     * @param velocityY 竖直方向上的速度 velocityY > 0  向上滑动,反之向下滑动
     * @return 父控件是否拦截该fling
     */
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {}


    /**
     * 当父控件不拦截该fling,那么子控件会将fling传入父控件
     *
     * @param target    具体嵌套滑动的那个子类
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑动,反之向右滑动
     * @param velocityY 竖直方向上的速度 velocityY > 0  向上滑动,反之向下滑动
     * @param consumed  子控件是否可以消耗该fling,也可以说是子控件是否消耗掉了该fling
     * @return 父控件是否消耗了该fling
     */
    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {}

    /**
     * 返回当前父控件嵌套滑动的方向,分为水平方向与,垂直方法,或者不变
     */
    public int getNestedScrollAxes() {}

NestedScrollingChild接口介绍

如果采用接口的方式实现嵌套滑动,子控件需要实现NestedScrollingChild接口。接口具体方法如下:

   /**
     * 开启一个嵌套滑动
     *
     * @param axes 支持的嵌套滑动方法,分为水平方向,竖直方向,或不指定
     * @return 如果返回true, 表示当前子控件已经找了一起嵌套滑动的view
     */
    public boolean startNestedScroll(int axes) {}

    /**
     * 在子控件滑动前,将事件分发给父控件,由父控件判断消耗多少
     *
     * @param dx             水平方向嵌套滑动的子控件想要变化的距离 dx<0 向右滑动 dx>0 向左滑动
     * @param dy             垂直方向嵌套滑动的子控件想要变化的距离 dy<0 向下滑动 dy>0 向上滑动
     * @param consumed       子控件传给父控件数组,用于存储父控件水平与竖直方向上消耗的距离,consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离
     * @param offsetInWindow 子控件在当前window的偏移量
     * @return 如果返回true, 表示父控件已经消耗了
     */
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow) {}


    /**
     * 当父控件消耗事件后,子控件处理后,又继续将事件分发给父控件,由父控件判断是否消耗剩下的距离。
     *
     * @param dxConsumed     水平方向嵌套滑动的子控件滑动的距离(消耗的距离)
     * @param dyConsumed     垂直方向嵌套滑动的子控件滑动的距离(消耗的距离)
     * @param dxUnconsumed   水平方向嵌套滑动的子控件未滑动的距离(未消耗的距离)
     * @param dyUnconsumed   垂直方向嵌套滑动的子控件未滑动的距离(未消耗的距离)
     * @param offsetInWindow 子控件在当前window的偏移量
     * @return 如果返回true, 表示父控件又继续消耗了
     */
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {}

    /**
     * 子控件停止嵌套滑动
     */
    public void stopNestedScroll() {}


    /**
     * 当子控件产生fling滑动时,判断父控件是否处拦截fling,如果父控件处理了fling,那子控件就没有办法处理fling了。
     *
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑动,反之向右滑动
     * @param velocityY 竖直方向上的速度 velocityY > 0  向上滑动,反之向下滑动
     * @return 如果返回true, 表示父控件拦截了fling
     */
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {}

    /**
     * 当父控件不拦截子控件的fling,那么子控件会调用该方法将fling,传给父控件进行处理
     *
     * @param velocityX 水平方向上的速度 velocityX > 0  向左滑动,反之向右滑动
     * @param velocityY 竖直方向上的速度 velocityY > 0  向上滑动,反之向下滑动
     * @param consumed  子控件是否可以消耗该fling,也可以说是子控件是否消耗掉了该fling
     * @return 父控件是否消耗了该fling
     */
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {}

    /**
     * 设置当前子控件是否支持嵌套滑动,如果不支持,那么父控件是不能够响应嵌套滑动的
     *
     * @param enabled true 支持
     */
    public void setNestedScrollingEnabled(boolean enabled) {}

    /**
     * 当前子控件是否支持嵌套滑动
     */
    public boolean isNestedScrollingEnabled() {}

    /**
     * 判断当前子控件是否拥有嵌套滑动的父控件
     */
    public boolean hasNestedScrollingParent() {}

谷歌嵌套滑动的方法调用设计

通过上文,我相信大家大概基本了解了NestedScrollingParent与NestedScrollingChild两个接口方法的作用,但是我们并不知道这些方法之间对应的关系与调用的时机。那么现在我们一起来分析谷歌对整个嵌套滑动过程的实现与设计。为了处理嵌套滑动,谷歌将整个过程分为了以下几个步骤:

  • 1.当父控件不拦截事件,子控件收到滑动事件后,会先询问父控件是否支持嵌套滑动。
  • 2.如果父控件支持嵌套滑动,那么父控件进行预先滑动。然后将处理剩余的距离交由给子控件处理。
  • 3.子控件收到父控件剩余的滑动距离并滑动结束后,如果滑动距离还有剩余,又会再问一下父控件是否需要再继续消耗剩下的距离。
  • 4.如果子控件产生了fling,会先询问父控件是否预先拦截fling。如果父控件预先拦截。则交由给父控件处理。子控件则不处理fling
  • 5.如果父控件不预先拦截fling, 那么会将fling传给父控件处理。同时子控件也会处理fling。
  • 6.当整个嵌套滑动结束时,子控件通知父控件嵌套滑动结束。

对fling效果不熟悉的小伙伴可以查看该篇文章---RecyclerView之Scroll和Fling

再结合之前我们对NestedScrollingParent与NestedScrollingChild中的方法。我们可以得到相应方法之间的调用关系。具体如下图所示:

方法对应关系.png

子控件方法调用时机

当我们了解了接口的调用关系后,我们需要知道子控件对相应嵌套滑动方法的调用时机。因为在低版本下,子控件向父控件传递事件需要配合NestedScrollingChildHelper类与NestedScrollingChild接口一起使用。由于篇幅的限制。这里就不向大家介绍如何构造一个支持嵌套滑动的子控件了。在接下来的知识点中都会在NestedScrollingChildView
的基础上进行讲解。希望大家可以结合代码与博客一起理解。

在接下来的章节中,会先讲解谷歌在NestedScrollingParent与NestedScrollingChild接口下嵌套滑动的API设计。关于NestedScrollingParent2与NestedScrollingChild2接口会单独进行解释。

子控件startNestedScroll方法调用时机

根据嵌套滑动的机制设定,子控件如果想要将事件传递给父控件,那么父控件是不能拦截事件的。当子控件想要将事件交给父控件进行预处理,那么必然会在其onTouchEvent方法,将事件传递给父控件。需要注意的是当子控件调用startNestedScroll方法时,只是判断是否有支持嵌套滑动的父控件,并通知父控件嵌套滑动开始。这个时候并没有真正的传递相应的事件。故该方法只能在子控件的onTouchEvent方法中事件为MotionEvent.ACTION_DOWN时调用。伪代码如下所示:

public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                mLastX = x;
                mLastY = y;
                //查找嵌套滑动的父控件,并通知父控件嵌套滑动开始。这里默认是设置的竖直方向
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
                break;
            }
        }
        return super.onTouchEvent(event);
    }

那子view仅仅通过startNestedScroll方法是如何找到父控件并通知父控件嵌套滑动开始的呢?我们来看看startNestedScroll方法的具体实现,startNestedScroll方法内部会调用NestedScrollingChildHelper的startNestedScroll方法。具体代码如下所示:

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        if (isNestedScrollingEnabled()) {//判断子控件是否支持嵌套滑动
            //获取当前的view的父控件
            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;
    }

从代码中我们可以看出,当子控件支持嵌套滑动时,子控件会获取当前父控件,并调用ViewParentCompat.onStartNestedScroll方法。我们继续查看该方法:

public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes, int type) {
        if (parent instanceof NestedScrollingParent2) {//判断父控件是否实现NestedScrollingParent2
            // First try the NestedScrollingParent2 API
            return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes, type);
        } else if (type == ViewCompat.TYPE_TOUCH) {//如果父控件实现NestedScrollingParent
            // Else if the type is the default (touch), try the NestedScrollingParent API
            return IMPL.onStartNestedScroll(parent, child, target, nestedScrollAxes);
        }
        return false;
    }

观察代码,我们可以发现,当父控件实现NestedScrollingParent接口后,会走IMPL.onStartNestedScroll方法,我们继续跟下去:

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

最后会调用ViewParetCompat中的onStartNestedScroll方法,该方法最终会调用父控件的onStartNestedScroll方法。绕了一大圈,也就调用了父控件的onStartNestedScroll来判断是否支持嵌套滑动。

那现在我们再回到子控件的startNestedScroll方法中。我们可以得知,如果当前父控件不支持嵌套滑动,那么会一直向上寻找,直到找到为止。如果仍然没有找到,那么接下来的子父控件的嵌套滑动方法都不会调用。如果子控件找到了支持嵌套滑动的父控件,那么接下来会调用父控件的onNestedScrollAccepted方法,表示父控件接受嵌套滑动。

子控件dispatchNestedPreScroll方法调用时机

当父控件接受嵌套滑动后,那么子控件需要将手势滑动传递给父控件,因为这里已经产生了滑动,故会在onTouchEvent中筛选MotionEvent.ACTION_MOVE中的事件,然后调用dispatchNestedPreScroll方法这些将滑动事件传递给父控件。伪代码如下所示:

private final int[] mScrollConsumed = new int[2];//记录父控件消耗的距离

    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                int dy = mLastY - y;
                int dx = mLastX - x;
                //将事件传递给父控件,并记录父控件消耗的距离。
                if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
                    dx -= mScrollConsumed[0];
                    dy -= mScrollConsumed[1];
                }
            }
        }

        return super.onTouchEvent(event);
    }

在上述代码中,dy与dx分别为子控件竖直与水平方向上的距离,int[] mScrollConsumed竖直用于记录父控件消耗的距离。那么当我们调用dispatchNestedPreScroll的方法,将事件传递给父控件进行消耗时,那么子控件实际能处理的距离为:

  • 水平方向: dx -= mScrollConsumed[0];
  • 竖直方向: dy -= mScrollConsumed[1];

接下来,我们继续查看dispatchNestedPreScroll的方法。

在dispatchNestedPreScroll方法内部会调用NestedScrollingChildHelper的dispatchNestedPreScroll方法具体代码如下:

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
            //获取当前嵌套滑动的父控件,如果为null,直接返回
            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;
                //调用父控件的onNestedPreScroll处理事件
                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;
    }

在该方法中,会先判断获取当前嵌套滑动的父控件。如果父控件不为null且支持嵌套滑动,那么接下来会调用ViewParentCompat.onNestedPreScroll()方法。代码如下所示:

 public void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
                int[] consumed) {
            if (parent instanceof NestedScrollingParent) {
                ((NestedScrollingParent) parent).onNestedPreScroll(target, dx, dy, consumed);
            }
        }

观察代码最终会调用父控件的onNestedPreScroll方法。需要注意的是,父控件可能会将子控件传递的滑动事件全部消耗。那么子控件就没有继续可处理的事件了。

onNestedPreScroll方法在嵌套滑动时判断父控件的滑动距离时尤为重要。

子控件dispatchNestedScroll方法调用时机

当父控件预先处理滑动事件后,也就是调用onNestedPreScroll方法并把消耗的距离传递给子控件后,子控件会获取剩下的事件并消耗。如果子控件仍然没有消耗完,那么会调用dispatchNestedScroll将剩下的事件传递给父控件。如果父控件不处理。那么又会传递给子控件进行处理。伪代码如下所示:

private final int[] mScrollConsumed = new int[2];//记录父控件消耗的距离

    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                int dy = mLastY - y;
                int dx = mLastX - x;
                //将事件传递给父控件,并记录父控件消耗的距离。
                if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
                    dx -= mScrollConsumed[0];
                    dy -= mScrollConsumed[1];
                    scrollNested(dx,dy);//处理嵌套滑动
                }
            }
        }

        return super.onTouchEvent(event);
    }
    //处理嵌套滑动
    private void scrollNested(int x, int y) {
        int unConsumedX = 0, unConsumedY = 0;
        int consumedX = 0, consumedY = 0;

        //子控件消耗多少事件,由自己决定
        if (x != 0) {
            consumedX = childConsumeX(x);
            unConsumedX = x - consumedX;
        }
        if (y != 0) {
            consumedY = childConsumeY(y);
            unConsumedY = y - consumedY;
        }

        //子控件处理事件
        childScroll(consumedX, consumedY);

        //子控件处理后,又将剩下的事件传递给父控件
        if (dispatchNestedScroll(consumedX, consumedY, unConsumedX, unConsumedY, mScrollOffset)) {
            //传给父控件处理后,剩下的逻辑自己实现
        }
        //传递给父控件,父控件不处理,那么子控件就继续处理。
        childScroll(unConsumedX, unConsumedY);

    }
    /**
     * 子控件滑动逻辑
     */
    private void childScroll(int x, int y) {
        //子控件怎么滑动,自己实现
    }
    /**
     * 子控件水平方向消耗多少距离
     */
    private int childConsumeX(int x) {
        //具体逻辑由自己实现
        return 0;
    }
    /**
     * 子控件竖直方向消耗距离
     */
    private int childConsumeY(int y) {
        //具体逻辑由自己实现
        return 0;
    }

在上述代码中,因为子控件消耗多少距离,是由子控件进行决定的,所以将这些方法抽象了出来了。在子控件的dispatchNestedScroll方法内部会调用NestedScrollingChildHelper的dispatchNestedScroll方法,具体代码如下所示:

public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
                //调用父控件的onNestedScroll方法。
                ViewParentCompat.onNestedScroll(parent, mView, dxConsumed,
                        dyConsumed, dxUnconsumed, dyUnconsumed, type);

                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }

该方法内部会调用ViewParentCompat.onNestedScroll方法。继续跟踪最终会调用ViewParentCompat中非静态的的onNestedScroll方法,代码如下所示:

public void onNestedScroll(ViewParent parent, View target, int dxConsumed, int dyConsumed,
                int dxUnconsumed, int dyUnconsumed) {
            if (parent instanceof NestedScrollingParent) {
                ((NestedScrollingParent) parent).onNestedScroll(target, dxConsumed, dyConsumed,
                        dxUnconsumed, dyUnconsumed);
            }
        }

该方法中,最终会调用父控件的onNestedScroll方法来处理子控件剩余的距离。

子控件stopNestedScroll方法调用时机

当整个事件序列结束的时候(当手指抬起或取消滑动的时候),需要通知父控件嵌套滑动已经结束。故我们需要在OnTouchEvent中筛选MotionEvent.ACTION_UP、MotionEvent.ACTION_CANCEL中的事件,并通过stopNestedScroll()方法通知父控件。伪代码如下所示:

public boolean onTouchEvent(MotionEvent event) {

        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_UP: {   //当手指抬起的时,结束事件传递
                stopNestedScroll();
                break;
            }
            case MotionEvent.ACTION_CANCEL: {   //当手指抬起的时,结束事件传递
                stopNestedScroll();
                break;
            }
        }
        return super.onTouchEvent(event);
    }

在stopNestedScroll()方法中,最终会调用父控件的onStopNestedScroll()方法,这里就不做更多的分析了。

子控件fling分发时机

现在就剩下最后一个嵌套滑动的方法了!!!对!就是fling。在了解子控件对fling的处理过程之前,我们先要知道fling代表什么样的效果。在Android系统下,手指在屏幕上滑动然后松手,控件中的内容会顺着惯性继续往手指滑动的方向继续滚动直到停止,这个过程叫做fling。也就是我们需要在onTouchEvent方法中筛选MotionEvent.ACTION_UP的事件并获取需要的滑动速度。伪代码如下:

fling的中文意思为抛、扔、掷。

public boolean onTouchEvent(MotionEvent event) {
         //添加速度检测器,用于处理fling
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);

        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_UP: {
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                int xvel = (int) mVelocityTracker.getXVelocity();
                int yvel = (int) mVelocityTracker.getYVelocity();  
             if (!dispatchNestedPreFling(velocityX, velocityY)) {
                boolean consumed = canScroll();
                //将fling效果传递给父控件
                dispatchNestedFling(velocityX, velocityY, consumed);
                 //然后子控件再处理fling
                childFling();//子控件自己实现怎么处理fling
                stopNestedScroll();//子控件通知父控件滚动结束
              }
              stopNestedScroll();//通知父控件结束滑动
                break;
            }
        }
        return super.onTouchEvent(event);
    }

这里就不在对fling效果是怎么分发到父控件进行解释啦~~。一定要结合NestedScrollingChildView类进行理解。那么假设大家都看了源码,那么我们可以得到如下几点:

  • 子控件dispatchNestedPreFling最终会调用父控件的onNestedPreFling方法。
  • 子控件的dispatchNestedFling最终会调用onNestedFling方法。
  • 如果父控件的拦截fling(也就是onNestedPreFling方法返回为true)。那么子控件是没有机会处理fling的。
  • 如果父控件拦截fling(也就是onNestedPreFling方法返回为false),则父控件会调用onNestedFling方法与子控件同时处理fling。
  • 当父控件与子控件同时处理fling时,子控件会立即调用stopNestedScroll方法通知父控件嵌套滑动结束。

NestedScrollingChild2与NestedScrollingParent2简介

最后一个知识点了,大家加油啊!!!!!!

在本文章前半部,我们都是围绕NestedScrollingChild与NestedScrollingParent进行讲解。并没有提及NestedScrollingChild2与NestedScrollingParent2接口。那这两个接口是处理什么的呢?这又要回到上文我们提到的NestedScrollingChild处理fling时的流程了,在谷歌之前的NestedScrollingParent与NestedScrollingChild的API设计中。并没有考虑如下问题:

  • 父控件根本不可能知道子控件是否fling结束。子控件只是在ACTION_UP中调用了stopNestedScroll方法。虽然通知了父控件结束嵌套滑动,但是子控件仍然可能处于fling中。
  • 子控件没有办法将部分fling传递给父控件。父控件必须处理整个fling。

而使用NestedScrollingChild2与NestedScrollingParent2这两个接口,子控件就能将fling传递给父控件,并且父控件处理了部分fling后,又可以将剩余的fling再传递给子控件。当子控件停止fling时,通知父控件fling结束了。这和我们之前分析的嵌套滑动是不是很像呢?直接讲知识点,大家不是很好理解,看下面这个例子:

NestedScrollingParent.gif

上述效果实现,请参看NestedScrollingParentLayout.java

在上面例子中是实现了NestedScrollingChild(NestedScrollView或RecyclerView等)与NestedScrollingParent接口的嵌套滑动,我们可以明显的看出,当我们手指快速向下滑动并抬起的时,子控件将fling分发给父控件,因为处理的距离不同,这个时候父控件已经处理滑动并fling结束,而内部的子控件(RecyclerView或NestedScrollView还在滚动,这种给我们的感觉就非常不连贯,好像每个控件在独自滑动。

在同样的滑动条件下,实现了NestedScrollingChild2(NestedScrollView或RecyclerView等)与NestedScrollingParent2接口的嵌套滑动.看下面的例子:

NestedScrollingParent2.gif

上述效果实现,请参看NestedScrollingParent2Layout.java

观察上图,我们能发现父控件与子控件(RecyclerView或NestedScrollView)的滑动更为顺畅与合理。那接下来我们看看谷歌对其的设计。

NestedScrollingChild2与NestedScrollingParent2分别继承了NestedScrollingChild与NestedScrollingParent,在继承的接口部分方法上增加了type参数。其中type的取值为TYPE_TOUCH(0)TYPE_NON_TOUCH(1)。用于区分手势滑动与fling。具体差异如下图所示:

接口差异.png

图片较大,可能阅读不清晰,建议放大观看。

谷歌在fling的处理上也与之前的NestedScrollingChild与NestedScrollingParent有所差异,在onTouchEvent方法中的逻辑进行了修改,伪代码如下所示:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();

        int y = (int) event.getY();
        int x = (int) event.getX();

        //添加速度检测器,用于处理fling效果
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);

        switch (action) {
            case MotionEvent.ACTION_UP: {//当手指抬起的时,结束嵌套滑动传递,并判断是否产生了fling效果
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                int xvel = (int) mVelocityTracker.getXVelocity();
                int yvel = (int) mVelocityTracker.getYVelocity();
                fling(xvel, yvel);//具体处理fling的方法
                mVelocityTracker.clear();
                stopNestedScroll(ViewCompat.TYPE_TOUCH));//注意这里stop的是带了参数的
                break;
            }

        }
        return super.onTouchEvent(event);
    }

当子控件手指抬起的时候,我们发现是调用stopNestedScroll(ViewCompat.TYPE_TOUCH)的方式来通知父控件当前手势滑动已经结束,继续查看fling方法。伪代码如下所示:

private boolean fling(int velocityX, int velocityY) {
        //判断速度是否足够大。如果够大才执行fling
        if (Math.abs(velocityX) < mMinFlingVelocity) {
            velocityX = 0;
        }
        if (Math.abs(velocityY) < mMinFlingVelocity) {
            velocityY = 0;
        }
        if (velocityX == 0 && velocityY == 0) {
            return false;
        }
        if (dispatchNestedPreFling(velocityX, velocityY)) {
            boolean canScroll = canScroll();
            //将fling效果传递给父控件
            dispatchNestedFling(velocityX, velocityY, canScroll);

            //子控件在处理fling效果
            if (canScroll) {
                //通知父控件开始fling事件,
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
                doFling(velocityX, velocityY);
                return true;
            }

        }
        return false;

    }

从代码中,我们可以看见,在新接口的处理逻辑中,还是会调用dispatchNestedPreFling与dispatchNestedFling方法。也就是之前的处理fling方式是没有被替代的,但是这并不说明没有变化。我们发现子控件调用了startNestedScroll方法,并设置了当前类型为TYPE_NON_TOUCH(fling),那么也就是说,在实现了NestedScrollingParent2的父控件中,我们可以在onStartNestedScroll方法中知道当前的滑动类型到底是fling,还是手势滑动。我们继续查看doFling方法。伪代码如下:

    /**
     * 实际的fling处理效果
     */
    private void doFling(int velocityX, int velocityY) {
        mScroller.fling(0, 0, velocityX, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        postInvalidate();
    }

doFling方法其实很简单,就是调用OverScroller的fing方法,并调用postInvalidate方法(为了帮助大家理解,这里并没有采用 postOnAnimation()的方式)。其中OverScroller的fing方法主要是根据当前传入的速度,计算出在匀减速情况下,实际运动的距离。这里也就解释了为什么,在只有速度的情况下,子控件可以将fling传递给父控件,因为速度最后变成了实际的运动距离。

这里就不对Scroller的fling方法中如何将速度转换成距离的算法进行讲解了。不熟悉的小伙伴可以自行谷歌或百度。

熟悉Scroller的小伙伴一定知道,为了获取到fling所产生的距离,我们需要调用postInvalidate()方法或Invalidate()方法。同时在子控件的computeScroll()方法中获取实际的运动距离。那么也就说最终的子控件的fing的分发实际是在computeScroll()方法中。继续查看该方法的伪代码:

 public void computeScroll() {
       if (mScroller.computeScrollOffset()) {
           int x = mScroller.getCurrX();
           int y = mScroller.getCurrY();
           int dx = x - mLastFlingX;
           int dy = y - mLastFlingY;

           mLastFlingX = x;
           mLastFlingY = y;
           //在子控件处理fling之前,先判断父控件是否消耗
           if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, null, TYPE_NON_TOUCH)) {
               //计算父控件消耗后,剩下的距离
               dx -= mScrollConsumed[0];
               dy -= mScrollConsumed[1];

               //因为之前默认向父控件传递的竖直方向,所以这里子控件也消耗剩下的竖直方向
               int hResult = 0;
               int vResult = 0;
               int leaveDx = 0;//子控件水平fling 消耗的距离
               int leaveDy = 0;//父控件竖直fling 消耗的距离

               if (dx != 0) {
                   leaveDx = childFlingX(dx);
                   hResult = dx - leaveDx;//得到子控件消耗后剩下的水平距离
               }
               if (dy != 0) {
                   leaveDy = childFlingY(dy);//得到子控件消耗后剩下的竖直距离
                   vResult = dy - leaveDy;
               }

               dispatchNestedScroll(leaveDx, leaveDy, hResult, vResult, null, TYPE_NON_TOUCH);

           }
       } else {
           //当fling 结束时,通知父控件
           stopNestedScroll(TYPE_NON_TOUCH);

       }
   }

观察代码,我们可以发现,子控件中分发fling的方式在与之前分发手势滚动的逻辑非常一致。

  • 产生fing时,调用带type(TYPE_NON_TOUCH)参数的dispatchNestedPreScroll方法,判断父控件是否处理fling事件。
  • 如果父控件处理,那么父控件消耗后,子控件再消耗剩余的距离
  • 子控件消耗后,如果还有剩余的距离,则调用带type(TYPE_NON_TOUCH)参数的dispatchNestedScroll方法,将剩下的距离传递给父控件。
  • 当子控件fling结束时,则调用stopNestedScroll(TYPE_NON_TOUCH)方法,通知父控件fling已经结束。

那么也就是说,NestedScrollingChild2与NestedScrollingParent2接口,只是在原有的方法中增加了TYPE_NON_TOUCH参数来让父控件区分到底是手势滑动还是fling。不得不佩服谷歌大佬的设计。不仅兼容还解决了实际的问题。

总结

通过上文的分析,我们能得到如下结论:

  • NestedScrolling(嵌套滑动)机制是建立在原有的事件机制之上,要实现嵌套滑动,父控件是不能拦截事件。
  • NestedScrolling(嵌套滑动)机制中接口要成对使用。如NestedScrollingChild2与NestedScrollingParent2成对。NestedScrollingChild与NestedScrollingParent成对。
  • 当我们需要子控件分发fling给父控件时,我们需要使用NestedScrollingChild2与NestedScrollingParent2。并在相应的方法中通过type(TYPE_TOUCH(0)TYPE_NON_TOUCH(1)),来判断是手势滑动还是fling。

最后

到现在整个NestedScrolling(嵌套滑动)机制就讲解完毕了,在接下来的文章中,会讲解相应嵌套滑动例子、CoordinatorLayout与Behavior、自定义Behavior等相关知识点,如果大家有兴趣的话,可以持续关注~。谢谢大家花时间阅读文章啦。Thanks

你可能感兴趣的:(自定义View事件之进阶篇(一)-NestedScrolling(嵌套滑动)机制)