[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver
Flutter使用sliver
来表示可滚动区域的一个个的child,sliver所遵循的并非是传统Widget的BoxConstraint布局约束,而是采用一种基于sliver协议的布局约束,这个协议只能在CustomScrollView中使用,最大的一个特点就是它只有主滚动方向上一维的Extent概念,而没有BoxConstraint的Width和Height的概念。
当然,Widget最终渲染时还是要转换成BoxConstraint的,因此sliver->BoxConstraint的转换也是广泛存在的。
得益于sliver协议,CustomScrollView支持多种多样的滚动布局,例如:
- SliverPersistentHeader,就支持滚动时将一个SliverWidget Pin在顶部;
- SliverFillRemaining,支持一个Sliver占满当前Viewport的所有剩余空间;
- SliverFillViewport,支持一个Sliver占有一整个Viewport的高度;
而相比较于ListView,CustomScrollView,直接对于slivers提供了一种最原始的支持,你可以自由地去组合支持sliver布局协议的组件,以在一个Viewport内得到良好的滚动体验。
一、问题引入:CustomScrollView与ListView嵌套的问题
我们知道,ListView要么使用shrinkWrap,让他蜷缩起来;要么明确指定它Viewport的尺寸,因此SliverFillRemaining通常情况下是非常适合充当ListView的容器的,因此我们就有了如下的结构:
CustomScrollView{
slivers:[
SliverPersistentHeader{
....
},
SliverFillRemaining{
child:ListView
}
]
}
在我们的想象中:SliverPersistentHeader在滚动过程中会收缩或者推走,然后SliverFillRemaining会逐渐拉长,直到撑满整个Viewport,接下来的滚动就会滚动ListView。
但是上述情况并没有生效,SliverPersistentHeader在滚动过程中会收缩,但只限于我们手指SliverPersistentHeader区域上滚动的情况,如果手指一开始就放在ListView上滚动,那么SliverPersistentHeader并不会收缩或者被推走:
原因很简单,这里有两个可滚动单元:CustomScrollView和ListView,它们的滚动事件是独立的:
- 如果手指在SliverPersistentHeader上滚动,这种情况下滚动事件其实是CustomScrollView消费的;
- 如果手指放在ListView上滚动,这种情况下滚动事件则变成了ListView去消费。
因此,同属于CustomScrollView的两个sliver组件表现出了完全不同的滑动响应。
分析下来,这种情况是合理的,但是产品和设计并不会听我们的解释。
二、封装好的替代者:NestedScrollView
上面提到的问题的核心点,用一句话概括总结,其实是:CustomScrollView和ListView之间的滚动是独立的,它们并没有结合起来,最终呈现出来的效果就是各滚各的,解决问题的思路也很简单,把二者结合起来即可,我们需要实现:
滑动事件产生滚动量,滚动量统一管理,如果手指向上滑动,视图整体向上,那么事件显然应该先交给CustomScrollView去消费;反之应该先交给ListView去消费。
官方其实已经提供了一个组件能够帮我们实现这个效果:NestedScrollView,即可以支持嵌套滑动的ScrollView,它需要声明两个部分:
- Header
- Body
其中的Header部分,是一个builder方法,需要返回一个sliver数组;而Body部分,需要返回一个Widget,按照一中的需求,我们可以拆分成这样的结构:
NestedScrollView{
header:[
SliverPersistentHeader()
]
body:ListView()
}
我们会发现它可以正常地工作,很好地去兼容ListView和NestedScrollView二者的滚动了,即使你手指在ListView上滚动,事件也会优先交给CustomScrollView去消费:
大致上看一下它的实现,NestedScrollView是基于CustomScrollView实现的
CustomScrollView
-> headers[ SliverPersistentHeader ]
-> SliverFillRemaining
-> PrimaryScrollController
-> body ( ListView )
headers和body分别对应header和body两个部分,初始情况下,当CustomScrollView滑动的时候,NestedScrollView会优先将事件派发给headers,随时headers的高度变化,SliverFillRemaining的高度会逐渐增大(它给子Widget的约束一直在发生变化) 。这个情况下虽然body的更多部分被露出来,但是并不是因为滑动造成的,而直到header部分产生了多余的滚动量的时候,此时的滑动事件才会被NestedScrollView派发给body,body中的可滑动内容才开始滚动。这就是NestedScrollView实现嵌套滑动的基本原理。
显然,NestedScrollView其实同时控制了CustomScrollView的ScrollController和PrimaryScrollController的ScrollController,以实现二者滚动量的统一管理与派发。
这能解决一对一的滑动嵌套问题,即一个CustomScrollView下有一个ListView的场景。
但是,如下的场景,对于NestedScrollView来说,就已经无能为力了:
三、PrimaryScrollController有多个可滚动的Widget
如上提到的问题,Widget的结构大致如下:
CustomScrollView
-> headers[SliverPersistentHeader]
-> SliverFillRemaining
-> PrimaryScrollController
-> body(Row{
children:[
Expanded(flex:1,child:ListView1),
Expanded(flex:3,child:ListView2)
]
})
PrimaryScrollController在一对一嵌套的场景中,会直接控制住它的唯一可滚动子Widget,也就是ListView,而在现在的这个场景中,PrimaryScrollController会直接控制住它的唯二可滚动的子Widget,即两个ListView都会被同一个ScrollController所控制,带来的结果就是你在左侧的ListView1上滚动,会顺带着把右侧的ListView2也带着滚动了。
此外,嵌套滚动依然是生效的,因为NestedScrollView控制PrimaryScrollController和CustomScrollView的ScrollController这一控制嵌套滚动的核心仍然没有发生变化。
我们可以分析出问题的原因 : ListView1和ListView2公用了PrimaryScrollController导致的问题 。
解决问题的办法也很简单,不让它们共用即可,你可以给ListView1单独new一个ScrollController,就像这样:
ListView(
controller: ScrollController()
)
这样一来你会发现ListView1和ListView2之间的滚动是独立了,但是ListView1上的滚动却无法和外层的CustomScrollView的滚动进行嵌套滑动了,ListView2的嵌套滚动仍然是正常的。因此这条路虽然简单,但是仍然有瑕疵。
不过:
四、ScorllController:为什么ListView可以被滚动
先把三中的问题放一放,ListView本身会规定一个Viewport,在Viewport内部的children一般会长于Viewport本身,而ListView在收到手指滚动的事件之后,只做了一件事:调整children部分的偏移量:
如上图,左侧的初始状态下,Viewport内的children顶部和Viewport顶部对齐,它的children元素的偏移量是0。
如果手指产生了300的偏移量,那么此时children就应该整体被「上移」300个像素。
我们知道,在Flutter的布局方式下,通用的视图刷新方式其实是一套在State中setState来重新生成新的Children Widget然后触发Element中的Widget更新方法,比较新旧Element的数据是否一致,然后更新RenderObject的过程。
如果参考这个过程,那么我们对于ListView的滚动可以做出如下的猜测:
- ListView内部的实现组件:ScorllState会设置手势监听;
- 监听到事件之后,计算偏移量;
- 然后通过setState((){...})方法去更新偏移量字段,然后触发Viewport的重新渲染,来更新RenderObject。
但这是错误的。
首先如果是通过setState实现的偏移量变更,那么势必会导致对于的State的build方法调用、新的children实例(Widget)被创建。但在这里它并没有导致仍然可见的children发生新的实例创建,这个可以通过比较简单的实验测试一下。
其次,如果滚动是通过setState实现的,那么在一次滚动中势必会触发数十、上百次的setState,虽然Widget的成本相对来说较低,但也不是这样去浪费的,显然也不太可能。
所以,对于ListView的viewport内容变更来说,它和传统的setState完全不一样,它的流程可以概括成:
- ListView内部的实现组件:ScorllState会设置手势监听;
- 监听到事件之后,计算偏移量;
- 然后直接notify到RenderObject层、Viewport对于的RenderObject元素,读取新的偏移量数值来完成视图偏移量的变更。
最明显的一个区别点就在于RenderViewport的更新会被监听到的滚动事件直接触发,而跳过setState的过程,你可以验证一下相关的调用:
markNeedsPaint的调用源头,完全来自于ScorllPosition的setPixels方法,对数值更新后,对它的观察者的调用,观察调用栈我们可以发现,其实滚动过程中产生了动画,动画的触发器将修改ScrollPosition,然后将修改后的结果通知RenderObject,来标记为需要重新绘制(markNeedsPaint)。
从这里我们知道了ListView实际上是怎么『动起来』的,其背后的ScrollController会通过监听获得新的手指滑动量delta,然后更新ScrollPosition相关的参数,最终直接去将ListView的Viewport对于的RenderObject层元素:_RenderSingleChildViewport做一个更新。
至此,如果我们希望统一去分发滚动量,就有了一个抓手,那就是ScrollPosition。
五、ScrollPosition:描述滚动信息的对象
ScrollController本身维护了一个ScrollPosition来描述Viewport中,children的滚动信息,它主要做两件事:
- 修改pixel,通知更新Viewport。
ScrollPosition甚至就是一个ViewportOffset的子类,ChangeNotifier的简介子类,它天生就有notify他人的能力。
- 接受来自用户的事件。
ListVIew对于的ScrollPosition的实现类是:ScrollPositionWithSingleContext(extends ScrollPosition、implements ScrollActivityDelegate) ,它有一个方法:
ScrollActivityDelegate#applyUserOffset
,这个方法就是接受用户滚动量的核心。
NestedScrollView的做法大致如下:
-
通过一个结构( _NestedScrollCoordinator)来统一管理两个ScrollController,分别是CustomScrollView自带的ScrollController以及PrimaryScrollController对于的滚动控制器;
-
通过重写ScrollPosition,得到NestedScrollPosition接收滚动量,然后将所有的滚动量优先交给( _NestedScrollCoordinator),无论是CustomScrollView还是PrimaryScrollController。
-
_NestedScrollCoordinator根据滚动量的方向(正负号代表方向)来判断这个事件优先给谁消费:
- 如果是手指向上的滑动事件,应该会优先交给CustomScrollView去展示(因为多用于SliverPersistentHeader的收起),如果产生了盈余的滚动量(OverScroll),那么再交给child去消费;
- 反之,则应该优先交给子Widget去展示,多用于PrimaryScrollController的快速向上,直到PrimaryScrollController的内容到达顶部之后,如果产生了盈余的滚动量,才应该交给CustomScrollView去执行,比如SliverPersistentHeader内容展开。
不过上述的两种情况并不是一定的,要结合具体场景去做具体的分析,这里只是给一个比较形象的例子。
我们只需要将CustomScrollView的ScrollController和子Widget的ScrollController的一对一关系改成一对二、甚至是一对多就可以兼容三中这种双侧列表滚动的场景,我们需要自定义ScrollController(核心是为了重写getScrollPosition来获得一个自定义的ScrollPosition),于是修改相关代码,我们可以得到:
六、实现
综上,我们要实现这个需求,我们要做如下的改造,首先我们要有一个结构来管理主ScrollController和子ScrollController(可能有多个),在这里我们叫它SliverCompat
,它主要负责几件事情:
- 接受所有来自于子Widget的滑动量;
- 决定滑动量分配给哪个ScrollController去消费;
这里提供了两个方法来创建ScrollController:
class SliverCompat {
MultiSliverCompatScrollController? _majorScrollController;
Key? debugKey;
SliverCompat({this.debugKey});
final HashMap<Key, MultiSliverCompatScrollController> _scrollPool = HashMap();
ScrollController generateMajorController() {
_majorScrollController ??=
MultiSliverCompatScrollController.major(const Key("Major"), this);
return _majorScrollController!;
}
ScrollController generateMinorController({required Key tag}) {
if (_scrollPool[tag] != null) {
return _scrollPool[tag]!;
}
MultiSliverCompatScrollController newController =
MultiSliverCompatScrollController.minor(tag, this);
_scrollPool[tag] = newController;
return newController;
}
// 接收child提交的滚动量
double submitUserOffset(
MultiSliverCompatScrollPosition? submitter, double delta) {
if (delta < 0) {
return onScrollToTop(submitter, delta);
} else {
return onScrollToBottom(submitter, delta);
}
}
......
}
在代码中,我们只需要自定义使用CustomScrollView即可:
_sliverCompat = SliverCompat();
return CustomScrollView(
controller: _sliverCompat.generateMajorController(),
slivers: [
SliverPersistentHeader(delegate: ...),
SliverFillRemaining(
child: ListView(
controller:
_sliverCompat.generateMinorController(tag: Key("subview")),
),
)
],
);
而对于SliverCompat中的submitUserOffset方法,则就是所有隶属于它的ScrollController向上提交滚动量的入口,我们如果要接收整个滚动量,我们需要重写一个ScrollPosition,而创建ScrollPosition是ScrollController创建的,所以我们也要重写一个ScrollController:
class MultiSliverCompatScrollController extends ScrollController {
Key? debugKey;
SliverCompat sliverCompat;
late bool isMajorScrollController;
MultiSliverCompatScrollController._(this.sliverCompat, {this.debugKey});
MultiSliverCompatScrollController.major(this.debugKey, this.sliverCompat) {
isMajorScrollController = true;
}
MultiSliverCompatScrollController.minor(this.debugKey, this.sliverCompat) {
isMajorScrollController = false;
}
@override
ScrollPosition createScrollPosition(ScrollPhysics physics,
ScrollContext context, ScrollPosition? oldPosition) {
if (isMajorScrollController) {
return MajorScrollPosition(sliverCompat,
physics: const ScrollPhysics(), context: context, debugKey: debugKey);
} else {
return MinorScrollPosition(sliverCompat,
physics: const ScrollPhysics(), context: context, debugKey: debugKey);
}
}
}
这里区分majorScrollPosition的情况去创建了两种不同的ScrollPosition,只是因为我直觉上这俩个Position会在一些场景下的表现性不同,但就目前来看实际上并没有发现啥场景需要区分:
class MultiSliverCompatScrollPosition extends ScrollPositionWithSingleContext {
SliverCompat sliverCompat;
Key? debugKey;
MultiSliverCompatScrollPosition(this.sliverCompat,
{required super.physics, required super.context, this.debugKey});
/// 所有的偏移量统一交给SliverCompat去处理;
@override
void applyUserOffset(double delta) {
sliverCompat.submitUserOffset(this, delta);
}
/// 食用滚动量,然后返回未吃完的滚动量
double applyClampedDragUpdate(double delta) {
// ....
}
}
class MajorScrollPosition extends MultiSliverCompatScrollPosition {
MajorScrollPosition(super.sliverCompat,
{required super.physics, required super.context, super.debugKey});
}
class MinorScrollPosition extends MultiSliverCompatScrollPosition {
MinorScrollPosition(super.sliverCompat,
{required super.physics, required super.context, super.debugKey});
}
注意这里的applyUserOffset
方法,它就是ScrollPosition的入口函数,我们需要去处理它:即将滚动量交给SliverCompat这个组件,在sliverCompat中,根据滚动量这个矢量来做如下的判断:
// 接收child提交的滚动量
double submitUserOffset(
MultiSliverCompatScrollPosition? submitter, double delta) {
if (delta < 0) {
return onScrollToTop(submitter, delta);
} else {
return onScrollToBottom(submitter, delta);
}
}
即滚动量<0时,意味着向上滚动;滚动量>0时,意味着向下滚动。
这关系到MajorScrollController和MinorScrollController谁优先去消费滚动数据的问题,我们这样实现onScrollToTop和onScrollToBottom方法:
double onScrollToTop(
MultiSliverCompatScrollPosition? submitter, double delta) {
double remaining = delta;
/// 内部消化
remaining = _majorScrollPosition.applyClampedDragUpdate(remaining); // 1
print('($debugKey)ToTop major消耗剩余:$remaining');
if (remaining == 0) {
return 0;
}
remaining = submitter?.applyClampedDragUpdate(remaining) ?? remaining;
print('($debugKey)ToTop minor消耗剩余:$remaining,submitter:$submitter');
return remaining;
}
double onScrollToBottom(
MultiSliverCompatScrollPosition? submitter, double delta) {
double remaining = delta;
remaining = submitter?.applyClampedDragUpdate(remaining) ?? remaining; // 2
print('($debugKey)ToBottom major消耗剩余:$remaining');
if (remaining == 0) {
return 0;
}
remaining = _majorScrollPosition.applyClampedDragUpdate(remaining);
print('($debugKey)ToBottom minor消耗剩余:$remaining,submitter:$submitter');
return remaining;
}
我们可以看到1和2两处的调用顺序。
最后就是对于applyClampedDragUpdate
方法的处理了,这个方法的一句话描述其实就是:
- 接收滚动量、消费滚动量,返回未消费的滚动量。
我们可以参考既有组件的实现,比如我们前面提到的NestedScrollView也有一个_NestedScrollPosition
,我们全局搜索一下就可以找到这个方法:
// Returns the amount of delta that was not used.
//
// Positive delta means going down (exposing stuff above), negative delta
// going up (exposing stuff below).
double applyClampedDragUpdate(double delta) {
// ...
代码部分搬过来即可。
至此,就实现了一个一对二甚至是一对多的多子Widget嵌套滑动组件。
转载自:https://juejin.cn/post/7386848746913742888