一篇文章让你轻松弄懂NestedScrollingParent & NestedScrollingChild

虽然很早之前使用CoordinatorLayout时就认识过nestedScrollingChildnestedScrollingParent, 也看多很多博客,但每次看着就不知所云了,所以这篇文章,我们就以问题为线索,带着问题找答案。

1. 谁实现 NestedScrollingChild,谁实现NestedScrollingParent ?

在实际项目中,我们往往会遇到这样一种需求,当ViewA还显示的时候,往上滑动到viewA不可见时,才开始滑动viewB, 又或者向下滑动到viewB不能滑动时,才开始向上滑动viewC. 如果列表滑动、上拉加载和下拉刷新的view都封装成一个组件的话,那滑动逻辑就是刚刚这样。而这其中列表就要实现nestedScrollingChild, 最外层的Container实现nestedScrollingParent. 如果最外层的Container希望在其它布局中仍然能够将滑动事件继续往上冒泡,那么container在实现nestedScrollingParent的同时也要实现nestedScrollingChild。 如下示意图所示。

示意图.png

所以这个问题的答案:

触发滑动的组件或者接受到滑动事件且需要继续往上传递的是nestedScrollingChild.

nestedScrollingChild的父布局,且需要消费传递的滑动事件就是nestedScrollingParent.

我们今天的最后也会给出如何利用nestedScrollingChildnestedScrollingParent来自定义一个集上拉加载和下拉刷新的组件。

2. 滑动事件如何在二者之间传递和消费的?

2.1 你能一眼认出这是child还是parentapi吗?

首先呢,我们要看一下nestedScrollingChildnestedScrollingParent有哪些api.

public interface NestedScrollingChild {
   
    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);
}

public interface NestedScrollingParent {
   
    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 int getNestedScrollAxes();
}

这里呢,我删掉了注释,我不想翻译那些注释放在上面的代码中,这样你就会将注意力放在我的注释中,然后陷入了咬文嚼字,最后感叹为什么每个字我都认识,连在了一起我怎么就看不懂的自我否定中。 之所以把上面的代码放出来,是为了我后面简述时,你不用一边看文章,一边还要切过去看源码看这个api是属于child还是属于parent的。

其实这里给你一个分辨是childparentapi的一个小诀窍,因为child是产生滑动的造势者,所以它的api都是以直接的动词开头,而parent的滑动响应是child通知parent的,所以都是以监听on开头,这样就记住了。
parent ----> onXXXX()
child -----> verbXXXX()

嗯,废话了好像很多了,这里我们要回到问题上,滑动事件如何在childparent之间传递和消费掉的呢?

2.2 滑动事件的是如何传递的

那既然能传递,说明这个滑动事件一定产生了,如何产生滑动事件?当然是用户手指在屏幕上滑动了呀。为了不说的这么枯燥,我们拿最熟悉熟悉的小伙伴RecyclerView来作为nestedScrollingChild讲解。这里我引入的版本是:25.3.1
implementation 'com.android.support:recyclerview-v7:25.3.1'

2.2.1 滑动事件传递从哪里产生?
     switch (action) {
          case MotionEvent.ACTION_DOWN: 
                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontally) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertically) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                startNestedScroll(nestedScrollAxis);
            } 
          break;
    }

这里,我们可以发现,当我的小手按在RecyclerView上时,调用了nestedScrollingChildstartNestedScroll(nestedScrollAxis), 这里我们再多让我们的大脑接受一点信息,那就是这个方法的参数:nestedScrollAxis, 滑动的坐标轴。 RecylerView是不是既可以水平滑动,又可以纵向滑动,那这里就是传递的就是RecyclerView可以滑动的坐标轴。

发现了startNestedScroll(axis),看看走到了哪里。

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

这里,我们发现又出来一个类:NestedScrollingChildHelper. 里面好像又有一些滑动的api。读到这里,不要怕,心态要稳住,不要崩塌了。给自己吃颗定心丸,我能行。

我们先看一眼,这个childHelper的这个方法干了啥?

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

