Flutter 滚动布局:sliver模型

一、滚动布局

Flutter中可滚动布局基本都来自Sliver模型,原理和安卓传统UI的ListView、RecyclerView类似,滚动布局里面的每个子组件的样式往往是相同的,由于组件占用内存较大,所以在内存上我们可以缓存有限个组件,滚动布局时仅仅刷新组件的数据,来达到滚动布局存放无限个子组件的目标。另一方面,内容被渲染到了lazy widget里,也就是SliverList and SliverGrid (以及它们的变种 SliverFixedExtentListSliverAnimatedGrid)。这些组件确保只有列表中可见部分会被布局和渲染。ListViewGridView只是CustomScrollViewSliverListSliverGrid组合在一起,方便我们使用的一个封装组件而已。它们允许我们直接使用box widget。而CustomScrollView的slivers属性明确要求我们使用sliver widget。但是sliver widget也只是box widget的包裹,后者才是真正用于渲染的。按照列表内容的差异,我们可以将scrolling widget分为以下三类:

sliver widget是可滚动列表中的一部分,它是面向viewport进行布局的。

二、ViewPort

  • ViewPort 是一个显示窗口,它内部可包含多个 Sliver;
  • ViewPort 的宽高是确定的,它内部 Slivers 的宽高之和是可以大于自身的宽高的;
  • ViewPort 为了提高性能采用懒加载机制,它只会绘制可视区域内容 Widget。

ViewPort 有一些重要属性:

Dart 复制代码
class Viewport extends MultiChildRenderObjectWidget {
  /// 主轴方向
  final AxisDirection axisDirection;
  /// 纵轴方向
  final AxisDirection crossAxisDirection;
  /// center 决定 viewport 的 zero 基准线,也就是 viewport 从哪个地方开始绘制,默认是第一个 sliver
  /// center 必须是 viewport slivers 中的一员的 key
  final Key center;
  
/// 锚点,取值[0,1],和 zero 的相对位置,比如 0.5 代表 zero 被放到了 Viewport.height / 2 处
  final double anchor;
  /// 滚动的累计值,确切的说是 viewport 从什么地方开始显示
  final ViewportOffset offset;
  /// 缓存区域,也就是相对有头尾需要预加载的高度
  final double cacheExtent;
  /// children widget
  List<Widget> slivers;
  }

我们看到center是一个key,表示从哪个sliver开始绘制,绘制的起点是一条zero基准线。这条基准线相对于视口的位置叫anchor。视口相对于整个滚动列表的位置叫offset。如果子元素进入了视口上下cacheExtent的区域就会被预先加载。

以上图为例,center=sliver1,anchor=0.2,此时sliver的top=0.2 * viewport.height,所以前面刚好能展示sliver0,也就是浅蓝色区域会滚到整个列表的开始位置。再比如,center=sliver1,anchor=0.4,此时sliver0上面会空出一条sliver大小的空间。

三、SliverConstraints

和 Box 布局使用 BoxConstraints 作为约束类似,Sliver 布局采用 SliverConstraints 作为约束,但相对于 Box 要复杂的多,可以理解为 SliverConstraints 描述了 Viewport 和它内部的 Slivers 之间的布局信息:

Dart 复制代码
class SliverConstraints extends Constraints {
    //主轴方向
    AxisDirection? axisDirection;
    //Sliver 新数据沿主轴的哪个方向插入?枚举类型,正向或反向
    GrowthDirection? growthDirection;
    //用户滑动方向
    ScrollDirection? userScrollDirection;
    //当前Sliver理论上(可能会固定在顶部)已经滑出可视区域的总偏移
    double? scrollOffset;
    //当前Sliver之前的Sliver占据的总高度,因为列表是懒加载,如果不能预估时,该值为double.infinity
    double? precedingScrollExtent;
    //上一个 sliver 覆盖当前 sliver 的大小,通常在 sliver 是 pinned/floating
    //或者处于列表头尾时有效。
    double? overlap;
    //当前Sliver在Viewport中的最大可以绘制的区域。
    //绘制如果超过该区域会比较低效(因为不会显示)
    double? remainingPaintExtent;
    //纵轴的长度;如果列表滚动方向是垂直方向,则表示列表宽度。
    double? crossAxisExtent;
    //纵轴方向
    AxisDirection? crossAxisDirection;
    //Viewport在主轴方向的长度;如果列表滚动方向是垂直方向,则表示列表高度。
    double? viewportMainAxisExtent;
    //Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]
    double? cacheOrigin;
    //Viewport加载区域的长度,范围:
    //[viewportMainAxisExtent,viewportMainAxisExtent + Viewport.cacheExtent*2]
    double? remainingCacheExtent;
}
cacheOrigin

在Viewport 预渲染区域中的起点,位于[-Viewport.cacheExtent, 0]之间

overlap

上图中的 sliver1 会被 SliverAppBar(pinned = true)遮住,遮住的大小就是 overlap,此时 overlap 会一直大于 0,如果设置像 iOS bouncing 那样的滑动效果,那么当 list 滚动到顶部继续滑动的时候 overlap 会小于 0(此刻并没有东西遮盖 sliver1,而是 sliver1 的 top 和 viewport 的 top 有间距)。

remainingPaintExtent

当前 Sliver 在 Viewport 中的最大可以绘制的区域。当viewport对sliver布局的时候,会将remainingPaintExtent减去这个sliver的paintExtent后,作为传入下一个sliver的remainingPaintExtent。remainingPaintExtent的初始值是viewportMainAxisExtent。

由此,我们可以计算当前 sliver 距离顶部的距离:

//如果大于0,表示当前 sliver 距离顶部的高度为 topOffset。若已经到达顶部或超出顶部,则该值始终等于 0。此时超出的距离可参考 scrollOffset。
topOffset = viewportMainAxisExtent - remainingPaintExtent;

当前sliver无论实际需要绘制多大的区域,最终能绘制到viewport的大小,不会超过remainingPaintExtent:

// paintExtent 通常需要这样处理一下,避免超出 remainingPaintExtent
paintExtent = min(paintExtent, constraints.remainingPaintExtent);
scrollOffset

scrollOffset 属性表示滚动滑出Viewport边界的距离,类似于 web 的 scrollTop 属性。一般来说表示组件的上边界离开 viewport 顶部的长度,未到达顶部之前都是 0。

SliverGeometry

Viewport 通过 SliverConstraints 告知它内部的 sliver 自己的约束信息,比如还有多少空间可用、offset 等,那么Sliver 则通过 SliverGeometry 反馈给 Viewport 需要占用多少空间量。

Dart 复制代码
const SliverGeometry({
  //Sliver在主轴方向预估长度,大多数情况是固定值,用于计算sliverConstraints.scrollOffset
  this.scrollExtent = 0.0, 
  this.paintExtent = 0.0, // 可视区域中的绘制长度
  this.paintOrigin = 0.0, // 绘制的坐标原点,相对于自身布局位置
  //在 Viewport中占用的长度;如果列表滚动方向是垂直方向,则表示列表高度。
  //范围[0,paintExtent]
  double? layoutExtent, 
  this.maxPaintExtent = 0.0,//最大绘制长度
  this.maxScrollObstructionExtent = 0.0,
  double? hitTestExtent, // 点击测试的范围
  bool? visible,// 是否显示
  //是否会溢出Viewport,如果为true,Viewport便会裁剪
  this.hasVisualOverflow = false,
  //scrollExtent的修正值:layoutExtent变化后,为了防止sliver突然跳动(应用新的layoutExtent)
  //可以先进行修正。
  this.scrollOffsetCorrection,
  double? cacheExtent, // 在预渲染区域中占据的长度
}) 
paintOrigin

