likes
comments
collection
share

[Flutter] sliver: 从BoxConstraint到SliverConstraint

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

[Flutter]BoxConstraint与Sliver

Sliver,在Flutter基础组件中,算是一个「进阶型」的组件了,它被官方直接内置在组件库中,提供给开发者使用,但是看着又不如ListView那样「直接、易用」,初学的时候非常纠结于一件事情:

什么是Sliver。

Sliver直接翻译为「条」、「薄片」。

银是silver,别搞混了

何为薄片?在一个可滑动布局中,一个children item所占据的区域,就是一个「薄片」,多个sliver薄片共同构成了ScrollView的children数组,如下图:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

从上图中,我们可以看到三个Sliver:

  1. SliverPersistent Header;
  2. SliverPersistent Header2,它是被「钉」在顶部的,不会像第一个SliverPersistentHeader一样会被其它的元素推走,也就是我们常说的吸顶效果:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

  1. SliverList,一个Sliver的List,它有很多的子item,但是只有最外层的SliverList才是一个Sliver,其内部的item,最终受到的还是BoxConstraint:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

这样一来,Sliver的概念似乎就更清晰了一些,就是可滑动布局中的这些「条状物」,就是可滑动布局中的一个构成单元,也就是一个Widget。

一、从ListView说起

老样子,我们从我们比较熟悉的ListView说起,ListView继承自BoxScrollView,ScrollView,而ScrollView最终是一个StatelessWidget,显然,ScrollView作为一个(StatelessWidget)本身并不管理Render阶段的事情,因此通过阅读它的build方法,我们可以看到一个StatefulWidget,也就是Scrollable。

1.1 Scrollable

简单来看看Scrollable,阅读一个StatefulWidget的代码,阅读它对应的State显然是更重要的事情。ScrollableState,值得注意的是,Scrollable本身暴露了一个静态的of方法,方便我们在子Widget中获取到ScrollableState对象,有助于在子Widget中向上控制父组件的滑动:

ScrollableState scrollable = Scrollable.of(context)

我们可以借助ScrollableState实现一个点击后在列表上进行跳转的操作:

List<Widget> _build100Children() {
  List<Widget> result = [];
  for (int i = 0; i < 100; i++) {
    result.add(Builder(builder: (context) {
      return SizedBox(
        height: 100,
        child: TextButton(
            onPressed: () {
              ScrollableState? state = Scrollable.of(context);
              // 例如点击index = 11的列表项目,就滚动到第89个列表项
              state?.position.jumpTo((100 - i) * 100);
            },
            child: commonText(index: i)),
      );
    }));
  }
  return result;
}

1.2 ScrollView的继承树

ScrollView作为一个StatelessWidget,它便是可滑动布局的继承树上是一个相对来说的根节点:

StatelessWidget -> ScrollView -> BoxScrollView -> ListView
                                               -> GridView
 -> CustomScrollView -> _NestedScrollViewCustomScrollView

可以看到,ScrollView在派生子类的时候,产生了两个不同的分支:BoxScrollView和CustomScrollView,前者和我们常用的ListView、GridView相关;而后者,如果使用过Sliver相关工具的开发者,一定对CustomScrollView和NestedScrollView(_NestedScrollViewCustomScrollView)不陌生。

[Flutter] sliver: 从BoxConstraint到SliverConstraint

1.3 ScrollView#build

[Flutter] sliver: 从BoxConstraint到SliverConstraint

我们提到过Sliver就是可滚动布局中的children中的一个个item,所以它们肯定也是Widget。

其中有一个关键的方法:buildSlivers,它的实现是抽象的,需要被两个子类:BoxScrollView和CustomScrollView来实现。

  1. BoxScrollView与CustomScrollView实现对比

2.1 BoxScrollView

我们直接看二者对于buildSlivers的方法实现,就已经非常直观了,首先我们看看我们常用的ListView对应的BoxScrollView,它内部所做的事情,实际上是为我们在Padding不为空的情况下,包裹了一个SliverPadding。