所有的绝妙之处就在这个方法中,这个方法我是原封不动拷贝下来,听我和你一句一句讲解。
第一句: 判断 mNestedScrollingParent是不是 null。 在NestedScrollingChildHelper这个类,全类只有两处给它赋值了,一个赋有值,就是上面代码中的while循环里面,一个是赋空值,在方法stopNestedScroll,这个方法什么时候调用啊,在你美丽的小手离开屏幕的时候。所以只要你的小手在屏幕上,这个startedNestedScroll 这个方法只会调用一次。也就是通知parent我美丽的小手指要滑动啦,通知过你,我就不通知了,哪个小仙女不是傲娇的。

第二句: 判断mIsNestedScrollingEnabled 是否要true. 这个变量也是至关重要的,它的作用是 要不要向上冒泡滑动事件,所以说哪天小仙女不开心了,直接调用了:setNestedScrollingEnabled(false), 父布局是怎么都不知道小手指有没有滑动的。

第三句+第四句:这里的p就是父布局了,这里的mView是在初始化这个类的时候,传递过来的,所以在RecyclerView中,可以找到这句话:mScrollingChildHelper = new NestedScrollingChildHelper(this);. 这里的mView就是RecyclerView 这位小仙女啦。

第五句:进入while循环了,为什么这里要while循环,因为它要确保使命必达,不管我的父布局有多深,我都要找到你,并通知到你。

第六句:if里的逻辑说明,如果parent监听到即将要在这个轴上有滑动事件,并且正是parent需要的事件,那么就会调用onNestedScrollAccept。 这里的ViewParentCompat.onStartNestedScroll(p, child, mView, axes) 会最终调用到实现nestedScrollingParent组件中的onStartNestedScroll方法,这个方法就是parent 判断收到该滑动通知时,是不是天时地利人和,如果是,我就返回true,后面一系列的小手指滑动都要告知我。如果返回false,说明parent此时在处理别的事情,后面小手指滑动的弧线再怎么优美,都不要来烦我。

第七句:onNestedScrollAccepted 说明parent正式接收了此child也就是recyclerView的滑动通知,最终会调用到parentonNestedScrollAccept方法中,如果此parent还实现了接口nestedScrollingChild, 可以在这个方法继续向parentparent上报了。

所以整个流程可以概括为:通知
ACTION_DOWN
--> child.startNestedScroll
--> childHelper.startNestedScroll
--> parent.onStartNestedScroll
--> parent.onNestedScrollAccept

2.2.2 小手指滑动的时候,childparent之间是如何通信的?
 case MotionEvent.ACTION_MOVE: {
         if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
             dx -= mScrollConsumed[0];
             dy -= mScrollConsumed[1];
             vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
         }

        if (mScrollState == SCROLL_STATE_DRAGGING) {
            mLastTouchX = x - mScrollOffset[0];
            mLastTouchY = y - mScrollOffset[1];

            if (scrollByInternal(canScrollHorizontally ? dx : 0,canScrollVertically ? dy : 0,
            vtev)) {
                 getParent().requestDisallowInterceptTouchEvent(true);
        }
    break;
}

这里呢,有两个重要的方法:dispatchNestedPreScrollscrollByInternal.

第一句: dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow),这里的参数中只有dx,dy两个参数在前面赋值了,而后面两个参数在哪里操作的呢?这里我们留个问号?首先这个方法会走到NestedScrollingChildHelper类中的方法:dispatchNestedPreScroll调用ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
 }

最终目的地来到了parentonNestedPreScroll()。所以我们可以大胆猜测,consumed, offsetInwindow, 是在parent这里赋值的,当然你可以不用赋值,不赋值的话,值也就是保留上一次的值。

  dx -= mScrollConsumed[0];
  dy -= mScrollConsumed[1];

