Loading...
墨滴

掀乱书页的风

2021/10/16  阅读:22  主题:兰青

NestScroll滑动机制

NestedScrolling机制翻译过来就是嵌套滚动机制,现在app中已经广泛应用了,比如我们58app的首页

当向上滚动列表时,列表的头部会跟着一起向上滑动,当顶部收缩到只剩搜索框时,则搜索框保持固定,而列表继续滚动;当向下滚动列表时,则是相反的过程。

这种效果其实是通过CoordinatorLayout结合Behavior来实现,但阅读源码就会发现,CoordinatorLayout本身是一个NestedScrollingParent,此外,要实现这种效果还要求可滚动的列表是一个NestedScrollingChild,也就是说,通过CoordinatorLayout结合Behavior来实现这种效果,其内部原理也是NestedScrolling。虽然CoordinatorLayout只是一个UI控件,但是呢不太容易上手。从一个开发者的角度来讲,为什么要引入这套机制呢,先来看一个问题

嵌套同向滑动

所谓嵌套同向滑动,就是指这样一种情况:两个可滑动的View内外嵌套,而且它们的滑动方向是相同的。

这种情况如果使用一般的处理方式,会出现交互问题,比如使用两个ScrollView进行布局,你会发现,触摸着内部的ScrollView进行滑动,它是滑不动的

原因

两个ScrollView嵌套时,滑动距离达到滑动手势判定阈值(mTouchSlop)的这个MOVE事件,会先经过父View 的onInterceptTouchEvent()方法,父View直接把事件拦截,子 View 的onTouchEvent()方法里虽然也会在判定滑动距离足够后调用requestDisallowInterceptTouchEvent(true),但始终要晚一步,导致子view无法滑动。
显示这不是我们想要的,那我们想要什么效果呢?

当手指触摸内部ScrollView进行滑动时,能先滑动内部的ScrollView,只有当内部的ScrollView滑动到尽头时,才滑动外部的ScrollView,这样看上去比较自然,但是要在滑动时实现这个效果却不那么容易

因为滑动动作不能立刻识别出来,它的处理本身就需要通过事件拦截机制进行,而事件拦截机制实质上只是单向的,而且方向从外到内,所以无法做到:先让内部拦截滑动,内部不拦截滑动时,再让外部拦截滑动

尝试解决

假设我们不知道NestedScrolling机制,那么我们怎么来实现这个简单效果呢
让父view第一次拦截滑动的判定条件成立时,先不进行拦截,如果子view没有申请外部不拦截,父view拦截条件第二次成立时,再进行拦截

RDark true boolean result = super.onInterceptTouchEvent(ev); //第一次不拦截,第二次可以拦截的时候再拦截 if (result && isFirstIntercept) { isFirstIntercept = false; return false; } return result; }

]]></ac:plain-text-body></ac:structured-macro>

 

ac:image<ri:url ri:value="https://s2.ax1x.com/2019/03/31/AryMBq.gif" /></ac:image>
效果是这样,确实实现了让内部先获取事件

优化

但我们希望体验能更好一点,从上图能看到,子view即使在自己无法滑动的时候,也会禁止父view拦截事件,无法通过滑动子view来让父view滑动
既然子view优先,完全可以让内部的ScrollView在DOWN事件的时候就申请外部不拦截,然后在滑动一段距离后,如果判断自己在该滑动方向无法滑动,再取消对外部的拦截限制

<ac:structured-macro ac:macro-id="28799bd1-5bad-4711-8d10-38afb9a5b1c5" ac:name="code" ac:schema-version="1"><ac:parameter ac:name="theme">RDark</ac:parameter><ac:parameter ac:name="linenumbers">true</ac:parameter> ac:plain-text-body<![CDATA[ @Override public boolean dispatchTouchEvent(MotionEvent ev) { if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) { //不允许父view拦截事件 getParent().requestDisallowInterceptTouchEvent(true); }

    if (ev.getActionMasked() == MotionEvent.ACTION_MOVE) {

        int offsetY = (int) ev.getY() - getField("mLastMotionY");
        //判断自己在该滑动方向无法滑动
        if (Math.abs(offsetY) > getField("mTouchSlop")) {
            if ((offsetY > 0 && isScrollToTop()) || (offsetY < 0 && isScrollToBottom())) {
                //允许父view拦截事件
                getParent().requestDisallowInterceptTouchEvent(false);
            }
        }
    }
    return super.dispatchTouchEvent(ev);
}

]]></ac:plain-text-body></ac:structured-macro>

 