[Flutter] sliver: 从BoxConstraint到SliverConstraint

这也是为什么,我们在一些异形屏幕上,ListView总会在顶部预留出一部分的Padding,特别是ListView不在顶部的时候(例如在Scaffold中,不使用Appbar,且上方有个Text的时候):

[Flutter] sliver: 从BoxConstraint到SliverConstraint

它就是我们通过MediaQuery.of(...).padding查询得到的一个屏幕的内边距,一般就是SafeArea隔开的高度。

但是,如果在Scaffold的外层使用了SafeArea以后,这个Padding一般会是EdgeInsets.ZERO,因为SafeArea会默认地将这个Padding移除掉,并在自己内部进行消化。

而Scaffold这个脚手架也会在一定的条件下为我们移除掉这个Padding,例如Appbar存在的情况下,这个Padding就不会被应用到body之上,而是被应用在顶部的Appbar上:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

也就是说,如果不使用SafeArea,我们也可以手动地用一个MediaQuery.removePadding包裹一下ListView,来移除这个Padding。

回到话题,返回的sliver对象,如果不是buildChildLayout的返回值,那就是SliverPadding套着一个buildChildLayout的返回值。

前者的实现会根据子类细分,例如ListView中:

 @override
Widget  buildChildLayout ( BuildContext context ) {
     if (itemExtent != null ) {
         return SliverFixedExtentList (
             delegate : childrenDelegate,
             itemExtent : itemExtent!,
        );
    } else  if (prototypeItem != null ) {
         return  SliverPrototypeExtentList (
             delegate : childrenDelegate,
             prototypeItem : prototypeItem!,
         );
    }
 return  SliverList(delegate : childrenDelegate);
} 

总结下来,ListView内部实际上也是用了Sliver的方法,只不过封装了一层,这些东西对上层的开发者是无感的,我们始终在操作Widget对象,处理Widget对象的约束。

但是作为一类通用、常用的方法,ListView限定了这些东西,在场景上必然有它的局限性。因此,作为可定制的ScrollView------CustomScrollView更加灵活。

2.2 CustomScrollView

而CustomScrollView的实现就简单的多:

/// The slivers to place inside the viewport.
final List<Widget> slivers;

@override
List<Widget> buildSlivers(BuildContext context) => slivers;

是的,我们需要自己去构建sliver,然后填入,但是,令人困惑的点就在于Sliver。

他并不支持普通的Widget,例如我们想在CustomScrollView中,填入一个Text,它就会直接抛出异常:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

直译下来就是:RenderViewport(CustomScrollView的Viewport)只允许RenderSliver,而不允许RenderParagraph。RenderSliver和RenderBox并不是一套布局协议(For example, a RenderSliver cannot be the child of a RenderBox because a RenderSliver does not understand the RenderBox layout protocol)

借鉴ListView的实现,在ListView中,children一定会被包裹在一个SliverFixedExtentListSliverPrototypeExtentList或者是SliverList当中,这就意味着ScrollView的children,必须是一个Sliver容器,如果我们要使用Text,就需要将一个Text包裹在一个Sliver容器中,比如:

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(child: commonText(index: 0),)
  ],
)

三、Sliver Layout Protocal

我们知道Flutter的「约束布局」的布局方式是:从根布局向下传递约束(Constraint);然后子组件向上返回尺寸(Size)。

但是,RenderSliver和RenderBox之间并不是一套布局协议, 所以会导致无法直接在CustomScrollView中使用BoxConstraint进行布局。

取而代之的,则是另外一对东西:SliverConstraints和SliverGeometry,我们可以在文章开头的例子中,打开Android Studio右侧的Flutter Inspector看到RenderObject中的这些配置信息:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

3.1 SliverConstraints 用于SliverLayout的不可变约束信息

给出了指定的视图,从当前视图(Sliver)出发,受到可滑动视图的所对应的Viewport受到的约束信息。