当该值小于 0 时,当前 sliver 的整体起始位置会向上偏移 paintOrigin.abs() 的长度。如果每次下拉 x 的长度,paintOrigin 也向上移动 x 的距离,则 sliver 相对静止,由此可实现 pinned 效果。

layoutExtent

布局时占用的高度,取值范围 [0, paintExtent]。即 layoutExtent 须小于等于 paintExtent (当前绘制的高度,一般是等于)。

当 layoutExtent 小于 paintExtent 时,则一部分高度会被下一个 sliver 顶上。

示例 2:

说明:蓝色块高度是 100,但是占据高度只有 30,导致红色块向上顶了 70的高度。紫色部分是下方的红色与上方的蓝色重叠的区域

visible

当 visible 为 false 时会影响子节点的显示。在示例中,只占据空间(占据高度 layoutExtent),而不显示界面。即不影响布局。

效果:

说明:中间 30 的高度为原蓝色块占据的空间

scrollExtent

可滚动的范围。一般来说,对于 ListView,在 sliver 上边界滚动到顶部之前 paintExtent 等于 layoutExtent 都等于 scrollExtent,到达顶部后慢慢变小,直到变为 0。而 scrollExtent 一直不变。

注:如果 layoutExtent 不慢慢变小,即保持不变并且大于 0,则在当前 sliver 滚动到顶部后还可以继续滚动 scrollExtent 的长度(除非 scrollExtent 也等于 0),然后再执行下一个 sliver 的滚动。

示例 3:

说明:由于 layoutExtent 与 scrollExtent 都一直不变,并且不等于 0。蓝色 sliver 向上滚动到 Viewport 顶部后,还可以继续滚动 100 的高度,当这 100 也滚完了,下一个 sliver 才开始滚动。

Sliver 布局过程

RenderViewport 在 layout 它内部的 slivers 的过程如下:

这个 layout 过程是一个自上而下的线性过程:

  • 给 sliver1 输入 SliverConstrains1 并且得到输出结果(SliverGeometry1) ,
  • 根据 SliverGeometry1 重新生成一个新的 SliverConstrains2 输入给 sliver2 得到 SliverGeometry2
  • ...
  • 直至最后一个 sliver 具体的过程可以查看 RenderViewport 的 layoutChildSequence 方法。

Slivers

Flutter 提供了很多的 Sliver 组件,下面我们主要说一下它们的作用是什么:

SliverAppBar

类似于 android 中 CollapsingToolbarLayout,可以根据滑动做伸缩布局,并提供了 actions,bottom 等提高效率的属性。

SliverList / SliverGrid

用法和 ListView / GridView 基本一致。 此外,ListView = SliverList + Scrollable,也就是说 SliverList 不具备处理滑动事件的能力,所以它必须配合 CustomScrollView 来使用。

SliverFixedExtentList

它比 SliverList 多了修饰词 FixedExtent,意思是它的 item 在主轴方向上具有固定的高度/宽度。

设计它的原因是在 item 高度/宽度全都一样的场景下使用,它的效率比 SliverList 高,因为它不用通过 item 的 layout 过程就可以知道每个 item 的范围。

在使用的时候必须传入 itemExtent:

Dart 复制代码
SliverFixedExtentList(
  itemExtent: 50.0,
  delegate: SliverChildBuilderDelegate(
	...
        );
    },
  ),
)
SliverPersistentHeader

SliverPersistentHeader 是一个可以固定/悬浮的 header,它可以设置在列表的任意位置,显示的内容需要设置 SliverPersistentHeaderDelegate。

Dart 复制代码
SliverPersistentHeader(
          pinned: true,
          delegate: ...,
)

SliverPersistentHeaderDelegate 是一个抽象类,我们需要自己实现它,它的实现很简单,只有四个必须要实现的成员:

Dart 复制代码
class CustomDelegate extends SliverPersistentHeaderDelegate {
  /// 最大高度
  @override
  double get maxExtent => 100;
  /// 最小高度
  @override
  double get minExtent => 50;

