QMUIContinuousNestedScrollLayout——连接滚动容器,专为文章详情页而生

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。 然后通过 NestedScrollingChildNestedScrollingParent 来约束事件的处理。其接口比较多,就不在这里列举了。最主要的是明白其处理逻辑:最内层的 NestedScrollingChild 拿到事件后,计算出滚动量,滚动量分如下三步处理:

NestedScrollingParent
NestedScrollingChild
NestedScrollingParent

一般而言,我们内层 View 是 RecyclerView, 是已经实现好了 NestedScrollingChild 的,我们只需要外层容器实现 NestedScrollingParent 来判断是否需要消耗混动量。但如果内层 View 是自定义 View,那就需要我们自己实现 NestedScrollingChild ,这相对而言是比较复杂的。 因而我没有完全采取 NestedScroll 机制,那样需要WebView、LinearLayout、自定义排版 View 都要实现 NestedScrollingChild ,前两者还好,但是我们的排版 View 的事件分发逻辑已经高度定制化,很难再接入这一套了,因而我对 TopView 采用外部拦截法,但是处理了 NestedScroll 机制的一些回调点。

事件分发流程

QMUIContinuousNestedLayout 可以设置两个滚动容器,分别为 TopViewBottomView 。 (目前来看,只设置两个滚动容器是足够的,对于将来的扩展而言,这也是足够的。后期可以扩展 QMUIContinuousNestedLayout 使其支持作为 TopView 或者 BottomView 嵌套到另一个 QMUIContinuousNestedLayout 里。)

  • TopView 一般是多种多样的,因而采用的是外部拦截法,滚动量由外层计算出,具体的消耗行为由 TopView 实现,实际上是由 QMUIContinuousNestedTopAreaBehavior 进行拦截。
  • BottomView 的内层一般都是 RecyclerView ,因而直接采用 NestedScroll 机制。(都 2019 年了, 忘掉 ListView 吧)

滚动消耗可以分为三部分:

  1. TopView 内部消耗
  2. BottomView 内部消耗
  3. TopViewBottomView 的整体移动消耗, 称为 “offset 消耗”

事件分发的总体流程大体分为两种:

  1. 如果 Down 事件发生在 TopView 上:
    a. 由 QMUIContinuousNestedTopAreaBehavior 拦截事件并计算好滚动量。
    b. 如果是向上滚动,那么先进行 TopView 内部消耗,然后进行 offset 消耗。如果是向下滚动,那么先进行 offset 消耗,然后进行 TopView 内部消耗。 (因为布局准确,这里不会存在 BottomView 内部消耗)
    c. 当 Up 事件发生,触发 fling,如果是向上滚动,还需要执行 BottomView 内部消耗。
  2. 如果 Down 事件发生在 BottomView 上:
    a. 滚动量是由最内层的 NestedScrollingChild 产生,然后配合外层的 QMUIContinuousNestedScrollLayout ( CoordinatorLayout ) 来进行滚动消耗。
    b. QMUIContinuousNestedScrollLayout 又将消耗行为委托给 QMUIContinuousNestedTopAreaBehavior
    c. 在 QMUIContinuousNestedTopAreaBehavior 中,如果是向上滚动,那么 onNestedPreScroll 优先决定是否需要进行 offset 消耗;如果是向下滚动,那么需要在 onNestedScroll 中根据剩余的滚动量做 offset 消耗。
    d. 当 Up 事件发生,触发 fling,如果是向上滚动,需要执行 TopView 内部消耗。

这里整理出主要的逻辑,让读者知道什么时机执行什么代码,具体代码就不贴了,可以自行去 Github 查看源代码。

接口设计

知道了整体流程,那么来看看 TopViewBottomView 的接口设计。

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()

TopViewBottomViewInteger.MAX_VALUEInteger.MIN_VALUE 做了特殊定义,分别是滚动到顶部与尾部,这在诸如 RecyclerView 等实现中特别友好, 可以通过 scrollToPosition 快速完成。

Tips: WebViewgetContentHeight() 是不准的,但是 computeVerticalScrollRange() 却是很准确的, WebView 的 滚动条实现也是依赖的它,因此是可以信任的。 但是 getScrollY 有时候并不准确,甚至会超过 computeVerticalScrollRange() , 因此计算滚动量和获取滚动位置时都要加上 computeVerticalScrollRange() 做最值保护。

其它

QMUIContinuousNestedTopDelegateLayoutTopView 添加 Header/Footer。 QMUIContinuousNestedBottomDelegateLayoutBottomView 添加了 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);
}

其会提供使用者六个蚕食,包含了 TopViewBottomView 、 offset 的当前值与范围值, 使用者可以灵活运用。当然相比与一般的滚动容器,onScroll 的回调可能会略多,因为两个容器与外部 offset 都会触发,并且可能重复,因而最好不要做耗时操作。

结语

一个复杂的 UI 组件,写出一个 Demo 可能很容易,但是要灵活协调各种场景的使用则不是那么容易的一件事情。这个时候一个好的设计就相当重要了,目前这个组件经历了微信读书书籍章节、漫画章节、讲书、公众号等的不断打磨,也只能说是能够满足当前需求,但谁又知道会有什么要求是当前组件不能胜任的呢?产品、设计的奇思异想往往会想要复用的同时加一点差异化,然后整个组件就蹦了。所以,读源码吧,重复造轮子虽然是不推荐的,但是在 UI 层面,却是无法避免的,至少要会改轮子。