例如,一个为0的scrollOffset就表示当前的这一块Sliver的(leading边缘)在这个Viewport中是可见的。而并不意味着viewport自身具有0的滚动偏移。

leading,竖直滑动情况下一般是上边缘;左右滑动情况下一般是左边缘。

有如下的布局:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

以SliverToBoxAdapter为例,分别统计了它在全部可见/部分可见/全部不可见情况下的SliverConstraint约束数值:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

通过图片和数值的结合,我们就很可以更加清晰地理解这个scrollOffset的含义,也点明了SliverConstraint本身的定义:从当前(Sliver)所对应的Viewport受到的约束信息

其他的参数描述戳这:SliverConstraints class - rendering library - Dart API

3.2 SliverGeometry

官方的定义就是:描述一个RenderSliver所占据的空间。

但是一个Sliver可能会在不同的方面需要计算占据的空间,所以这里的返回值就不像Size一样只有width和height两个属性了,而且因为主轴存在的关系,Sliver的父布局,也就是ScrollView,通常只要处理主轴上的约束关系,副轴一般会直接将上层的约束直接传递给下层。因此,SliverGeometry相比较于Box布局模型的2维的Size多了亿些描述参数,但是都是描述同一个方向的:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

具体的参数说明戳这:SliverGeometry class - rendering library - Dart API

四、ListView中的Sliver

SliverFixedExtentList、SliverPrototypeExtentList和SliverList

总之,Flutter依靠Sliver来完成可滑动布局的创建,无论是常用的ListView,还是不常用的CustomScrollView,它们出自同一个源头:ScrollView。而ScrollView在操作的对象一定都是RenderSliver。

ListView在此基础上做了相应的封装,提供了一个builder参数,进行了数据源(Data) -> Item Widget的转换。得益于ListView在自身和ItemWidget之间提供的Sliver组件中间层,我们可以在ItemWidget中使用BoxConstraint来完成我们的列表项布局,ListView提供的三个Sliver中间层分别是:

  1. SliverFixedExtentList
  2. SliverPrototypeExtentList
  3. SliverList

它会根据如下的逻辑来选择具体的哪一种Sliver组件被使用:

@override
Widget buildChildLayout(BuildContext context) {
  if (itemExtent != null) {
    return SliverFixedExtentList ( 
      delegate: childrenDelegate,
      itemExtent: itemExtent!,
    );
  } else if (prototypeItem != null) {
 return SliverPrototypeExtentList ( 
      delegate: childrenDelegate,
      prototypeItem: prototypeItem!,
    );
  }
  return SliverList ( delegate : childrenDelegate); 
}

这里就涉及到两个参数:itemExtentprototypeItem了。

4.1 itemExtent和SliverFixedExtentList

前者表示item在主轴上的大小,默认就是高度,一但指定了item的高度其实能解决很多问题,比如说之前提到的ListView内Column中的Expanded无法确定高度的问题,在加上itemExtent参数就能解决:

Widget _buildItemExtentWidget() => ListView(
      itemExtent: 300,
      children: [
        Column(
          children: [
            Expanded(
              child: Container(
                color: ColorUtil.getRandomColor(allowTransparent: false),
                height: 100,
                child: TextButton(
                    onPressed: () {},
                    child: commonText(
                        title: "Fixed height Expanded Item in ListView")),
              ),
            )
          ],
        )
      ],
    );

他背后其实是SliverFixedExtentList的作用,它为所有的子item提供了相同的主轴高度。这和你直接在Column外面套一个SizedBox非常相似,也有相似的局限性,如果含有不确定高度的item,显然不应该使用这种方案。

4.2 prototypeItem与SliverPrototypeExtentList

prototype直译为原型,prototypeItem就是原型item,在提供原型item的时候,会强制所有的children和原型item具有一致的高度,显然,这个和itemExtent非常相似,但是前者需要我们在开发的时候就确定数值的大小,而后者可以稍微地将这个过程延迟一点,只提供一个Widget作为原型item,将它的尺寸设置为其他item的高度。

