小松的技术博客

六和敬

若今生迷局深陷,射影含沙。便许你来世袖手天下,一幕繁华。 你可愿转身落座,掌间朱砂,共我温酒煮茶。

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 拿到事件后,计算出滚动量,滚动量分如下三步处理:

  1. 先问问 NestedScrollingParent 要不要消耗滚动量?,消耗多少?(onNestedPreScroll)。
  2. 如果滚动量没被完全消耗,则判断 NestedScrollingChild 自己要不要消耗滚动量?消耗多少?(组件内部实现)。
  3. 如果滚动量依旧没被消耗完,则再问一下 NestedScrollingParent 要不要消耗剩余滚动量?(onNestedScroll)。

一般而言,我们内层 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 层面,却是无法避免的,至少要会改轮子。

QMUI 团队招 Android 开发啦,可发简历至contact@qmuiteam.com
←微信← →支付宝 →