[Flutter] 多层级嵌套滚动

389 阅读9分钟

[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去处理,交给谁呢?

我们可以简单地画这个图:

image.png

其中黄色部分的就是三级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去消费,这样我们就可以得到这样的一条完整的传递路线,沿着下图的绿色结点自底向上地传递:

image.png

其中有几个节点是可以处理滚动事件的,分别是:

ListView3自己、CustomScrollView3、CustomScrollView2和最顶层的CustomScrollView1。

不难看出我们要控制着四者的协调滚动,上一版本的SliverCompat仅仅区分了主、副,无法很好地实现这个需求,对应的四个节点都需要:

1、自己可滚动

2、可向上提交滚动量

3、可向下返回剩余的滚动量

备注:

  • 如果是Reverse方向滚动,那么这个滚动量就应该是ListView3先消费,然后盈余的滚动量再给父布局消费了。
  • 这个方向和消费顺序不光是嵌套滚动的消费顺序,还是后续惯性动画的消费顺序,如果你要实现多层级的惯性动画消费那么也必须考虑这个顺序问题。

三、实现

可接受滚动事件的结点它们各自都应该成为一个SliverCompat的处理结点,为了和上一篇文章中的普通的SliverCompat区分,我们叫他ScrollViewExCoordinator

image.png

whiteboard_exported_image-4.png 对于ListView3,它会和一个ScrollViewExCoordinator做绑定,ScrollViewExCoordinator提供一个ScrollViewExController的实例,交给ListView,用于给ListViewcontroller字段赋值。 ListView的滚动就完全交给ScrollViewExCoordinatorScrollViewExControllerScrollViewExPosition来处理。

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;
}

以两个注释为例,代码被分为了三个部分:

  1. 向外传递,ListView3会先把滚动量向上传递,如果父布局的Coordinator不为空则表示是一个有效的滚动结点,因此将滚动量直接交给CustomScrollView3进行消费;

  2. CustomScrollView3递归地重复这个过程,直到某个节点的父布局Coordinator为空,此时认为它是一个顶层节点。顶层节点意味着递归的「回归过程」开始,顶层节点尝试调用applyClampedDragUpdate去消费滚动量。

  3. 剩余的滚动量会逐渐回归到子组件,或者滚动量在中途被完全消耗。

四、总结

和NestedScrollView使用NestedScrollCoordinator统一管理所有的ScrollController、ScrollPosition不一样,我们在这里实现的ScrollViewExCoordinator则是将需要管理的结点单独成一个结点,然后依赖BuildContext Tree来处理它的上下级关系,这样做的好处很明显就是可以更加自由地在多个不同层级的Widget树中嵌套滚动量,缺点在于复杂视图结构可能难以直接维护层级关系。