Unity UI - 布局(三)之 Grid Layout Group 布局分析

前言

在上一篇文章中,我们分析了用于为子元素实现布局的抽象基类 LayoutGroup,用于水平方向布局子元素的 HorizontalLayoutGroup 类和用于垂直方向布局子元素的 VerticalLayoutGroup 类;通过对这两个类的分析,我们了解了 Layout Group 为子元素布局时的具体流程以及其实现。

除了 HorizontalLayoutGroup 和 VerticalLayoutGroup 两个类用于实现子元素排列布局之外,还有一个用于实现子元素布局的类 GridLayoutGroup。

GridLayoutGroup

Grid Layout Group 将子元素按照网格布局,在 Unity 编辑器中可以配置相关属性,如下:

GridLayoutGroup 类继承自 LayoutGroup 类,因此它具有 LayoutGroup 类中的常用的成员变量和方法;

除了从 LayoutGroup 类继承而来的成员属性外,它自身还定义了一些成员变量:

  • m_CellSize - 这是一个 Vector2 类型的变量,代表了其每个子元素被布局时候的宽和高,这一点和其他 Layout Group 不同,Grid Layout Group 不需要使用 Layout Element 的 minSize、preferredSize 和 flexibleSize 等数据

  • m_StartCorner - 定义了第一个子元素位置在哪个地方,它是枚举 Corner 类型的变量;当设置不同的参数值时,如下可以看到子元素布局发生的变化:

  • m_StartAxis - 指定当前 Grid Layout Group 首先应该在哪个方向上去放置元素,也是枚举 Axis 类型的一个变量;同样看看设置不同值的时候子元素布局发生的变化:
  • m_ChildAlignment - 这个变量是从 LayoutGroup 类继承过来的,指定了子元素的对齐方式;设置不同值的时候子元素布局发生的变化如下:
  • m_Constraint - 约束当前 Grid Layout Group 的行列数目,它同样是一个枚举 Constraint 类型的变量;也看看设置不同值的时候子元素布局发生的变化:

Auto Layout 系统执行布局时,同样是依次调用 GridLayoutGroup 类的 CalculateLayoutInputHorizontalSetLayoutHorizontalCalculateLayoutInputVerticalSetLayoutVertical 方法。下面来分析这几个方法:

CalculateLayoutInputHorizontal 方法

当 Auto Layout 系统调用此方法时,会计算当前 Layout Group 水平方向上的尺寸大小。

首先调用了 LayoutGroup 类的 CalculateLayoutInputHorizontal 方法找到所有需要计算布局的子元素,这和 Horizontal/VerticalLayoutGroup 类计算类似。

紧接着判断 m_Constraint 约束值,根据设置的不同的约束计算当前 GridLayoutGroup 最少列的数量 minColumns 和首选的列的数量 preferredColumns,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int minColumns = 0;
int preferredColumns = 0;
if (m_Constraint == Constraint.FixedColumnCount)
{
minColumns = preferredColumns = m_ConstraintCount;
}
else if (m_Constraint == Constraint.FixedRowCount)
{
minColumns = preferredColumns = Mathf.CeilToInt(rectChildren.Count / (float)m_ConstraintCount - 0.001f);
}
else
{
minColumns = 1;
preferredColumns = Mathf.CeilToInt(Mathf.Sqrt(rectChildren.Count));
}

从代码中看出,当固定列数(m_ConstraintConstraint.FixedColumnCount)的时候,当前 GridLayoutGroup 最少列数和首选列数都是 m_ConstraintCount 设置的值;当行数固定的时候,最少列数和首选列数就由子元素数量和设置的约束行数 m_ConstraintCount 相除取整得到;否则最少列数为 1,首选列数量就是子元素数量开方后取整得到的值。

计算完 minColumns 和 preferredColumns 后就调用 LayoutGroup 类的 SetLayoutInputForAxis 设置当前 Layout Group 此刻方向上的 totalMin、totalPreferred 和 totalFlexible 数据,代码如下:

1
SetLayoutInputForAxis(padding.horizontal + (cellSize.x + spacing.x) * minColumns - spacing.x, padding.horizontal + (cellSize.x + spacing.x) * preferredColumns - spacing.x, -1, 0);

\[ totalMin = padding + 最小列数量 \times (每个子元素宽度 + spacing) - spacing \]

\[ totalPreferred = padding + 首选列数量 \times (每个子元素宽度 + spacing) - spacing \]