ac:image<ri:url ri:value="https://s2.ax1x.com/2019/03/31/ArrYIU.gif" /></ac:image>
可以看到子view划不动的时候,父view会滑动(卡顿,以及其他bug请忽略)
但目前为止没有实现最好的交互体验,最好的交互体验应该让子view不能滑动时,能接着滑动父view,甚至滑动过程中快速抬起时,接下来的惯性滑动也能在两个滑动View间传递。
连续滑动和惯性滑动需要重写scrollBy和computeScroll这两个方法,要做的修改会更多一些,这里暂时就不去实现了,但做肯定是没问题的。

小结

到这里我们对嵌套滑动交互的理解基本已经比较清楚了,让我们自己实现也就那么回事,主要需要解决下面几个问题:

  1. 在子view可以滑动的时候,阻止父view拦截滑动事件,先滑动子view
  2. 在用户一次滑动操作中,当子view滑动到终点时,切换滑动对象为父view,让用户能够连续滑动
  3. 在用户快速抬起触发的惯性滑动中,当子view滑动到终点时,切换滑动对象为父view,让惯性能够连续

现在我们看下系统提供的 NestedScrolling 机制是怎么完成嵌套滑动的,跟我们的实现相比,有什么区别,是更好还是更好?

概述

NestedScrolling机制能够让父view和子view在滚动时进行配合,其基本流程如下:

  1. 当子view开始滚动之前,可以通知父view,让其先于自己进行滚动;
  2. 子view自己进行滚动
  3. 子view滚动之后,还可以通知父view继续滚动

要实现这样的交互,父View需要实现NestedScrollingParent接口,而子View需要实现NestedScrollingChild接口。

在这套交互机制中,child是动作的发起者,parent只是接受回调并作出响应

另外:父view和子view并不需要是直接的父子关系,即如果“parent1包含parent2,parent2包含child”,则parent1和child仍能通过nestedScrolling机制进行交互。

NestedScrolling机制

先上图
ac:image<ri:url ri:value="https://s2.ax1x.com/2019/03/31/AryRKA.png" /></ac:image>

源码解析

NestedScrollingChild和NestedScrollingParent

<ac:structured-macro ac:macro-id="37761d19-5e2f-4405-9a53-e241abddb14a" ac:name="code" ac:schema-version="1"><ac:parameter ac:name="theme">RDark</ac:parameter><ac:parameter ac:name="linenumbers">true</ac:parameter> ac:plain-text-body<![CDATA[ public interface NestedScrollingChild { //开始、停止嵌套滚动 public boolean startNestedScroll(int axes); public void stopNestedScroll(); //触摸滚动相关 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 void setNestedScrollingEnabled(boolean enabled);
public boolean isNestedScrollingEnabled();
public boolean hasNestedScrollingParent();

}

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 boolean onNestedPreFling(View target, float velocityX, float velocityY);

public int getNestedScrollAxes();

}]]></ac:plain-text-body></ac:structured-macro>

 

这两个接口其实没什么好说的,仅仅就是定义了一些抽象方法而已。抽象方法能做什么事取决于它的具体实现。对于实现这两个接口中的大部分方法,我们只要调用其对应的helper类中的同名方法即可。

NestedScrollingChild中的方法(发起者)

NestedScrollingParent中的方法(被回调)

startNestedScroll

onStartNestedScroll、onNestedScrollAccepted

dispatchNestedPreScroll

onNestedPreScroll

dispatchNestedScroll

onNestedScroll

stopNestedScroll

onStopNestedScroll

.......

....

这些方法之间的触发关系是如果建立起来的呢?——通过NestedScrollingChildHelper对象

NestedScrollingChildHelper

