Flutter中一个自定义child位置的组件

404 阅读3分钟

背景

image.png

最近在做需求的时候,设计页面如上,滑块下面的气泡要按照上面的方式排列,首尾两个滑块在两端,中间的需要在滑块宽度的35%的地方。这时候使用Row组件就不合适了,因为无法精确定位中间的位置。

方案

仔细分析一下,其实我们所需要的就是一个能手动控制子组件位置的widget,但是Flutter中并没有提供类似的(可能有,但是我没找到),不过我们能手动实现一个。

在实现这样的组件之前,我们需要对Flutter的布局流程有个简单的认识:

  1. 上层组件向下层组件传递约束(constraints)条件。
  2. 下层组件确定自己的大小,然后告诉上层组件。注意下层组件的大小必须符合父组件的约束。
  3. 上层组件确定下层组件相对于自身的偏移和确定自身的大小(大多数情况下会根据子组件的大小来确定自身的大小)。

而Flutter中布局类组件都直接或间接的继承自SingleChildRenderObjectWidget 和MultiChildRenderObjectWidget的Widget,比如Row、Column、Stack等,我们可以参考这些组件来实现一个自定义子组件位置的widget。在自定义组件里我们需要把子组件的位置交由外界来处理。

实现

我们可以自定义一个ManualLayoutWidget组件,继承自MultiChildRenderObjectWidget,看过Flutter中一个能获取行数的Wrap这篇文章的可能了解MultiChildRenderObjectWidget的实现方式,该组件需要一个RenderObject对象,也就是真正渲染的对象。

在RenderObject对象中,我们重点需要实现三个方法:

  1. setupParentData: 当子组件被添加到父组件的时候,该方法会设置子组件的parentData(后续定位会用到)
  2. performLayout:用来布局子组件,并确定自身的size(在该方法里定位每个child的位置)
  3. paint:绘制

可以看到每个child的位置都是在performLayout方法里决定的,我们需要在该方法里进行:

  1. 布局子组件
  2. 将组件自身的size,上一个child的rect,下标以及约束传递给外界,让外界决定child的位置(Offset)
  3. 根据每个组件的位置、大小决定自身的大小

上面说了大致的思路,下面来看下具体实现:

/// 定义方法 外界传回child的位置
/// [childSize] 当前child的大小
/// [previousChildRect] 前一个child的rect
/// [index] 当前child所在的下标
/// [constraints] 组件本身的约束
typedef ManualLayoutChildCallBack = Offset Function(
  Size childSize,
  Rect? previousChildRect,
  int index,
  BoxConstraints constraints,
);


@override
void performLayout() {
  RenderBox? child = firstChild;
  final constraints = this.constraints;

  if (child == null) {
    size = constraints.smallest;
    return;
  }

  Rect? previousChildRect;

  final BoxConstraints childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);

  double maxX = .0;
  double maxY = .0;
  int index = 0;
  while (child != null) {
    child.layout(childConstraints, parentUsesSize: true);
    final childSize = child.size;
    final childParentData = child.parentData as _ManualRenderParentData;

    final offset = _layoutChild(childSize, previousChildRect, index, constraints);

    childParentData.offset = offset;

    previousChildRect = offset & childSize;

    maxX = math.max(maxX, previousChildRect.right);
    maxY = math.max(maxY, previousChildRect.bottom);

    child = childParentData.nextSibling;
    index = index + 1;
  }

  // 这里暂时只考虑横向
  size = constraints.constrain(Size(constraints.maxWidth, maxY));
}

为什么不用Stack?

为什么不用Stack呢,使用Positioned也可以自己决定child的位置。不过这种方式有一些缺点,不太符合需求。可以看到滑块和下面的气泡有一个灰色带圆角的背景,这里我是整体用Container包裹的,大致的结构是Container > Column > [滑块 气泡],而Positioned是不参与Stack组件的大小计算的,我们可以看下源码:

// 计算Stack的大小
Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) {
... 省去部分代码
  double width = constraints.minWidth;
  double height = constraints.minHeight;

  RenderBox? child = firstChild;
  while (child != null) {
    final StackParentData childParentData = child.parentData! as StackParentData;

    if (!childParentData.isPositioned) {
      hasNonPositionedChildren = true;

      final Size childSize = layoutChild(child, nonPositionedConstraints);

      width = math.max(width, childSize.width);
      height = math.max(height, childSize.height);
    }

    child = childParentData.nextSibling;
  }

  final Size size;
  if (hasNonPositionedChildren) {
    size = Size(width, height);
    assert(size.width == constraints.constrainWidth(width));
    assert(size.height == constraints.constrainHeight(height));
  } else {
    size = constraints.biggest;
  }

  assert(size.isFinite);
  return size;
}

这样就无法拿到真实的大小,这样也就无法决定Container的大小了。当然要实现的话也可以,我们可以给Container一个固定的高度,因为在这个例子里气泡的高度其实是固定的。但是尝试另外一种方式不也挺happy嘛😊

完整代码可以在github.com/lwy121810/m… 这里查看

参考

  1. book.flutterchina.club/chapter4/co…