Flutter 布局组件——用 Align 对齐你的组件

3,531 阅读8分钟

我们经常有对齐的诉求,比如一个组件在一个大组件的左上角,右下角,中间显示等等,Container 中可以设置 alignment 属性来实现。Flutter 也有专门的布局组件来实现同样的效果,那就是 Align。

这一篇文章,我就全方位介绍一下 Align 组件,从南到北(基本使用,原理)。

Align 介绍.png

Align 组件介绍

Align 组件的功能主要有两个:对齐其内部的子节点基于子节点的大小调整自己的大小

效果是这样的:

align (1).gif

我们举个例子,你想要让子节点显示在右下角,那么可以设置属性为 Alignment.bottomRight,但是有个前提需要 Align 本身的尺寸大于子节点。

对于对齐来说,最起码外圈尺寸是要大于子节点的大小,两个要是一样大,肯定就重叠了呀。我们看外圈也就是 Align 的尺寸,默认是多少呢?会尽可能大的,父节点给 Align 的约束是多少,那么 Align 就取约束的最大值。

当然了也有非默认的规则:

如果没有约束并且 widthFactorheightFactor 也没设置,那么 Align 的大小就是子节点的大小。

如果 widthFactorheightFactor 设置了,那么 Align 的大小就是子节点的大小与因子的运算值。比如 widthFactor 是 2.0,那么 Align 的宽度就是子节点宽度的两倍。

现在我们知道了 Alin 是啥,下面我们看 Align 的属性。

Align 的属性

属性类型作用
keyKey?组件的标示
alignmentAlignmentGeometry子节点的对齐方法,一般设置为 Alignment
widthFactordouble?宽度因子,Align 的宽度 = 子节点的宽度 * 宽度因子
heightFactordouble?高度因子,Align 的高度 = 子节点的高度 * 高度因子
childWidget?待对齐的子节点

alignment 对齐

这个属性用来控制对齐,虽然类型是 AlignmentGeometry 但是我们常用 Alignment 来赋值。

Alignment 是用构造方法的 x 和 y 来控制位置,范围是 -1 到 1 。如果 x 的值是 -1 ,子节点放在 Align 的左边,1 表示子节点会放在 Align 的右边,同理 y 的 -1 表示子节点在 Align 的上边,1 表示在下边。我们在下面会介绍具体的计算过程。

widthFactor 宽度因子

如果设置非 null,那么 Align 的宽度就是 子节点的宽度与因子的乘积,这个值不能是负数。

heightFactor 高度因子

如果设置非 null,那么 Align 的高度就是 子节点的高度与因子的乘积,这个值不能是负数。

知道了这些属性,下面我们使用效果。

Align 使用

Align 的外圈是黑色边框的 Container,子节点是 60*60 的蓝色色块

基本使用

基础代码如下:

Container(
  width: 300,
  height: 300,
  decoration: BoxDecoration(border: Border.all(color: Colors.black)),
  child: Align(
    child: Container(
      height: 60,
      width: 60,
      color: Colors.blue,
    ),
  ),
)

企业微信截图_a2e7fd70-2a42-4a85-8cb4-14a088022558.png

Align 的布局默认居中,可以调整其 aligment 属性

Align 摆放原理

老规矩 👉三棵树最终章Align 是渲染型组件,它的渲染对象是 RenderPositionedBox,所以我们去 RenderPositionedBox 看摆放的原理。

ParentData 父节点需要知道的数据

在介绍摆放之前,我们先介绍一个概念 ParentData ,就是父渲染对象想要知道的数据。 比如字节点的位置等等, 对应到代码中就是 RenderObject 的 parentData 属性。

RenderObject 的 ParentData 分为两大类:盒子数据 和 Sliver 数据,我们以 ContainerParentDataMixin 为例,看看存储的内容:

mixin ContainerParentDataMixin<ChildType extends RenderObject> on ParentData {
  
  ChildType? previousSibling;
  
  ChildType? nextSibling;
  
  @override
  void detach() {
    super.detach();
  }
}

从这个数据 model 中,父节点可以知道某个子节点的前后节点是谁。 ChildType 是继承自 RenderObject 的范型。

image.png

我们熟知的 Row/Column、Stack、Wrap 等组件对应的渲染对象持有的 parentData 都是 ContainerParentDataMixin 的子类型。所以它们的渲染对象在布局的时候,才可以依次布局子节点。

RenderPositionedBoxparentDataBoxParentData。BoxParentData 的定义如 下:

class BoxParentData extends ParentData {
  /// The offset at which to paint the child in the parent's coordinate system.
  Offset offset = Offset.zero;

  @override
  String toString() => 'offset=$offset';
}

offset 就是 Align 字节点在 Align 中的位置,看到这里你就明白了,对齐就是这个值的计算。下面我们看计算的过程。

布局过程

摆放就是布局测量和绘制。分别对应着 performLayoutpaint

布局过程如下:

  • 第一:确定布局子节点,确定子节点的大小
  • 第二:根据缩放因子和子节点的大小确定自己的大小
  • 第三:根据对齐属性确定子节点的位置
  • 第四:根据位置进行绘制

下面我们从代码中来看具体的过程:

