QMUIContinuousNestedScrollLayout——连接滚动容器,专为文章详情页而生
source link: https://www.tuicool.com/articles/qMVjyaF
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
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 NestedScrollingChild NestedScrollingParent
一般而言,我们内层 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 层面,却是无法避免的,至少要会改轮子。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK