flutter —— 布局原理与约束 (Sliver 布局)

布局模型

RenderBox 布局,移步 flutter —— 布局原理与约束

Sliver 的布局流程如下:

  1. Viewport 将当前布局和配置信息通过 SliverConstraints 传递给 Sliver。
  2. Sliver 确定自身的位置、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。
  3. Viewport 读取 geometry 中的信息来对 Sliver 进行布局和绘制。

Sliver 模型的约束

基础约束对象

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

布局信息对象

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, // 在预渲染区域中占据的高度
}) 

布局原理

基本流程:约束子 sliver,设置自身 geometry,保存子节点偏移信息,绘制子节点。

下面自定义一个简单的 SliverToBoxAdapter 组件:

class SliverToCustomBoxAdapter extends SingleChildRenderObjectWidget {
  const SliverToCustomBoxAdapter({
    Key? key,
    Widget? child,
  }) : super(child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderSliverToCustomBoxAdapter();
  }
}


class RenderSliverToCustomBoxAdapter extends RenderSliverSingleBoxAdapter {
  RenderSliverToCustomBoxAdapter();

  @override
  void setupParentData(RenderObject child) {
    if (child.parentData is! SliverPhysicalParentData) {
      child.parentData = SliverPhysicalParentData();
    }
  }

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    // 1.对子节点进行约束 SliverConstraints
    child!.layout(constraints.asBoxConstraints(), parentUsesSize: true);

    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child!.size.width;
        break;
      case Axis.vertical:
        childExtent = child!.size.height;
        break;
    }

    final paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: childExtent);

    final double cacheExtent = calculateCacheOffset(constraints, from: 0.0, to: childExtent);

    // 2.上报当前节点的布局信息给 viewport
    geometry = SliverGeometry(
      // 默认 paintExtent 大于 0时才显示当前 sliver。手动设置 visible: true 则总是可见,不受 paintExtent 影响。
      // visible: paintExtent > 0.0
      // 可以调整此值为 -constraints.scrollOffset,来影响 paintChild.offset。
      // paintOrigin: 0
      // 通常等于子组件的高度,并且不随着滚动而改变
      scrollExtent: childExtent,
      // 通过等于页面上实际显示的长度。滚动到顶部后,开始变小,直到等于 0。手动设置的话,须小于 maxPaintExtent
      paintExtent: paintedChildSize,
      cacheExtent: cacheExtent,
      // 最大可绘制长度,常等于 childExtent。取值必须小于 constraints.remainingPaintExtent。
      maxPaintExtent: childExtent,
    );

    // 3.设置并保存 paintOffset 到 parentData 中,待稍后确定子节点位置(offset)
    // PS:方法内部执行的 childParentData.paintOffset = Offset(0.0, -constraints.scrollOffset);
    setChildParentData(child!, constraints, geometry!);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && geometry!.visible) {
      final SliverPhysicalParentData childParentData = child!.parentData! as SliverPhysicalParentData;
      print("offset: $offset, paintOffset: ${childParentData.paintOffset}");
      // 4.绘制子节点
      // topOffset = constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;
      //其中 offset = topOffset(sliver 到达顶部后等于 0) + paintOrigin(默认等于0)
      //其中 paintOffset(默认等于0)
      //即到达顶部后,如果两者都等于0,并且 visible 不等于 false,则 child 保持当前位置不变。
      context.paintChild(child!, offset + childParentData.paintOffset);
    }
  }
}

约束属性

cacheOrigin

Viewport 预渲染区域的起点[-Viewport.cacheExtent, 0]

remainingPaintExtent

当前 Sliver 在 Viewport 中的最大可以绘制的区域。

作用1:计算当前 sliver 距离顶部的距离

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

作用2:限制 paintExtent 的最大高度

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

scrollOffset

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

overlap

上一个 sliver 覆盖当前 sliver 的大小,见下面的示例2。或者在顶部向下拉一下弹回的效果时,overlay 会变成负的。

布局属性

paintOrigin

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

layoutExtent

布局时占用的高度,取值范围 [0, paintExtent]。即 layoutExtent 须小于等于 paintExtent (当前绘制的高度,一般是等于)。
当 layoutExtent 小于 paintExtent 时,则一部分高度会被下一个 sliver 顶上。

示例 2:

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

代码:

class CustomSliverPage extends StatefulWidget {
  const CustomSliverPage({Key? key}) : super(key: key);

  @override
  State<CustomSliverPage> createState() => _CustomSliverPageState();
}

class _CustomSliverPageState extends State<CustomSliverPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.blueGrey,
        body: CustomScrollView(
          physics: AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
          slivers: [
            SliverAppBar(
              toolbarHeight: 0,
            ),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                height: 200,
                color: Colors.green,
                child: Text("200"),
              ),
            ),
            SliverWrapper(
              sliver: SliverList(
                delegate: SliverChildListDelegate([
                  Container(
                    alignment: Alignment.center,
                    color: Colors.blue.withOpacity(0.5),
                    height: 100,
                    child: Text("100"),
                  ),
                ]),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                height: 200,
                color: Colors.red,
                child: Text("200"),
              ),
            )
          ],
        ));
  }
}

class SliverWrapper extends SingleChildRenderObjectWidget {
  const SliverWrapper({
    Key? key,
    Widget? sliver,
  }) : super(child: sliver);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderSliverWrapper();
  }
}

class RenderSliverWrapper extends RenderSliver with RenderObjectWithChildMixin<RenderSliver> {
  RenderSliverWrapper();

  @override
  void setupParentData(RenderObject child) {
    if (child.parentData is! SliverPhysicalParentData) {
      child.parentData = SliverPhysicalParentData();
    }
  }

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    //1. 对子节点进行约束 SliverConstraints
    child!.layout(constraints, parentUsesSize: true);
    final SliverGeometry childLayoutGeometry = child!.geometry!;
    //2. 上报当前节点的布局信息给 viewport
    geometry = SliverGeometry(
      // scrollExtent: childLayoutGeometry.scrollExtent - childLayoutGeometry.maxScrollObstructionExtent,
      paintExtent: childLayoutGeometry.paintExtent,
      paintOrigin: childLayoutGeometry.paintOrigin,
      //占用布局的高度,默认等于绘制高度 paintExtent
      // 这里 paintExtent 等于 100, 而 layoutExtent 等于 30,即有 70 的高度未被占用,则下一个 sliver 会向上占用剩下的 70的高度
      layoutExtent: math.min(childLayoutGeometry.paintExtent, 30),
      maxPaintExtent: childLayoutGeometry.maxPaintExtent,
      maxScrollObstructionExtent: childLayoutGeometry.maxScrollObstructionExtent,
      hitTestExtent: childLayoutGeometry.hitTestExtent,
      visible: childLayoutGeometry.visible,
      hasVisualOverflow: childLayoutGeometry.hasVisualOverflow,
      scrollOffsetCorrection: childLayoutGeometry.scrollOffsetCorrection,
    );
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && child!.geometry!.visible) {
      //3. 绘制子节点
      final SliverPhysicalParentData childParentData = child!.parentData! as SliverPhysicalParentData;
      context.paintChild(child!, offset + childParentData.paintOffset);
    }
  }

  @override
  void applyPaintTransform(RenderObject child, Matrix4 transform) {
    assert(child == this.child);
    final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
    childParentData.applyPaintTransform(transform);
  }

}

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 才开始滚动。

代码:

class CustomSliverPage extends StatefulWidget {
  const CustomSliverPage({Key? key}) : super(key: key);

  @override
  State<CustomSliverPage> createState() => _CustomSliverPageState();
}

class _CustomSliverPageState extends State<CustomSliverPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.blueGrey,
        body: CustomScrollView(
          physics: AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
          slivers: [
            SliverAppBar(
              toolbarHeight: 0,
            ),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                height: 200,
                color: Colors.green,
                child: Text("200"),
              ),
            ),
            SliverWrapper(
              sliver: SliverList(
                delegate: SliverChildListDelegate([
                  Container(
                    alignment: Alignment.center,
                    color: Colors.blue.withOpacity(0.8),
                    height: 300,
                    child: Text("300"),
                  ),
                ]),
              ),
            ),
            SliverToBoxAdapter(
              child: Container(
                alignment: Alignment.center,
                height: 1200,
                color: Colors.red,
                child: Text("200"),
              ),
            )
          ],
        ));
  }
}

class SliverWrapper extends SingleChildRenderObjectWidget {
  const SliverWrapper({
    Key? key,
    Widget? sliver,
  }) : super(child: sliver);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderSliverWrapper();
  }
}

class RenderSliverWrapper extends RenderSliver with RenderObjectWithChildMixin<RenderSliver> {
  RenderSliverWrapper();

  @override
  void setupParentData(RenderObject child) {
    if (child.parentData is! SliverPhysicalParentData) {
      child.parentData = SliverPhysicalParentData();
    }
  }

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    //1. 对子节点进行约束 SliverConstraints
    child!.layout(constraints, parentUsesSize: true);
    final SliverGeometry childLayoutGeometry = child!.geometry!;
    //2. 上报当前节点的布局信息给 viewport
    geometry = SliverGeometry(
      // 当前 Sliver 顶部向上滚动到 Viewport 顶部后,还可以继续滚动 100 的高度
      // 当这 100 也滚完了,下一个 sliver 才开始滚动
      scrollExtent: 100,
      paintExtent: 300,
      paintOrigin: childLayoutGeometry.paintOrigin,
      layoutExtent: 300,
      maxPaintExtent: 300,
      maxScrollObstructionExtent: childLayoutGeometry.maxScrollObstructionExtent,
      hitTestExtent: childLayoutGeometry.hitTestExtent,
      visible: true,
      // hasVisualOverflow: true,
      // scrollOffsetCorrection: 0.2,
    );
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null && child!.geometry!.visible) {
      //3. 绘制子节点
      final SliverPhysicalParentData childParentData = child!.parentData! as SliverPhysicalParentData;
      context.paintChild(child!, offset + childParentData.paintOffset);
    }
  }

  @override
  void applyPaintTransform(RenderObject child, Matrix4 transform) {
    assert(child == this.child);
    final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
    childParentData.applyPaintTransform(transform);
  }
}

实际使用场景相关示例

2233

posted on 2022-08-16 21:38  Lemo_wd  阅读(1208)  评论(0编辑  收藏  举报

导航