@override
void performLayout() {
  final BoxConstraints constraints = this.constraints;
  final bool shrinkWrapWidth = _widthFactor != null || constraints.maxWidth == double.infinity;
  final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight == double.infinity;

  if (child != null) {
    child!.layout(constraints.loosen(), parentUsesSize: true); //第一处
    size = constraints.constrain(Size(
      shrinkWrapWidth ? child!.size.width * (_widthFactor ?? 1.0) : double.infinity,
      shrinkWrapHeight ? child!.size.height * (_heightFactor ?? 1.0) : double.infinity,
    )); //第二处
    alignChild(); //第三处
  } else {
    size = constraints.constrain(Size(
      shrinkWrapWidth ? 0.0 : double.infinity,
      shrinkWrapHeight ? 0.0 : double.infinity,
    ));
  }
}

我们看第一处的代码,第一处是布局子节点,这个入参非常有意思:constraints.loosen()parentUsesSize

constraints.loosen() 的作用是子节点的约束是宽松的:0 - Align的最大宽度,所以 Align 的子节点最大可用空间不会超过 Align,超过会怎么办呢?截断! 不会出现 over 那样的溢出提示。

parentUsesSize 的作用是告诉 framework,Align 的尺寸信息依赖我的子节点,如果我的子节点标记为 dirty 了,请带上我。

所以第一处的代码是确定子节点的尺寸,我们看第二处的代码,第二处是根据子节点的大小来确定自己的大小。我们以宽度为例:

标题宽度因子不是null宽度因子是 null
约束无限true 子节点宽度 * 宽度因子true 子节点宽度
约束有限true 子节点宽度 * 宽度因子false 宽度无限(最大宽度)

挺有意思吧~,只要设置了尺寸因子,那么 Align 的尺寸就是子节点的大小与尺寸因子的乘积了。

只要不设置,要么就是子节点的宽度,要么就是父布局的宽度,这样就实现了子节点在其爷爷节点中的对齐,Align 只是桥梁而已。

我们第三处,对齐子节点。

@protected
void alignChild() {
  _resolve();
  final BoxParentData childParentData = child!.parentData! as BoxParentData;
  childParentData.offset = _resolvedAlignment!.alongOffset(size - child!.size as Offset);
}

Offset alongOffset(Offset other) {
  final double centerX = other.dx / 2.0;
  final double centerY = other.dy / 2.0;
  return Offset(centerX + x * centerX, centerY + y * centerY);
}

对齐的就是确定了我们上面讲到的确定 offset,确定的方式就是 alongOffset

我们可以暂时先想一下怎么确定位置?

Flutter 的处理是先居中对齐,然后左减右加,加减的范围就是 Alignment 构造方法中 x,y 的绝对值。

比如 Align 的宽度范围是 0 —— 120, child 的宽度是 60。

所以居中的位置是 (120 - 60 )/ 2 = 30, child 的范围就是 30 - 90。

我们在看左上角 Alignment topLeft = Alignment(-1.0, -1.0) 的计算过程。

首先,先确定居中的位置 30
其次,确定宽度 centerX + x * centerX ,就是 30 + (-1)* 30 = 0
最后,x 的坐标就是 0

所以,才有文章开头动画中范围是 -1 到 1,-1 代表最左边和最上边,1 代表最右边和最下边,其他的数值,在这个范围浮动。

示意图.png

这就是位置 offset 的计算过程,有了这个 offset,有什么用呢?

按着这个位置绘制和响应手势!!

绘制过程

RenderPositionedBox 并没有绘制的 paint 方法,绘制方法在其父类 RenderShiftedBox 中。

@override
void paint(PaintingContext context, Offset offset) {
  if (child != null) {
    final BoxParentData childParentData = child!.parentData! as BoxParentData;
    context.paintChild(child!, childParentData.offset + offset);
  }
}

我们看到就是在 Align 的基础上增加布过程中计算好的的 offset

比如上面布局过程计算好的 offset 是 (20,20),那么它的真实位置就是 Align 左上角的坐标,向右向下偏移(20,20),偏移后的就是坐标就是 Align 子节点真实的绘制坐标。

手势响应范围

我们知道手势是有测试范围的,一般是组件的范围内,也会增加是否落在了自己的组件上。如下:

bool hitTest(BoxHitTestResult result, { required Offset position }) {
  if (_size!.contains(position)) {
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
  }
  return false;
}

上面是通用的处理方式,如果落点在自己的组件范围内,会继续判断是否落在了子节点上 hitTestChildren,是否自己有额外的判断 hitTestSelf

我们看 Align 渲染对象的 hitTestChildren

@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
  if (child != null) {
    final BoxParentData childParentData = child!.parentData! as BoxParentData;
    return result.addWithPaintOffset( 
      offset: childParentData.offset,// 第一处
      position: position,
      hitTest: (BoxHitTestResult result, Offset transformed) {
        assert(transformed == position - childParentData.offset);
        return child!.hitTest(result, position: transformed);
      },
    );
  }
  return false;
}

我们看到在检测自己的子节点是否满足点击的时候,加了一个偏移转换,转换的坐标就是布局阶段的 offset 。

通过上面的绘制和点击检测,我们可以清晰的理解 ParentData 的作用,它携带的数据就是给 Align 组件用的。

总结

这一篇就结束啦,这是布局组件的第一篇。介绍了 Align 的使用场景、基本属性、基本使用。在这些的基础上,探究了 Align 能够实现对齐的原理,浓缩成一句话就是:先找到中间,在计算因子。我们使用的 Center 组件就是 Align 的子类,只是将对齐属性设置为了居中而已。