prototypeItem会在其他的sliver元素被布局之前进行布局,并且不会被展示出来绘制、也不会接受任何事件。

在原型item传入之后,会成为SliverPrototypeExtentList对应RenderObject:_RenderSliverPrototypeExtentList的一个属性:child,这个child在布局之后,会获得它自己的size属性,然后可以通过itemExtent获取它的尺寸属性。

显然,这个child就不能是sliver了,应该是一个普通的RenderBox(要不然没有size属性)。

 @override
double get itemExtent {
 assert (child != null && child!. hasSize );
 return constraints. axis == Axis . vertical ? child!. size . height : child!. size . width ;
} 

相比较第一种SliverFixedExtentList,局限性小了一些,你要是写成这样:

Widget _buildPrototypeListViewWidget() => ListView(
  prototypeItem: const SizedBox(
    height: 300,
  ),
  ......
)

那和SliverFixedExtentList差别就不大了,但是无论是SliverFixedExtentList还是SliverPrototypeExtentList,它们虽然能够给与子Widget一个主轴上的非infinity约束,但是都需要我们手动去指定。

为什么要特地提供这两种方法来构建ListView?

(以纵向滚动为例)首先这两种方法通常只针对高度确定的item有效,一般只有一些特定的列表、纵向的图片封面流等等场景时使用的。

其次,ListView自身会具有Lazy Loading,即懒加载的功能,它只会加载Viewport和Viewport之外和附近的一些不可见Item,很明显就是为了去提高性能。但是在高速滑动的场景中,由于距离Viewport比较『远』的item是懒加载的,因此ListView需要重新去渲染它们,并得到它们的高度,这样以确定滚动的偏移量效果。

例如此时展示的最后一个item的index = 4,Viewport以外,下方额外渲染了index=5的item。如果此时通过上面提到的ScrollableState获取控制器,直接滚动到index = 100的item,此时需要跨越95个ListView的Item,但是此时ListView并不知道这95个item的高度,它需要在很短的滚动时间内将它们计算出来,然后得到高度,最终得到一个滚动的偏移量 = 这95个item的高度之和。

显然,这种操作是有性能问题的,核心的点就在于,ListView被命令滑动到index = 100的item,但是它并不知道自己要滚动多少,而滚动的偏移量又由一个个的item所占据的高度所决定,这样一来,问题就变成了item的高度不确定,显然,SliverPrototypeExtentList和SliverFixedExtentList都在尝试去解决这个不确定高度的问题。

4.3 SliverList与SliverFillRemaining

既不传itemExtent,又不传prototypeItem,那么此时ListView给与子Widget,在主轴上的约束就会是0,infinity,如果Column受到这个约束限制,它的Expanded属性就会失效。它其实没有什么好说的,我们要着重说说上一篇中提到的另一个内容:SliverFillRemaining。

只要在把ListView换成CustomScrollView,并在Column外,套上一层:SliverFillRemaining,Column就可以神奇的获取到CustomScrollView的剩余高度约束了,这也让Column中的Expanded能够正常工作。

SliverFillRemaining就好像为CustomScrollView定制的Expanded:

CustomScrollView(
      slivers: [
        SliverFillRemaining(
          child: Column(
            children: [
              Expanded(
                child: Container(

从源码中,我们可以得到它的布局方式,其实就是通过拿到它所受到的SliverConstraints信息,计算得到一个剩余的尺寸,并且将这个剩余的尺寸作为一个强制约束施加给子Widget(这里会根据SliverFillRemaining#child是否可以滑动区分不同的情况 ,不同情况代码稍有不同

@override
void performLayout() {
  final SliverConstraints constraints = this.constraints;
  final double extent = constraints.remainingPaintExtent - math.min(constraints.overlap, 0.0);
  
  if (child != null)
  child!.layout(constraints.asBoxConstraints(
    minExtent: extent,
    maxExtent: extent,
  ));
  
 ......
}

比如在CustomScrollView的Sliver中,只有一个SliverFillRemaining的时候,参数的取值会是这样的:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

此时我们在SliverFillRemaining上方加一个高度为100的Widget,此时的参数就变成了这样:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

此时我们在SliverFillRemaining下方加一个高度为100的Widget,此时的参数并没有发生变化:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

显然,和Column + Expanded的组合还是有不同的,SliverFillRemaining会强制子Widget的高度大小为可以展示的剩余空间,如1中的剩余空间就是屏幕高度:932。

2中的剩余空间就是832,但是,要注意的是,如果此时发生了上滑,那么剩余空间就会从832慢慢又回到932,因为顶部的SliverToBoxAdapter被挤出去了,剩余的空间会慢慢变多;也就是说,SliverFillRemaining会让child的高度约束发生变化:

flutter: flutter_experiment_log: Column's constraints:BoxConstraints(w=430.0, h=832.0)
Reloaded 2 of 587 libraries in 189ms.
flutter: flutter_experiment_log: Column's constraints:BoxConstraints(w=430.0, h=833.0)
省略号省略号省略号
flutter: flutter_experiment_log: Column's constraints:BoxConstraints(w=430.0, h=927.0)
flutter: flutter_experiment_log: Column's constraints:BoxConstraints(w=430.0, h=932.0)

如果你在Text外部套一个FittedBox,并且把CustomScrollView的滚动方向改为横向:

SliverToBoxAdapter(
  ......
),
SliverFillRemaining(
  child: FittedBox(
    child: Text("HelloWorld"),
  )
),

那么此时会有一个不错的效果:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

而第二次在SliverFillRemaining下方添加的第二个高度为100的Widget并不会使得SliverFillRemaining内容的高度从832缩小为732,因为它并不影响CustomScrollView正在显示的剩余空间:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

所以,通过SliverFillRemaining获得的约束大小,会随着滚动状态的变化而改变。SliverFillRemaining给定的约束,其实是排除SliverFillRemaining leading方向元素之后,当前CustomScrollView对应的viewport剩余空间的约束。

假设此时CustomScrollView中,一共有两个Sliver组件,Sliver1和SliverFillRemaining:

  • 如果另一个Sliver 1排在CustomScrollView的slivers数组中,SliverFillRemaining项目之前,假设此时屏幕的高度是932,而该Sliver 1的高度也为932(恰好撑满屏幕),虽然一开始我们的SliverFillRemaining是不可见的,但是随着手指向上滑动,视图整体向下,Sliver 1的一部分被推出屏幕,假设被推出部分的高度为200(Sliver1任有732的部分是可见的),那么此时的SliverFillRemaining所能占满的viewport的剩余空间就是200,并且随着滑动的过程,这个viewport的剩余空间会从0逐渐变大,最大可以达到viewport的高度,这里的例子中就是932(屏幕高度,因为CustomScrollView受到的约束假定是直接使用的屏幕高度,没有其他约束的影响)。
  • 如果另一个Sliver 1排在CustomScrollView的slivers数组中,SliverFillRemaining项目之后,不会影响CustomScrollView当前viewport的剩余可展示空间;

当然,这个剩余空间的最大值其实是CustomScrollView受到的约束的最大值,如果你在CustomScrollView外面套一个SizedBox,高度为300,这样CustomScrollView受到的是一个高度为300的强制约束,这样一来,viewport的剩余空间最大也就只能为300,伴随着滑动,SliverFillRemaining的高度会在0~300之间变动。

显然,SliverFillRemaining和Expanded还是不同的,虽然他们解决的问题有一点相似:

  • SliverFillRemaining:占满除开SliverFillRemaining上方sliver后* ,当前Viewport的剩余空间;
  • Expanded:占满除开其他Widget后Column的剩余空间;

SliverFillRemaining会先占满剩余空间,这就意味着SliverFillRemaining下方的slivers们并没有机会在SliverFillRemaining占满剩余剩余空间之前就被显示出来。

SliverFillRemaining的特殊之处在于,CustomScrollView的Slivers们不一定会同时在viewport中可见,伴随着滚动,SliverFillRemaining上方(leading方向)的其他Sliver移出viewport,Viewport的剩余空间会频繁地发生变动。

4.4 SliverFillViewport

除此之外,还有个组件SliverFillViewport,它和FillRemaining的区别在于,它会在一开始就将Delegate中,child的boxConstraint设置为Viewport的尺寸,简单来说就是会独占一个Viewport尺寸区域,并且不会发生改变,如果Viewport是撑满整个屏幕的,那么对应的BoxConstraint的数值就是屏幕约束. (横向滑动)的过程中,FillViewPort的约束并不会改变,而一直是一开始设定的381.5和831.8,而FillRemaining则由于Leading方向(横向滑动就是左边)的可用空间不断增大而导致可占据的宽度也不断增大。

[Flutter] sliver: 从BoxConstraint到SliverConstraint

五、 总结

5.1 ListView与Sliver

那么为什么更为常见的ListView中,我们仍然是面向Widget开发,而不是面向Sliver开发呢?

首先,Slivers,仍然是Widget,但是它是一个很特殊的Widget,它被专用于描述ScrollView中的这种条状物,即一个item,它会构建RenderSliver来完成Sliver相关的布局。

ListView所解决的是只是超长子视图(例如很长或者很宽的Column/Row)的滚动问题,它的具名构造方法:ListView.builder,所解决的是一类更为广泛的问题:列表

即包装了一层SliverList(也可能是SliverFixedExtentList或者SliverPrototypeExtentList),并将所有的子Widget在SliverList中进行布局,因此,会形成如下的结构:

ListVIew
    -> SliverList
        -> Widget item1
        -> Widget item2
        -> Widget item3
        ......

所以ListView本身也是在操作SliverList,只不过它为我们屏蔽了Sliver相关的细节,我们可以直接聚焦于数据和视图之间的关系,并使用BoxConstraint在SliverList中进行布局。

ScrollView中并不支持BoxConstriants,但是具体到某个具体的Sliver中,都是BoxConstraints的。因此Sliver本身只是特定于ScrollView子Widget的产物。我们使用CustomScrollView的时候,Sliver通常被用来实现一些滑动特性相关的内容,而具体的内容实现,更多地还是在写BoxConstraints布局。

5.2 Constraint和SliverConstraints

前者是我们在Flutter中更为常用的一类布局协议,它通过向下传递约束:BoxConstraint,向上传递尺寸(Size)来完成布局。

后者则是特定在ScrollView中的一类布局,由于ScrollView通常只针对具体的scrollDirection进行滑动,所以通常来说我们只需要聚焦于滚动方向上的状态进行提供约束,它描述了当前的Sliver在ScrollView中所受到的约束状态,会频繁地发生变动。

Sliver约束传递完成后,会生成一个SliverGeometry,作为Size的对应产物,同样是对尺寸数据进行的描述,同样地,也只有主轴的尺寸数据,但是描述信息中会有更多维度。

Flutter的主要布局方式还是Constraint,即约束布局,你会发现,无论是CustomScrollView受到的约束,还是Sliver组件给与Child施加的约束,仍然都是BoxConstraint,只有在可滑动布局(ScrollView)存在时,BoxConstraint才会被SliverConstraint所取代,但是child在具体布局的时候,又不乏有SliverConstraints 转换为BoxConstraint的这一种操作,譬如:SliverToBoxAdapter中,对子Widget布局的处理:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

asBoxConstraints本身的处理,也就是根据SliverConstraint一些参数的定义,将一些细节给出,重新组成BoxConstraint:

[Flutter] sliver: 从BoxConstraint到SliverConstraint

所以,Sliver的布局本身是一个从BoxConstraint到SliverConstraint,然后又回到BoxConstraint的过程。

end~