  /// shrinkOffset: 当前 sliver 顶部越过屏幕顶部的距离
  /// overlapsContent: 下方是否还有 content 显示
  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
        return your widget
    );
  }
  /// 是否需要刷新
  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return maxExtent != oldDelegate.maxExtent ||
        minExtent != oldDelegate.minExtent;
  }
}

在实际运用中沉浸式的设计是很常见的,使用 SliverPersistentHeaderDelegate 可以轻松的实现沉浸式的效果:

它的实现原理就是根据 shrinkOffset 动态调整状态栏的样式和标题栏的颜色,实现代码见下面的 沉浸式 Header。

SliverToBoxAdapter

将 BoxWidget 转变为 Sliver:由于 CustomScrollView 只能接受 Sliver 类型的 child,所以很多常用的 Widget 无法直接添加到 CustomScrollView 中,此时只需要将 Widget 用 SliverToBoxAdapter 包裹一下就可以了。 最常见的使用就是 SliverList 不支持横向模式,但是又无法直接将 ListView 直接添加到 CustomScrollView 中,此时用 SliverToBoxAdapter 包裹一下:

Dart 复制代码
CustomScrollView(
      slivers: <Widget>[
        SliverToBoxAdapter(
          child: _buildHorizonScrollView(),
        ),
      ],
    ));

 Widget _buildHorizonScrollView() {
    return Container(
      height: 50,
      child: ListView.builder(
          scrollDirection: Axis.horizontal,
          primary: false,
          shrinkWrap: true,
          itemCount: 15,
          itemBuilder: (context, index) {
            return Container(
              color: ColorUtils.randomColor(),
              width: 50,
              height: 50,
            );
          }),
    );
  } 
SliverPadding

可以用在 CustomScrollView 中的 Padding。 需要注意的是不要用它来包裹 SliverPersistentHeader ,因为它会使 SliverPersistentHeader 的 pinned 失效,如果 SliverPersistentHeader 非要使用 Padding 效果,可以在 delegate 内部使用 Padding。

  • wrong code:
Dart 复制代码
SliverPadding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            sliver: SliverPersistentHeader(
              pinned: true,
              floating: false,
              delegate: Delegate(),
            ),
          )
  • correct code:
Dart 复制代码
class Delegate extends SliverPersistentHeaderDelegate {
  @override
  Widget build(
          BuildContext context, double shrinkOffset, bool overlapsContent) =>
      Padding(
        padding: EdgeInsets.symmetric(horizontal: 16),
        child: Container(
          color: Colors.yellow,
        ),
      );
  ...
}
SliverSafeArea

用法和 SafeArea 一致。

SliverFillRemaining

可以填充屏幕剩余控件的 Sliver。

参考文献:

Flutter - 循序渐进 Sliver - 掘金
flutter ------ 布局原理与约束 (Sliver 布局)

相关推荐
sunly_4 小时前
Flutter:父组件,向子组件传值,子组件向二级页面传值
flutter
爱学习的绿叶7 小时前
flutter TabBarView 动态添加删除页面
flutter
趴菜小玩家9 小时前
使用 Gradle 插件优化 Flutter Android 插件开发中的 Flutter 依赖缺失问题
android·flutter·gradle
jhonjson1 天前
Flutter开发之flutter_local_notifications
flutter·macos·cocoa
iFlyCai1 天前
23种设计模式的Flutter实现第一篇创建型模式(一)
flutter·设计模式·dart
恋猫de小郭1 天前
Flutter 小技巧之 OverlayPortal 实现自限性和可共享的页面图层
flutter
A_cot2 天前
Vue.js:构建现代 Web 应用的强大框架
前端·javascript·vue.js·flutter·html·web·js
B.-2 天前
在 Flutter 应用中调用后端接口的方法
android·flutter·http·ios·https
️ 邪神2 天前
【Android、IOS、Flutter、鸿蒙、ReactNative 】约束布局
android·flutter·ios·鸿蒙·reactnative
pinkrecall20122 天前
flutter调试
flutter