QMUI 在 v1.3.2 提供了一个全新的组件:QMUIContinuousNestedLayout。点击这里可查看使用文档。本文就来聊一聊它的使用场景、设计以及实现。
很多 App 的信息流详情界面,都会使用一个 WebView 展示内容,然后底部一个列表显示评论。这是 QMUIContinuousNestedLayout 的一个使用场景。但 QMUIContinuousNestedLayout 则支持更多的使用场景:

起源
组件的创建离不开需求场景,不同的需求场景,组件的设计也会有很大的不同。 QMUIContinuousNestedLayout 则是因微信读书故事流而产生,目前其提供的功能也完全是为了满足故事流详情界面。相比一般信息流的详情页,微信读书故事流详情界面更加复杂:需要同时支持 WebView / RecyclerView / 自定义排版 View / 普通LinearLayout 等 View 与 嵌套 RecyclerView 的 ViewPager 的连接。
NestedScroll 机制
凡是嵌套滚动组件的实现,最佳选择肯定是官方的 NestedScroll 机制,进一步可以选择实现了这个机制的 CoordinatorLayout。但QMUIContinuousNestedLayout 虽然继承了 CoordinatorLayout,但不是完全遵循 NestedScroll 机制。 这是为什么呢?我们先来了解下 NestedScroll 机制。
NestedScroll 机制是 Android L 之后才提出的,在这之前,处理滚动只能依赖于外部拦截法和内部拦截法了。
- 外部拦截法:外部容器通过
onInterceptTouchEvent拦截掉事件的传递,外部容器检测并处理滚动。 - 内部拦截法: 内部容器
requestDisallowInterceptTouchEvent要求系统将事件直接传递给内部容器。
一般而言,外部拦截法和内部拦截法不能公用。 否则内部容器可能并没有机会调用 requestDisallowInterceptTouchEvent。
NestedScroll 机制使用了内部拦截法。因此事件总是先传递给内层的 view。 然后通过 NestedScrollingChild 和 NestedScrollingParent 来约束事件的处理。其接口比较多,就不在这里列举了。最主要的是明白其处理逻辑:最内层的 NestedScrollingChild 拿到事件后,计算出滚动量,滚动量分如下三步处理:
- 先问问
NestedScrollingParent要不要消耗滚动量?,消耗多少?(onNestedPreScroll)。 - 如果滚动量没被完全消耗,则判断
NestedScrollingChild自己要不要消耗滚动量?消耗多少?(组件内部实现)。 - 如果滚动量依旧没被消耗完,则再问一下
NestedScrollingParent要不要消耗剩余滚动量?(onNestedScroll)。
一般而言,我们内层 View 是 RecyclerView, 是已经实现好了 NestedScrollingChild 的,我们只需要外层容器实现 NestedScrollingParent 来判断是否需要消耗混动量。但如果内层 View 是自定义 View,那就需要我们自己实现 NestedScrollingChild,这相对而言是比较复杂的。 因而我没有完全采取 NestedScroll 机制,那样需要WebView、LinearLayout、自定义排版 View 都要实现 NestedScrollingChild,前两者还好,但是我们的排版 View 的事件分发逻辑已经高度定制化,很难再接入这一套了,因而我对 TopView 采用外部拦截法,但是处理了 NestedScroll 机制的一些回调点。
事件分发流程
QMUIContinuousNestedLayout 可以设置两个滚动容器,分别为 TopView 和 BottomView。 (目前来看,只设置两个滚动容器是足够的,对于将来的扩展而言,这也是足够的。后期可以扩展 QMUIContinuousNestedLayout 使其支持作为 TopView 或者 BottomView嵌套到另一个
QMUIContinuousNestedLayout 里。)
TopView一般是多种多样的,因而采用的是外部拦截法,滚动量由外层计算出,具体的消耗行为由TopView实现,实际上是由QMUIContinuousNestedTopAreaBehavior进行拦截。BottomView的内层一般都是RecyclerView,因而直接采用NestedScroll机制。(都 2019 年了, 忘掉ListView吧)
滚动消耗可以分为三部分:
TopView内部消耗BottomView内部消耗TopView与BottomView的整体移动消耗, 称为 “offset 消耗”
事件分发的总体流程大体分为两种:
- 如果 Down 事件发生在
TopView上:
a. 由QMUIContinuousNestedTopAreaBehavior拦截事件并计算好滚动量。
b. 如果是向上滚动,那么先进行TopView内部消耗,然后进行 offset 消耗。如果是向下滚动,那么先进行 offset 消耗,然后进行TopView内部消耗。 (因为布局准确,这里不会存在BottomView内部消耗)
c. 当 Up 事件发生,触发 fling,如果是向上滚动,还需要执行BottomView内部消耗。 - 如果 Down 事件发生在
BottomView上:
a. 滚动量是由最内层的NestedScrollingChild产生,然后配合外层的QMUIContinuousNestedScrollLayout(CoordinatorLayout) 来进行滚动消耗。
b.QMUIContinuousNestedScrollLayout又将消耗行为委托给QMUIContinuousNestedTopAreaBehavior。
c. 在QMUIContinuousNestedTopAreaBehavior中,如果是向上滚动,那么onNestedPreScroll优先决定是否需要进行 offset 消耗;如果是向下滚动,那么需要在onNestedScroll中根据剩余的滚动量做 offset 消耗。
d. 当 Up 事件发生,触发 fling,如果是向上滚动,需要执行TopView内部消耗。
这里整理出主要的逻辑,让读者知道什么时机执行什么代码,具体代码就不贴了,可以自行去 Github 查看源代码。
接口设计
知道了整体流程,那么来看看 TopView 与 BottomView 的接口设计。
TopView 主要接口只有三个:
public interface IQMUIContinuousNestedTopView extends IQMUIContinuousNestedScrollCommon {
// 传入未消耗的滚动量,返回值应当是 `TopView` 处理完后依旧没被消耗的量。
// Integer.MAX_VALUE 表示滚动到底部
// Integer.MIN_VALUE 表示滚动到顶部
int consumeScroll(int dyUnconsumed);
// 当前滚动量
int getCurrentScroll();
// 总的可滚动量
int getScrollOffsetRange();
}
BottomView 的接口相对比较多一点,主要原因是 TopView 的所有行为都被 QMUIContinuousNestedTopAreaBehavior 拦截并处理了,所以它自身不需要处理 smoothScroll 等行为。
public interface IQMUIContinuousNestedBottomView extends IQMUIContinuousNestedScrollCommon {
int HEIGHT_IS_ENOUGH_TO_SCROLL = -1;
// 传入未消耗的滚动量,因为是走 NestedScroll 机制,所以这里已经不需要再关系处理后的未消耗量了。
// Integer.MAX_VALUE 表示滚动到底部
// Integer.MIN_VALUE 表示滚动到顶部
void consumeScroll(int dyUnconsumed);
// 慢滚动
void smoothScrollYBy(int dy, int duration);
void stopScroll();
/**
* BottomView 的高度不一定能撑满整个内容区域,如果不做任何处理,
* 那么完全滚动到 BottomView 时, 就会有很多空白,
* 因而添加这个接口,当内容还不足以滚动时,返回内容高度,否则返回 HEIGHT_IS_ENOUGH_TO_SCROLL
*/
int getContentHeight();
int getCurrentScroll();
int getScrollOffsetRange();
}
这里的 getScrollOffsetRange() 与 View.computeVerticalScrollRange() 并不一致, computeVerticalScrollRange() 是返回了内容的真实长度,而 getScrollOffsetRange() 返回的最大滚动量,一般等于 computeVerticalScrollRange() - getHeight()。
TopView 与 BottomView 对 Integer.MAX_VALUE 和 Integer.MIN_VALUE 做了特殊定义,分别是滚动到顶部与尾部,这在诸如 RecyclerView 等实现中特别友好, 可以通过 scrollToPosition快速完成。
Tips: WebView 的 getContentHeight() 是不准的,但是 computeVerticalScrollRange() 却是很准确的,WebView 的 滚动条实现也是依赖的它,因此是可以信任的。 但是 getScrollY 有时候并不准确,甚至会超过computeVerticalScrollRange(),
因此计算滚动量和获取滚动位置时都要加上 computeVerticalScrollRange() 做最值保护。
其它
QMUIContinuousNestedTopDelegateLayout 为 TopView 添加 Header/Footer。 QMUIContinuousNestedBottomDelegateLayout 为 BottomView 添加了 Sticky Header。 QMUIContinuousNestedBottomDelegateLayout 没有添加 Footer 实现,是因为场景少,而且可以作为 RecyclerView 的一个 itemView。
而在实现上,主要依赖 QMUIViewOffsetHelper 来处理滚动位置,官方也有 ViewOffsetHelper 这个工具类,可惜不是 public 的,它是一个非常好用的工具类,在滚动、位置偏移等场景很有用,有兴趣的可以了解一下,有时候查看官方组件的实现,可以了解到很多很有用的编码技巧。
QMUIContinuousNestedScrollLayout 也提供了滚动位置信息的 save 与 restore 功能,其实现与 View 状态存储与恢复差不多,同过Key-Value 的形式收集到一个 Bundle 中。当然也就存在相应的弊端: 如果两个 View 的 id 相同,那么状态恢复会出错;如果 key 值冲突, 那么 QMUIContinuousNestedScrollLayout 的 restore 也会不准确。因为 QMUIContinuousNestedScrollLayout 目前并不能用 DelegateLayout 做多层次嵌套(应该不会有人这么干吧)
最后一个功能时滚动监听的实现:
public interface OnScrollListener {
void onScroll(int topCurrent, int topRange,
int offsetCurrent, int offsetRange,
int bottomCurrent, int bottomRange);
void onScrollStateChange(int newScrollState, boolean fromTopBehavior);
}
其会提供使用者六个蚕食,包含了 TopView、 BottomView 、 offset 的当前值与范围值, 使用者可以灵活运用。当然相比与一般的滚动容器,onScroll 的回调可能会略多,因为两个容器与外部 offset 都会触发,并且可能重复,因而最好不要做耗时操作。
结语
一个复杂的 UI 组件,写出一个 Demo 可能很容易,但是要灵活协调各种场景的使用则不是那么容易的一件事情。这个时候一个好的设计就相当重要了,目前这个组件经历了微信读书书籍章节、漫画章节、讲书、公众号等的不断打磨,也只能说是能够满足当前需求,但谁又知道会有什么要求是当前组件不能胜任的呢?产品、设计的奇思异想往往会想要复用的同时加一点差异化,然后整个组件就蹦了。所以,读源码吧,重复造轮子虽然是不推荐的,但是在 UI 层面,却是无法避免的,至少要会改轮子。