dispatchNestedPreScroll()这个方法返回true后,发现重新计算了dx,dy, 在方法scrollByInternal()方法中,用的是最新的dx,dy值。说明当小手指产生滑动位移的时候,先分发给parent,让parent先消耗,并在方法中将parent消耗的位移传递过来,那么剩下的位移,ok,那充当childRecyclerView内部消费了。

 if (scrollByInternal(canScrollHorizontally ? dx : 0,canScrollVertically ? dy : 0,vtev)) {
      getParent().requestDisallowInterceptTouchEvent(true);
 }
boolean scrollByInternal(int x, int y, MotionEvent ev) {
        int unconsumedX = 0, unconsumedY = 0;
        int consumedX = 0, consumedY = 0;
        if (mAdapter != null) {
            if (y != 0) {
                consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
                unconsumedY = y - consumedY;
            }          
        }
        if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)) {
            // 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];
        } 
        return consumedX != 0 || consumedY != 0;
    }

第二句:scrollByInternal()就是内部滑动消耗了,在这个方法里面,我们发现继续往parent分发了事件:dispatchNestedScroll(consumeX, consumeY, unconsumeX, unconsumeY), 把自己未消耗的滑动位移继续移交给parent,这个时候最终会走到parent的方法:onNestedScroll()。 在这里,如果parent还实现了nestedScrollingChild,可以将未消耗的滑动位移继续移交给自己的parent.

@Override
  public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
    if(isNestedScrollingEnabled()) {
      dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, mParentOffsetInWindow);
    }
  }

所以我们可以总结如下:通信

ACTION_MOVE : 小手指滑动位移为:dy
--> childHelper.dispatchNestedPreScroll(dy)
--> parent.onNestedPreScroll(dy), consumedY = parent.onNestedPreScroll(dy)
--> dy' = dy - consumeY recyclerView.scrollByInternal(dy') unconsumeY = dy' - recyclerView.scrollByInternal(dy')
--> parent.startNestedScroll(unconsumeY)

2.2.3 小手指滑累了,离开屏幕时,又有哪些事件传递?
  case MotionEvent.ACTION_UP: {
        if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
                    setScrollState(SCROLL_STATE_IDLE);
         }
        resetTouch();
   } 
  break;
public boolean fling(int velocityX, int velocityY) {
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);
            if (canScroll) {
                mViewFlinger.fling(velocityX, velocityY);
                return true;
            }
        }
        return false;
    }  
 private void resetTouch() {
        stopNestedScroll();
    }

这里我们发现先是child执行fling方法,也就是当手松开时仍然有速度,那么会执行一段惯性滑动,而在这惯性滑动中, 这里就很奇妙了,先是通过dispatchNestedPreFling()将滑动速度传递给parent, 如果parent不消耗的话,再次通过dispatchNestedFlingparent传递,只是这次的传递会带上child自己是否有能力消费惯性滑动,最后不管parent有没有消费,child也就是recyclerview都会执行自己的fling.也就是:

mViewFlinger.fling(velocityX, velocityY);

走完了惯性滑动,就会走到stopNestedScroll(). 按照上面的逻辑处理,我们应该可以猜到接下来的逻辑就是走到NestedScrollingChildHelper这个类。然后目的地会到达parentonStopNestedScroll方法。这里,parent就可以处理当小手指离开屏幕时的一些逻辑了。这条路很简单,没有返回值,也没有传递什么变量。还是很好理解的。

    public void stopNestedScroll() {
        if (mNestedScrollingParent != null) {
            ViewParentCompat.onStopNestedScroll(mNestedScrollingParent, mView);
            mNestedScrollingParent = null;
        }
    }

这里呢,我们可以总结如下:收尾

ACTION_UP

--> childHelper.dispatchNestedPreFling
--> parent.onNestedPreFling
--> childHelper.dispatchNestedFling
--> parent.onNestedFling
--> child.fling
--> childHelper.stopNestedScroll
--> parent.onStopNestedScroll

这样,我们整个nestedScrollingChildnestedScrollingParent之间的丝丝缕缕都讲解完了。

3. 实践

这里我们利用nestedScrollingChildnestedScrollingParent实现的自定义上拉加载,下拉刷新的控件。

https://github.com/thh0613/nestedScrollDemo

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

推荐阅读更多精彩内容