[Flutter] 多层级嵌套滚动
一、单级嵌套滚动
在[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver - 掘金中,我们介绍另一种SliverCompat的实现方式,以实现一对多场景下的嵌套滚动,其核心逻辑是在父组件中声明一个SliverCompat对象,然后通过给外层的CustomScrollController提供一个majorScrollController,给内部嵌套的ListView提供一个minorScrollController来区分内层、外层的ScrollController。
并且在各自ScrollController接收到滚动事件之后,优先递交给SliverCompat对象进行处理,根据滚动的方向来判断将滚动量交给谁去消费,比如初始状态手指向下滑动,此时滑动量应先给CustomScrollView决定要不要消费,剩余的滚动量才交给子Widget消费,这样就能实现滑动量从CustomScrollView到ListView的丝滑过渡:
这种实现能够满足一般的使用场景:即单级嵌套(例如CustomScrollView + 同一层级的ListView)滚动的场景,如果此时有多个CustomScrollView嵌套,这种实现就无法满足如下的场景了:
理想情况下,它滚动起来应该是这样的,TabBar下的所有内容均支持按层级嵌套的多级嵌套滚动:
1、其中的最上层的AppBar这里特殊处理过了,不参与嵌套滚动。
2、遇到这东西建议先去和产品与UED Battle一下能不能换种实现方式,这篇文章仅是对上一篇文章的实现优化。
二、问题分析
其实原理我们大致上已经知道了,要有一个统一的结构去处理滚动量
,而现有的内容结构大致如下:
这里的主要内容以TabView中的内容为主,一共是三个CustomScrollView为主体,然后:
-
第一层CustomScrollView对应的左侧有两个ListView,而右侧是第二层CustomScrollView;
-
第二层CustomScrollView左侧是一个FittedBox + Text组件,右侧是第三CustomScrollView;
-
第三层CustomScrollView简单嵌套了一个ListView;
如果单纯地去按照Major、Minor去设置ScrollController,那么这里会有非常复杂的嵌套关系,比如第二层CustomScrollView既是第一层CustomScrollView的MinorScrollController又是第三层的MajorScrollController,嵌套层级一多就近乎无法处理。
如果跳脱出这种单纯的主、副
思维来看,嵌套滚动量在Widget Tree上的传递其实根本的是两个方向:
- 沿着Widget Tree向上层提交(向Root节点提交);
- 沿着Widget Tree向下层返回(向Leaf结点返回);
这就构成了很简单的树上的路径搜索算法,不过我们需要基于Flutter提供的结构树去处理我们的嵌套滚动量,我们要做的就是搜集所有可滚动组件(ListView、CustomScrollView等等)的滚动量,然后交给统一的结构:SliverCompat去处理,交给谁呢?
我们可以简单地画这个图:
其中黄色部分的就是三级CustomScrollView的嵌套,而红色部分则是ListView,无论是黄色还是红色,这两种结点都是可以独立接收滑动事件的,换句话说,我们就是要去控制它们的滚动事件的统一处理。
我明明手指按在AppBar上可以滑动啊,为什么AppBar不算在其中?
其实手指在AppBar上滚动的时候,也是CustomScrollView接收的滚动事件,然后CustomScrollView来处理SliverAppBar的一些尺寸变化,这也正是CustomScrollView和ListView不完全兼容的原因,它们的ScrollController是隔离的,换言之NestedScrollView中只有外层的CustomScrollView和内层的PrimaryScrollController#child是可以滚动的,header部分的sliver组件一般都是不直接滚动的 。
我们按照ScrollDirection的规则,规定两个滚动方向:forward和reverse,其中forward方向表示列表在初始状态下,手指向上拖动时,列表整体向下滚动的操作;reverse则相反,表示列表向上滚动的操作。
如果此时进行forward操作,手指在ListView3上滚动,ListView3在收到滚动事件时,不应该由自己去消费,而是应该交给CustomScrollView3去消费。但是CustomScrollView3其实又是CustomScrollView2的子Widget,因此CustomScrollView3需要将滚动量提交给CustomScrollView2,让它决定是否去消费,直到CustomScrollView1中。它对于这一套滚动系统来说是一个隔离的根节点,因此而不需要再向上传递了,换句话说我们的滚动应该先交给顶层的CustomScrollView去消费,这样我们就可以得到这样的一条完整的传递路线,沿着下图的绿色结点自底向上地传递:
其中有几个节点是可以处理滚动事件的,分别是:
ListView3自己、CustomScrollView3、CustomScrollView2和最顶层的CustomScrollView1。
不难看出我们要控制着四者的协调滚动,上一版本的SliverCompat仅仅区分了主、副,无法很好地实现这个需求,对应的四个节点都需要:
1、自己可滚动
2、可向上提交滚动量
3、可向下返回剩余的滚动量
备注:
- 如果是Reverse方向滚动,那么这个滚动量就应该是ListView3先消费,然后盈余的滚动量再给父布局消费了。
- 这个方向和消费顺序不光是嵌套滚动的消费顺序,还是后续惯性动画的消费顺序,如果你要实现多层级的惯性动画消费那么也必须考虑这个顺序问题。
三、实现
可接受滚动事件的结点它们各自都应该成为一个SliverCompat的处理结点,为了和上一篇文章中的普通的SliverCompat区分,我们叫他ScrollViewExCoordinator
。
对于ListView3,它会和一个
ScrollViewExCoordinator
做绑定,ScrollViewExCoordinator
提供一个ScrollViewExController
的实例,交给ListView
,用于给ListView
的controller
字段赋值。
ListView的滚动就完全交给ScrollViewExCoordinator
、ScrollViewExController
和ScrollViewExPosition
来处理。
3.1 接收滚动量
如何接受滚动量在上文我们已经提过了:需要自己去实现我们的ScrollController,然后重写插件ScrollPosition的方法:
class ScrollViewExController extends ScrollController {
final Key? key;
ScrollViewExController(this.key);
late ScrollViewExCoordinator? _coordinator;
ScrollViewExCoordinator get coordinator => _coordinator!;
attachToCoordinator(ScrollViewExCoordinator coordinator) {
_coordinator = coordinator;
}
detachFromCoordinator() {
_coordinator = null;
}
@override
ScrollPosition createScrollPosition(ScrollPhysics physics,
ScrollContext context, ScrollPosition? oldPosition) {
return ScrollViewExPosition(
physics: physics, context: context, coordinator: coordinator);
}
@override
void dispose() {
super.dispose();
_coordinator?.dispose();
detachFromCoordinator();
}
}
而ScrollViewExPosition
的整体实现:
class ScrollViewExPosition extends ScrollPositionWithSingleContext {
ScrollViewExPosition(
{required super.physics,
required super.context,
required this.coordinator});
@override
String toString() {
return "${super.toString()},<key:[${coordinator.key}]>";
}
ScrollViewExCoordinator coordinator;
bool get shouldIgnorePointer => activity?.shouldIgnorePointer ?? false;
@override
void applyUserOffset(double delta) {
double fingerOverscroll = coordinator.applyUserFingerScrolling(delta);
}
double applyClampedDragUpdate(double delta) {
assert(delta != 0.0);
final double minValue =
delta < 0.0 ? -double.infinity : min(minScrollExtent, pixels);
final double maxValue = delta > 0.0
? double.infinity
: pixels < 0.0
? 0.0
: max(maxScrollExtent, pixels);
final double oldPixels = pixels;
final double newPixels = clampDouble(pixels - delta, minValue, maxValue);
final double clampedDelta = newPixels - pixels;
if (clampedDelta == 0.0) {
return delta;
}
final double overscroll = physics.applyBoundaryConditions(this, newPixels);
final double actualNewPixels = newPixels - overscroll;
final double offset = actualNewPixels - oldPixels;
if (offset != 0.0) {
forcePixels(actualNewPixels);
didUpdateScrollPositionBy(offset);
}
return delta + offset;
}
ScrollActivity? get currentScrollingActivity => activity;
}
其中的applyUserOffset
方法,用于直接从Drag事件处接收滚动量,如果你要统筹管理滚动量,那么肯定要在applyUserOffset处进行上报Coordinator进行处理。因此,重中之重就是Coordinator的实现了:
//// 可滚动结点
class ScrollViewExCoordinator {
///// FIELDS or GETTER /////
late ScrollViewExController _currentNodeScrollController;
final BuildContext context;
final Key? key;
ScrollViewExCoordinator(this.context, this.key);
ScrollViewExController get currentScrollController =>
_currentNodeScrollController;
ScrollViewExPosition get _currentPosition =>
currentScrollController.position as ScrollViewExPosition;
ScrollViewExCoordinator? get parentCoordinator =>
ScrollViewExWidgetBuilderState.getCoordinator(context);
///// BUILD /////
/// 创建对应的ScrollController
ScrollViewExController createScrollViewExController(Key? key) {
_currentNodeScrollController = ScrollViewExController(key);
_currentNodeScrollController.attachToCoordinator(this);
return _currentNodeScrollController;
}
void dispose() {
_currentNodeScrollController.detachFromCoordinator();
onFingerOverScrollingListener = null;
}
///// FINGER SCROLLING /////
/// 应用用户的滚动量,返回盈余滚动量
double applyUserFingerScrolling(double delta) {
if (delta < 0) {
// pass_to_top
double? overscroll;
if (parentCoordinator == null) {
overscroll = delta;
} else {
overscroll = parentCoordinator?.applyUserFingerScrolling(delta);
}
if (overscroll == 0) {
return 0;
}
// consume by self
double remaining = _currentPosition.applyClampedDragUpdate(overscroll!);
return remaining;
} else {
if (delta < precisionErrorTolerance) {
return 0;
}
// consume by self
double overscroll = _currentPosition.applyClampedDragUpdate(delta);
if (overscroll < precisionErrorTolerance) {
return 0;
}
// pass_to_top
double? remaining = ScrollViewExWidgetBuilderState.getCoordinator(context)
?.applyUserFingerScrolling(overscroll);
return remaining ?? overscroll;
}
}
}
重点可以看applyUserFingerScrolling,外层if的两个分支分别代表了两个方向,由于forward和reverse滚动方向不同,所以消费事件的顺序也不同,所以要分开处理。 这里通过类似InheritedWidget向上查找的思路,直接传递滚动量,然后将消费完的剩余滚动量逐层返回,以实现嵌套滚动
3.2 传递滚动量
每个可滚动节点都应该是一个ScrollViewExCoordinator,因此我们需要一个代理Widget来完成这个构建SliverViewExCoordinator的工作:
当然,你在每个地方手动声明也不是不可以,就是会很麻烦
typedef ScrollViewExBuilder = Function(
ScrollViewExController scrollViewExController);
class ScrollViewExWidgetBuilder extends StatefulWidget {
final ScrollViewExBuilder builder;
const ScrollViewExWidgetBuilder(
{required this.builder, super.key});
@override
State<ScrollViewExWidgetBuilder> createState() =>
ScrollViewExWidgetBuilderState();
}
class ScrollViewExWidgetBuilderState extends State<ScrollViewExWidgetBuilder> {
late ScrollViewExCoordinator _coordinator;
@override
void initState() {
_coordinator = ScrollViewExCoordinator(context, widget.key);
super.initState();
}
static ScrollViewExCoordinator? getCoordinator(BuildContext context) {
return context
.findAncestorStateOfType<ScrollViewExWidgetBuilderState>()
?._coordinator;
}
@override
Widget build(BuildContext context) {
return widget
.builder(_coordinator.createScrollViewExController(widget.key));
}
}
使用时:
ScrollViewExWidgetBuilder(builder:
(controller) {
return ListView(
controller:controller
...
)
})
这样就将一个ListView和一个ScrollViewExWidgetBuilder提供的ScrollViewExCoordinator/ScrollViewExController连接起来了,如果我们需要在组件间传递滚动量只需要在当前Coordinator节点对应的BuildContext
即可。
以ListView3为例,它拿到的coordinaotr就会是CustomScrollView3对应的ScrollViewExCoordinator,这样ListView3就可以调用它的方法向CustomScrollView3提交滚动量了,CustomScrollView3也需要走一样的流程,先向上查找更上层的ScrollViewExCoordinator的实例,如果找不到,说明自己就已经是根节点了,可以尝试着去消费对应的滚动量;如果找得到那么就继续向上传递,以初始情况列表展开为例,手指向上滑动时,代码如下:
if (delta < 0) {
// pass_to_top
double? overscroll;
if (parentCoordinator == null) {
overscroll = delta;
} else {
overscroll = parentCoordinator?.applyUserFingerScrolling(delta);
}
if (overscroll == 0) {
return 0;
}
// consume by self
double remaining = _currentPosition.applyClampedDragUpdate(overscroll!);
return remaining;
}
以两个注释为例,代码被分为了三个部分:
-
向外传递,ListView3会先把滚动量向上传递,如果父布局的Coordinator不为空则表示是一个有效的滚动结点,因此将滚动量直接交给CustomScrollView3进行消费;
-
CustomScrollView3
递归地重复这个过程,直到某个节点的父布局Coordinator为空,此时认为它是一个顶层节点。顶层节点意味着递归的「回归过程」开始,顶层节点尝试调用applyClampedDragUpdate
去消费滚动量。 -
剩余的滚动量会逐渐回归到子组件,或者滚动量在中途被完全消耗。
四、总结
和NestedScrollView使用NestedScrollCoordinator统一管理所有的ScrollController、ScrollPosition不一样,我们在这里实现的ScrollViewExCoordinator则是将需要管理的结点单独成一个结点,然后依赖BuildContext Tree来处理它的上下级关系,这样做的好处很明显就是可以更加自由地在多个不同层级的Widget树中嵌套滚动量,缺点在于复杂视图结构可能难以直接维护层级关系。