<ac:structured-macro ac:macro-id="89495c6c-e34b-4f4c-adac-ae9d0b1a065b" ac:name="code" ac:schema-version="1"><ac:parameter ac:name="theme">RDark</ac:parameter><ac:parameter ac:name="linenumbers">true</ac:parameter> ac:plain-text-body<![CDATA[private final View mView;//发起者child private ViewParent mNestedScrollingParent;//配合者parent private boolean mIsNestedScrollingEnabled;

public NestedScrollingChildHelper(View view) { mView = view; }

public boolean hasNestedScrollingParent() { return mNestedScrollingParent != null; }

public void setNestedScrollingEnabled(boolean enabled) { ... mIsNestedScrollingEnabled = enabled; }]]></ac:plain-text-body></ac:structured-macro>

 

  • mView:发起者child,在创建NestedScrollingChildHelper对象时,由构造方法传入
  • mNestedScrollingParent:在startNestedScroll(int axes)方法中找到的配合者parent
  • mIsNestedScrollingEnabled:相当于是一个功能开关,如果值为false的话,那么NestedScrolling机制就无法使用

startNestedScroll(int axes)方法

这个方法所做的事情就是自下而上遍历mView的各级父view,看其中是否存在一个实现了NestedScrollingParent接口并且其onStartNestedScroll(…)方法返回true的父view,如果存在,则将这个父view赋值给成员变量mNestedScrollingParent,并返回true(表示找到了能与发起者child进行配合动作的配合者parent)。

<ac:structured-macro ac:macro-id="5bbe9876-56a3-476e-94fd-47cf0330d91e" ac:name="code" ac:schema-version="1"><ac:parameter ac:name="theme">RDark</ac:parameter><ac:parameter ac:name="linenumbers">true</ac:parameter> ac:plain-text-body<![CDATA[public boolean startNestedScroll(int axes) { if (hasNestedScrollingParent()) { return true; }

if (isNestedScrollingEnabled()) {
    ViewParent p = mView.getParent();
    View child = mView;

    //往上逐层调用每个父view的onStartNestedScroll方法,直到某个父view的onStartNestedScroll返回了ture,
    //此时说明找到了配合者parent
    while (p != null) {
        //这几个参数的含义参考NestedScrollParent接口的onStartNestedScroll
        if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {//找到了配合者parent
            mNestedScrollingParent = p;//保存配合者parent
            ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);//调用配合者parent的onNestedScrollAccepted方法
            return true;
        }

        if (p instanceof View) {
            child = (View) p;
        }
        p = p.getParent();
    }
}

return false;

}]]></ac:plain-text-body></ac:structured-macro>

 

stopNestedScroll()方法

就是简单的调用配合者parent的onStopNestedScroll方法而已

<ac:structured-macro ac:macro-id="3040e8f5-084b-42a6-b75a-557bbd97592d" ac:name="code" ac:schema-version="1"><ac:parameter ac:name="theme">RDark</ac:parameter><ac:parameter ac:name="linenumbers">true</ac:parameter> ac:plain-text-body</ac:plain-text-body></ac:structured-macro>


dispatchNestedPreScroll(…)方法

  • 调用配合者parent的onNestedPreScroll方法
  • 根据发起者child的起止位置计算offsetInWindow