\[ totalFlexible = -1 \]

CalculateLayoutInputVertical 方法

这个方法和 CalculateLayoutInputHorizontal 方法类似,只不过这里计算的是当前 Layout Group 垂直方向上的尺寸大小。

首先根据设置的约束条件计算最少行的数量 minRows,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int minRows = 0;
if (m_Constraint == Constraint.FixedColumnCount)
{
minRows = Mathf.CeilToInt(rectChildren.Count / (float)m_ConstraintCount - 0.001f);
}
else if (m_Constraint == Constraint.FixedRowCount)
{
minRows = m_ConstraintCount;
}
else
{
float width = rectTransform.rect.width;
int cellCountX = Mathf.Max(1, Mathf.FloorToInt((width - padding.horizontal + spacing.x + 0.001f) / (cellSize.x + spacing.x)));
minRows = Mathf.CeilToInt(rectChildren.Count / (float)cellCountX);
}

如果约束条件 m_Constraint 设置为约束列(Constraint.FixedColumnCount),最少行的数量 minRows 由子元素数量和设置的列数 m_ConstraintCount 相除取整得到;如果约束条件 m_Constraint 设置为约束行(Constraint.FixedRowCount),最少行的数量 minRows 就是设置的约束值 m_ConstraintCount;否则如果没有约束条件,首先计算每一行需要放置子元素的数量,最少行数量 minRows 就由子元素的数量和当前行可容纳子元素相除取整得到。

计算完最少行的数量后就调用 LayoutGroup 类的 SetLayoutInputForAxis 设置当前 Layout Group 此刻方向上的 totalMin、totalPreferred 和 totalFlexible 数据,代码如下:

1
2
float minSpace = padding.vertical + (cellSize.y + spacing.y) * minRows - spacing.y;
SetLayoutInputForAxis(minSpace, minSpace, -1, 1);

\[ totalMin = totalPreferred = padding + 最小行数量 \times (每个子元素高度 + spacing) - spacing \]

\[ totalFlexible = -1 \]

SetLayoutHorizontal 和 SetLayoutVertical 方法

这两个方法分别用于水平和垂直方向上为子元素布局,方法都仅仅调用了 SetCellsAlongAxis 方法,只是传入的参数不一样,因此我们直接跳到这方法中去分析。

SetCellsAlongAxis 方法

在这个方法中设置子元素的位置和尺寸大小。

在进行水平方向的布局时仅计算子元素的尺寸大小(两个方向都计算)而不计算位置信息;在进行垂直方向的布局过程又仅设置水平和垂直方向的位置信息,这与其他 Layout Group 为子元素布局不同(它们是一个方向会进行计算尺寸和设置位置信息这两个步骤)。

首先是水平方向布局,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (axis == 0)
{
for (int i = 0; i < rectChildren.Count; i++)
{
RectTransform rect = rectChildren[i];
// other code ...
rect.anchorMin = Vector2.up;
rect.anchorMax = Vector2.up;
rect.sizeDelta = cellSize;
}
return;
}

可以看出仅仅为每个子元素设置了四个锚点位置都位于父元素左上角(Vector2.up)以及尺寸大小设置为配置的 m_CellSize

当垂直布局时,开始计算水平和垂直两个方向上的位置信息。步骤如下:

  1. 首先计算水平和垂直两个方向上课容纳的子元素的数量,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (m_Constraint == Constraint.FixedColumnCount)
{
cellCountX = m_ConstraintCount;
if (rectChildren.Count > cellCountX)
cellCountY = rectChildren.Count / cellCountX + (rectChildren.Count % cellCountX > 0 ? 1 : 0);
}
else if (m_Constraint == Constraint.FixedRowCount)
{
cellCountY = m_ConstraintCount;
if (rectChildren.Count > cellCountY)
cellCountX = rectChildren.Count / cellCountY + (rectChildren.Count % cellCountY > 0 ? 1 : 0);
}
else
{
if (cellSize.x + spacing.x <= 0)
cellCountX = int.MaxValue;
else
cellCountX = Mathf.Max(1, Mathf.FloorToInt((width - padding.horizontal + spacing.x + 0.001f) / (cellSize.x + spacing.x)));
if (cellSize.y + spacing.y <= 0)
cellCountY = int.MaxValue;
else
cellCountY = Mathf.Max(1, Mathf.FloorToInt((height - padding.vertical + spacing.y + 0.001f) / (cellSize.y + spacing.y)));
}

