likes
comments
collection
share

[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver

作者站长头像
站长
· 阅读数 17

Flutter使用sliver来表示可滚动区域的一个个的child,sliver所遵循的并非是传统Widget的BoxConstraint布局约束,而是采用一种基于sliver协议的布局约束,这个协议只能在CustomScrollView中使用,最大的一个特点就是它只有主滚动方向上一维的Extent概念,而没有BoxConstraint的Width和Height的概念。

当然,Widget最终渲染时还是要转换成BoxConstraint的,因此sliver->BoxConstraint的转换也是广泛存在的。

得益于sliver协议,CustomScrollView支持多种多样的滚动布局,例如:

  1. SliverPersistentHeader,就支持滚动时将一个SliverWidget Pin在顶部;
  2. SliverFillRemaining,支持一个Sliver占满当前Viewport的所有剩余空间;
  3. 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并不会收缩或者被推走:

[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver

原因很简单,这里有两个可滚动单元:CustomScrollView和ListView,它们的滚动事件是独立的:

  • 如果手指在SliverPersistentHeader上滚动,这种情况下滚动事件其实是CustomScrollView消费的;
  • 如果手指放在ListView上滚动,这种情况下滚动事件则变成了ListView去消费。

因此,同属于CustomScrollView的两个sliver组件表现出了完全不同的滑动响应。

分析下来,这种情况是合理的,但是产品和设计并不会听我们的解释。

二、封装好的替代者:NestedScrollView

上面提到的问题的核心点,用一句话概括总结,其实是:CustomScrollView和ListView之间的滚动是独立的,它们并没有结合起来,最终呈现出来的效果就是各滚各的,解决问题的思路也很简单,把二者结合起来即可,我们需要实现:

滑动事件产生滚动量,滚动量统一管理,如果手指向上滑动,视图整体向上,那么事件显然应该先交给CustomScrollView去消费;反之应该先交给ListView去消费。

官方其实已经提供了一个组件能够帮我们实现这个效果:NestedScrollView,即可以支持嵌套滑动的ScrollView,它需要声明两个部分:

  1. Header
  2. Body

其中的Header部分,是一个builder方法,需要返回一个sliver数组;而Body部分,需要返回一个Widget,按照一中的需求,我们可以拆分成这样的结构:

NestedScrollView{
    header:[
        SliverPersistentHeader() 
    ]
    body:ListView() 
}

我们会发现它可以正常地工作,很好地去兼容ListView和NestedScrollView二者的滚动了,即使你手指在ListView上滚动,事件也会优先交给CustomScrollView去消费:

[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver

大致上看一下它的实现,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来说,就已经无能为力了:

[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver

三、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() 
)

这样一来你会发现ListView1ListView2之间的滚动是独立了,但是ListView1上的滚动却无法和外层的CustomScrollView的滚动进行嵌套滑动了,ListView2的嵌套滚动仍然是正常的。因此这条路虽然简单,但是仍然有瑕疵。

不过:

[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver

四、ScorllController:为什么ListView可以被滚动

先把三中的问题放一放,ListView本身会规定一个Viewport,在Viewport内部的children一般会长于Viewport本身,而ListView在收到手指滚动的事件之后,只做了一件事:调整children部分的偏移量:

[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver 如上图,左侧的初始状态下,Viewport内的children顶部和Viewport顶部对齐,它的children元素的偏移量是0。

如果手指产生了300的偏移量,那么此时children就应该整体被「上移」300个像素。

我们知道,在Flutter的布局方式下,通用的视图刷新方式其实是一套在State中setState来重新生成新的Children Widget然后触发Element中的Widget更新方法,比较新旧Element的数据是否一致,然后更新RenderObject的过程。

如果参考这个过程,那么我们对于ListView的滚动可以做出如下的猜测:

  1. ListView内部的实现组件:ScorllState会设置手势监听;
  2. 监听到事件之后,计算偏移量;
  3. 然后通过setState((){...})方法去更新偏移量字段,然后触发Viewport的重新渲染,来更新RenderObject。

但这是错误的。

首先如果是通过setState实现的偏移量变更,那么势必会导致对于的State的build方法调用、新的children实例(Widget)被创建。但在这里它并没有导致仍然可见的children发生新的实例创建,这个可以通过比较简单的实验测试一下。

其次,如果滚动是通过setState实现的,那么在一次滚动中势必会触发数十、上百次的setState,虽然Widget的成本相对来说较低,但也不是这样去浪费的,显然也不太可能。

所以,对于ListView的viewport内容变更来说,它和传统的setState完全不一样,它的流程可以概括成:

  1. ListView内部的实现组件:ScorllState会设置手势监听;
  2. 监听到事件之后,计算偏移量;
  3. 然后直接notify到RenderObject层、Viewport对于的RenderObject元素,读取新的偏移量数值来完成视图偏移量的变更。

最明显的一个区别点就在于RenderViewport的更新会被监听到的滚动事件直接触发,而跳过setState的过程,你可以验证一下相关的调用:

[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver

markNeedsPaint的调用源头,完全来自于ScorllPosition的setPixels方法,对数值更新后,对它的观察者的调用,观察调用栈我们可以发现,其实滚动过程中产生了动画,动画的触发器将修改ScrollPosition,然后将修改后的结果通知RenderObject,来标记为需要重新绘制(markNeedsPaint)。

从这里我们知道了ListView实际上是怎么『动起来』的,其背后的ScrollController会通过监听获得新的手指滑动量delta,然后更新ScrollPosition相关的参数,最终直接去将ListView的Viewport对于的RenderObject层元素:_RenderSingleChildViewport做一个更新。

至此,如果我们希望统一去分发滚动量,就有了一个抓手,那就是ScrollPosition。

五、ScrollPosition:描述滚动信息的对象

ScrollController本身维护了一个ScrollPosition来描述Viewport中,children的滚动信息,它主要做两件事:

  1. 修改pixel,通知更新Viewport。

ScrollPosition甚至就是一个ViewportOffset的子类,ChangeNotifier的简介子类,它天生就有notify他人的能力。

  1. 接受来自用户的事件。

ListVIew对于的ScrollPosition的实现类是:ScrollPositionWithSingleContext(extends ScrollPosition、implements ScrollActivityDelegate) ,它有一个方法:ScrollActivityDelegate#applyUserOffset,这个方法就是接受用户滚动量的核心。

NestedScrollView的做法大致如下:

  1. 通过一个结构_NestedScrollCoordinator)来统一管理两个ScrollController,分别是CustomScrollView自带的ScrollController以及PrimaryScrollController对于的滚动控制器;

  2. 通过重写ScrollPosition,得到NestedScrollPosition接收滚动量,然后将所有的滚动量优先交给( _NestedScrollCoordinator),无论是CustomScrollView还是PrimaryScrollController。

  3. _NestedScrollCoordinator根据滚动量的方向(正负号代表方向)来判断这个事件优先给谁消费:

    1. 如果是手指向上的滑动事件,应该会优先交给CustomScrollView去展示(因为多用于SliverPersistentHeader的收起),如果产生了盈余的滚动量(OverScroll),那么再交给child去消费;
    2. 反之,则应该优先交给子Widget去展示,多用于PrimaryScrollController的快速向上,直到PrimaryScrollController的内容到达顶部之后,如果产生了盈余的滚动量,才应该交给CustomScrollView去执行,比如SliverPersistentHeader内容展开。

不过上述的两种情况并不是一定的,要结合具体场景去做具体的分析,这里只是给一个比较形象的例子。

我们只需要将CustomScrollView的ScrollController和子Widget的ScrollController的一对一关系改成一对二、甚至是一对多就可以兼容三中这种双侧列表滚动的场景,我们需要自定义ScrollController(核心是为了重写getScrollPosition来获得一个自定义的ScrollPosition),于是修改相关代码,我们可以得到:

[Flutter] NestedScrollView与嵌套滚动、多子Widget的嵌套滚动Flutter使用sliver

六、实现

综上,我们要实现这个需求,我们要做如下的改造,首先我们要有一个结构来管理主ScrollController和子ScrollController(可能有多个),在这里我们叫它SliverCompat,它主要负责几件事情:

  1. 接受所有来自于子Widget的滑动量;
  2. 决定滑动量分配给哪个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时,意味着向下滚动。

这关系到MajorScrollControllerMinorScrollController谁优先去消费滚动数据的问题,我们这样实现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
评论
请登录