<ac:structured-macro ac:macro-id="17eee57b-c605-476f-8b6f-d432989bba2c" ac:name="code" ac:schema-version="1"><ac:parameter ac:name="theme">RDark</ac:parameter><ac:parameter ac:name="linenumbers">true</ac:parameter> ac:plain-text-body<![CDATA[public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { if (isNestedScrollingEnabled() && mNestedScrollingParent != null) { if (dx != 0 || dy != 0) {

        //获得发起者child的起始位置
        int startX = 0;
        int startY = 0;
        if (offsetInWindow != null) {
            mView.getLocationInWindow(offsetInWindow);
            startX = offsetInWindow[0];
            startY = offsetInWindow[1];
        }

        //将comsumed清空
        if (consumed == null) {
            if (mTempNestedScrollConsumed == null) {
                mTempNestedScrollConsumed = new int[2];
            }
            consumed = mTempNestedScrollConsumed;
        }
        consumed[0] = 0;
        consumed[1] = 0;

        //调用配合者parent的onNestedPreScroll方法
        ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

        //根据发起者child的起始位置和终止位置计算offsetInWindow
        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;]]></ac:plain-text-body></ac:structured-macro><p>&nbsp;</p><h4>dispatchNestedScroll</h4><p>这个方法跟上面的dispatchNestedPreScroll()方法是类似的,不展开了</p><h4>dispatchNestedPreFling(&hellip;)和dispatchNestedFling(&hellip;)方法</h4><p>这两个方法可以类比于dispatchNestedPreScroll(&hellip;)和dispatchNestedScroll(&hellip;),但是更简单,只做了&ldquo;调用配合者parent的同名方法&rdquo;这一件事。</p><ac:structured-macro ac:macro-id="9521ede4-1b3e-4dd2-b992-a74b137f6eb6" ac:name="code" ac:schema-version="1"><ac:parameter ac:name="theme">RDark</ac:parameter><ac:parameter ac:name="linenumbers">true</ac:parameter><ac:plain-text-body><![CDATA[public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
    //调用配合者parent的onNestedPreFling方法
    return ViewParentCompat.onNestedPreFling(mNestedScrollingParent, mView, velocityX, velocityY);
}
return false;

}

public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { if (isNestedScrollingEnabled() && mNestedScrollingParent != null) { //调用配合者parent的onNestedFling方法 return ViewParentCompat.onNestedFling(mNestedScrollingParent, mView, velocityX, velocityY, consumed); } return false; }]]></ac:plain-text-body></ac:structured-macro>

 

以上就是NestedScrollingChildHelper的主要代码。

总结一下,NestedScrollingChildHelper主要就是做了下面这两件事

  • 找到配合者parent
  • 作为发起者child和配合者parent之间方法调用的桥梁,起一个中介或者说是代理的作用。

当我们调用NestedScrollingChild中的方法XXX()时,方法XXX()实际会去调用NestedScrollingChildHelper中的方法XXX(),而NestedScrollingChildHelper中的方法XXX()又会去调用NestedScrollingParent中的方法onXXX(),就是这样一个简单的传递流程。方法的返回值则是走相反的传递路径。

NestedScrollingParentHelper

此Helper类的工作非常简单,就是保存了axes的信息而已

<ac:structured-macro ac:macro-id="5bfd2471-3bc6-4bbe-9e89-c3d00fa405d5" ac:name="code" ac:schema-version="1"><ac:parameter ac:name="theme">RDark</ac:parameter><ac:parameter ac:name="linenumbers">true</ac:parameter> ac:plain-text-body<![CDATA[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;
}

}]]></ac:plain-text-body></ac:structured-macro>

 

总结与思考

你会发现,这跟我们自己实现嵌套滑动的方式非常像,但它有这些地方做得更好

  1. 发起者child使用更灵活的方式找到和绑定自己的配合者parent,而不是直接找自己的上一级结点
  2. 对每一次MOVE事件传递来的滑动,都使用「parent -> child -> parent -> child」机制进行消费,让发起者child在消费滑动时与parent配合更加细致、紧密和灵活
  3. 对用户fling操作引发的滑动,与用户滑动屏幕触发的滑动使用同样的机制进行消费,实现了完美的惯性连续效果

回想NestedScrolling的工作流程并结合上面helper类的源码,我们会发现整个NestedScrolling机制其实就是两个接口加上一个中介(NestedScrollingChildHelper)而已。在两个helper类中也基本没有涉及到接口的使用方式

google对于NestedScrolling机制的设计也很值得我们在自己的项目中借鉴:

  1. 通过两个接口来解耦需要进行交互的view
  2. 提供封装了接口之间交互逻辑的helper类以方便用户使用接口

一些较新的系统view都已经实现了NestedScrollingChild或NestedScrollingParent接口,也就是说他们直接支持NestedScrolling,例如:

NestedScrollView 已实现 NestedScrollingParent和NestedScrollingChild
RecyclerView 已实现 NestedScrollingChild
CoordinatorLayout 已实现 NestedScrollingParent

有些善于学习的同学可能会问了, 我看源码里面CoordinatorLayout,RecyclerView等都是实现的NestedScrollingParent2和NestedScrollingChild2啊,最近 Google 还在androidx的支持库里增加了NestedScrollingChild3啊,你上面说的这一堆是不是都过时了呀?不要担心,这里为了便于大家理解,采用的是NestedScrolling最初版本,核心是不变的,而且省略了一些非核心的方法,NestedScrolling机制的后续版本主要是bug修复和优化。

历史演化

  1. 2014年9月,Google 在Android 5.0( API 21)中的 View 和 ViewGroup 中加入了第一个版本的 NestedScrolling 机制,此时能够通过启用嵌套滑动,让嵌套的ScrollView不出现交互问题,但这个机制只有 API 21 以上才能使用
  2. 2015年4月,Google 重构了第一个版本的 NestedScrolling 机制,逻辑没有变化,但是把它从 View 和 ViewGroup 中剥离,得到了两个接口(NestedScrollingChild、NestedScrollingParent)和两个 Helper (NestedScrollingChildHelper、NestedScrollingParentHelper),并且用这套新的机制重写了一个默认启用嵌套滑动的NestedScrollView,并把它们都放入了Revision 22.1.0的v4 support library,让低版本的系统也能使用嵌套滑动机制,不过此时的第一版机制有「惯性不连续」的 Bug,它的现象是这样的:在滑动内部 View 时快速抬起手指,内部 View 会开始惯性滑动,当内部 View 惯性滑动到自己顶部时便停止了滑动,此时外部的可滑动 View 不会有任何反应,即使外部View可以滑动
  3. 2017年9月,Google 在Revision 26.1.0的v4 support library中发布了第二个版本的 NestedScrolling 机制,增加了接口NestedScrollingChild2、NestedScrollingParent2,主要是给原本滑动相关的方法增加了一个参数type,表示了两种滑动类型TYPE_TOUCH、TYPE_NON_TOUCH。并且使用新的机制重写了嵌套滑动相关的控件。这次更新解决了第一个版本中「惯性不连续」的Bug,但也引入了新的Bug,「二倍速」(仅NestedScrollView)它的现象是这样的:当外部 View 不在顶部、内部 View 在顶部时,往下滑动内部 View 然后快速抬起(制造 fling )预期效果应该是:外部 View 往下进行惯性滑动,实际上也大概是这样,但有一点点区别:外部 View 往下滑动的速度会比你预想中要快,大概是两倍的速度(反方向也是一样);还有一个bug是空气马达,它的现象是这样:当外部 View 在顶部、内部 View 也在顶部时,往下滑动内部 View 然后快速抬起(制造 fling ),(目前什么都不会发生,因为都滑到顶了,关键是下一步) 你马上滑外部 View,预期应该是:外部 View 往上滚动,但实际上你会发现:你滑不动它,或是滑上去一点,马上又下来了,像是有一台无形的马达在跟你的手指较劲(反方向也是一样)
  4. 2018年11月,Google 给已经并入AndroidX 家族的 NestedScrolling 机制更新了第三个版本,具体版本是androidx.core 1.1.0-alpha01,增加了接口NestedScrollingChild3、NestedScrollingParent3,改动只是给原来的dispatchNestedScroll()和onNestedScroll()增加了int[] consumed参数。并且后续把嵌套滑动相关的控件用新机制进行了重写。这次更新解决了第二个版本中 NestedScrollView的「二倍速」Bug,同时期望解决「空气马达」Bug,但是没有解决彻底,还遗留了「摁不住」Bug,他的现象是这样的:它没有加入触摸外部view后关闭内view马达的机制,更确切地说是没有加入「触摸外部 View 后阻止对内部View 传递过来的滑动进行消费的机制」所以只有外部 View 滑动到尽头的时候才能关闭马达,外部 View 没法给内部 View 反馈自己被摁住了, 解决方法,反射解决,这里不展开了

 

掀乱书页的风

2021/10/16  阅读:22  主题:兰青

作者介绍

掀乱书页的风