这里根据设置的行列约束条件 m_Constraint 不同进行了不同的计算:

如果设置为固定列数 Constraint.FixedColumnCount,那么列数(水平方向的子元素数量) cellCountX 就是 m_ConstraintCount 设置的值,此时如果子元素总数量一行还放不下,就计算行数(垂直方向子元素数量) cellCountY,如果一行就可以容纳所有子元素,行数 cellCountY 就是默认值 1;

如果设置为固定行数 Constraint.FixedRowCount,那么行数 cellCountY 就是 m_ConstraintCount 设置的值,此时如果子元素总数量还比行数要多,那么就计算列数 cellCountX,如果一列就可以容纳所有子元素,列数 cellCountX 就是默认值 1;

如果设置为无约束 Constraint.Flexible,那么列数 cellCountX 就是宽度与每个子元素宽(需要考虑 padding 和 spacing)相除取整得到的结果;行数 cellCountY 是高度与每个子元素的高(需要考虑 padding 和 spacing)相除取整得到的结果。

  1. 计算完行数和列数,使用 m_StartAxis 主方向(即布局起始方向)值来计算相关值:

    • 主方向设置为水平方向,主方向上元素的个数 cellsPerMainAxis 就是 cellCountX;通过 Clamp 方法对列数和行数进行过滤得到最终列数 actualCellCountX 和最终行数 actualCellCountY

    • 主方向设置为垂直方向,主方向上元素的个数 cellsPerMainAxis 就是 cellCountY;同样也通过 Clamp 方法对列数和行数进行过滤得到最终列数 actualCellCountX 和最终行数 actualCellCountY

  2. 计算当前列(行)数下水平(垂直)方向所需要的空间,包括 spacing:

1
Vector2 requiredSpace = new Vector2(actualCellCountX * cellSize.x + (actualCellCountX - 1) * spacing.x, actualCellCountY * cellSize.y + (actualCellCountY - 1) * spacing.y);
  1. 调用 LayoutGroup 类的 GetStartOffset 方法分别计算水平和垂直方向上起始的偏移量,这里会用到设置的对齐方式属性 m_ChildAlignment:
1
Vector2 startOffset = new Vector2(GetStartOffset(0, requiredSpace.x), GetStartOffset(1, requiredSpace.y));
  1. 最后根据上面得到的控制变量计算最终每个子元素的位置并布局,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int cornerX = (int)startCorner % 2;
int cornerY = (int)startCorner / 2;
for (int i = 0; i < rectChildren.Count; i++)
{
int positionX, positionY;
if (startAxis == Axis.Horizontal)
{
positionX = i % cellsPerMainAxis;
positionY = i / cellsPerMainAxis;
}
else
{
positionX = i / cellsPerMainAxis;
positionY = i % cellsPerMainAxis;
}
if (cornerX == 1)
positionX = actualCellCountX - 1 - positionX;
if (cornerY == 1)
positionY = actualCellCountY - 1 - positionY;
SetChildAlongAxis(rectChildren[i], 0, startOffset.x + (cellSize[0] + spacing[0]) * positionX, cellSize[0]);
SetChildAlongAxis(rectChildren[i], 1, startOffset.y + (cellSize[1] + spacing[1]) * positionY, cellSize[1]);
}

遍历每一个子元素,通过以下步骤确定最终位置:

  1. 如果 m_StartAxis 主方向设置为水平方向,说明布局顺序是从左到右然后从上到下;如果主方向设置为垂直方向,那么布局顺序变成了从上到下,然后从左到右;根据这两种顺序计算每个元素在水平和垂直方向上的索引 positionXpositionY

  2. 根据设置的 m_StartCorner 计算水平和垂直方向上的 Corner 值,当水平方向的 Corner 值为 1 的时候(m_StartCorner 设置为右边的角落),对水平方向索引值 positionX 进行左右翻转;同理,当垂直方向的 Corner 值为 1 的时候(m_StartCorner 设置为下边的角落),也对垂直索引方向索引值 positionY 进行上下翻转。

  3. 最后一步调用 LayoutGroup 类的 SetChildAlongAxis 方法设置子元素的位置和尺寸大小:

    • 当前方向上的位置计算如下

      \[ 位置 = 起始偏移量 + (cellSize + spacing) \times 元素在当前方向上的索引值 \]

    • 大小是设置的 m_CellSize 的值

    • 这个方法会先后被调用两次用于设置水平和垂直两个方向上的布局