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
拿到事件后,计算出滚动量,滚动量分如下三步处理:
1.先问问 NestedScrollingParent
要不要消耗滚动量?,消耗多少?(onNestedPreScroll)。
2.如果滚动量没被完全消耗,则判断 NestedScrollingChild
自己要不要消耗滚动量?消耗多少?(组件内部实现)。
3.如果滚动量依旧没被消耗完,则再问一下 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
吧)
滚动消耗可以分为三部分:
1.TopView
内部消耗
2.BottomView
内部消耗
3.TopView
与BottomView
的整体移动消耗, 称为 “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 查看源代码。
接口设计
知道了整体流程,那么来看看 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 层面,却是无法避免的,至少要会改轮子。