Unity UI - 布局(二)

前言

在上一篇文章中,我们分析了 Unity 基础布局和 Auto Layout 系统,重点分析了 Auto Layout 系统的组成以及用于控制 UI 元素自身 Layout 的控制器 ContentSizeFitter 组件和 AspectRatioFitter 组件。

Auto Layout 系统除了能够对控制器组件自身所在 UI 元素进行布局控制外,还有能够控制子元素的 Layout Controller,本文中将着重分析这一部分内容。除此之外,还将对 Auto Layout 系统中额外的问题进行分析。

我们知道 Layout Controller 都会实现 ILayoutController 接口用来实现自动布局;用于控制 UI 元素自身 Layout 的控制器具体实现了 ILayoutSelfController 接口,剩下的另一个 ILayoutGroup 就是用于控制子元素布局的接口。

LayoutGroup

LayoutGroup 组件不会控制自身所在 UI 元素的尺寸大小或位置,但是它能够控制子元素的布局。LayoutGroup 类是一个抽象类,它继承自 UIBehaviour 同时实现了 ILayoutElement 和 ILayoutGroup 接口,因此 LayoutGroup 也可以被作为一个 Layout Element 受其它 Layout Controller 控制布局。

LayoutGroup 类作为其他具体 Layout Group 的抽象类,它内部定义了一些常用的公共计算方法以及一些需要用到的基础变量。

常用成员变量

首先 LayoutGroup 类中有一个 RectOffset 类型的变量 m_Padding,表示添加到子元素 Layout Elements 四周的 padding 值;另外还有一个 TextAnchor 类型的变量 m_ChildAlignment,表示在所有子元素没有填充满所有可以空间(如没有设置 Flexible Size)时,子元素的对齐方式,这两个变量都可以在 Unity 编辑器中手动设置。

另外还有几个成员变量用来存储当前 Layout Group 的一些信息:

Property Description
m_TotalMinSize 当前 Layout Group 的最下尺寸
m_TotalPreferredSize 当前 Layout Group 的首选尺寸大小
m_TotalFlexibleSize 当前 Layout Group 的 flexible size

常用方法

  1. 在实现的两个接口所有方法中,LayoutGroup 类仅重写了 ILayoutElement 接口中的 CalculateLayoutInputHorizontal 方法。代码如下:
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
26
27
28
29
30
31
public virtual void CalculateLayoutInputHorizontal()
{
m_RectChildren.Clear();
var toIgnoreList = ListPool<Component>.Get();
for (int i = 0; i < rectTransform.childCount; i++)
{
var rect = rectTransform.GetChild(i) as RectTransform;
if (rect == null || !rect.gameObject.activeInHierarchy)
continue;
rect.GetComponents(typeof(ILayoutIgnorer), toIgnoreList);
if (toIgnoreList.Count == 0)
{
m_RectChildren.Add(rect);
continue;
}
for (int j = 0; j < toIgnoreList.Count; j++)
{
var ignorer = (ILayoutIgnorer)toIgnoreList[j];
if (!ignorer.ignoreLayout)
{
m_RectChildren.Add(rect);
break;
}
}
}
ListPool<Component>.Release(toIgnoreList);
m_Tracker.Clear();
}

这个方法中主要就是寻找当前 UI 元素所有的子元素中所有需要被 Auto Layout 系统控制布局的元素;主要通过子元素身上是否绑定了实现 ILayoutIgnorer 接口的组件以及对应的接口方法 ignoreLayout 的返回值来判断当前 UI 元素是否需要被 Auto Layout 系统忽略。代码中将所有需要被 Layout 的 UI 元素添加到成员变量 m_RectChildren 中。在具体 Layout Group 实现类(如 HorizontalOrVerticalLayoutGroup)中,会根据得到的这些子元素去计算容纳它们的 TotalMinSize、TotalPreferredSize 和 TotalFlexibleSize,最后具体 Layout Group 实现类会调用 LayoutGroup 类的 SetLayoutInputForAxis 方法临时保存这些数据,可用于后续子元素尺寸的计算或自身 LayoutGroup 的尺寸大小的适配(如当前 LayoutGroup 所在对象上同时绑定了一个 ContentSizeFilter 组件,那么 ContentSizeFilter 适配计算当前对象大小尺寸调用相关方法时就需要用到这些数据,详见《Unity UI - 布局(一)》)。

  1. 由于 LayoutGroup 继承自 UIBehaviour,因此它具有 Unity 生命周期的方法回调,在这些回调方法中大多数都调用了类本身的 SetDirty 方法去标记自身所在的 UI 元素可以被重新构建。

  2. SetChildAlongAxisWithScale 方法在 LayoutGroup 类中有四个重载,他们的作用都是用来设置 Layout Element 的位置和尺寸大小;这里我们主要来看看其中两个重载的实现,因为另外两个重载也是在方法内分别调用了这两个重载方法;

首先来看看第一个重载实现,它需要四个参数:

Parameter Description
rect 当前需要修改的 UI 元素 RectTransform
axis 设置位置和尺寸大小的方向(0 代表水平方向,1 代表垂直方向)
pos 当前需要设置的距离左边或上边的位置
scaleFactor 缩放系数

代码如下:

1
2
3
4
5
6
7
8
9
10
protected void SetChildAlongAxisWithScale(RectTransform rect, int axis, float pos, float scaleFactor)
{
// other code ...
rect.anchorMin = Vector2.up;
rect.anchorMax = Vector2.up;
Vector2 anchoredPosition = rect.anchoredPosition;
anchoredPosition[axis] = (axis == 0) ? (pos + rect.sizeDelta[axis] * rect.pivot[axis] * scaleFactor) : (-pos - rect.sizeDelta[axis] * (1f - rect.pivot[axis]) * scaleFactor);
rect.anchoredPosition = anchoredPosition;
}

在这个方法中,将当前正在修改的 UI 元素的 RectTransform 的四个锚点都设置到了左上角(Vector2.up),并根据穿过来的参数 pos 设置 UI 元素的位置(主要是修改相应方向上的 anchoredPosition 值),值得注意的是在这个方法中并没有修改 UI 元素的 RectTransform 的尺寸大小。

  1. 下面我们再看看另一个重载方法,在上面方法参数的基础上这个方法多了一个 size 参数,表示需要设置的 RectTransform 的尺寸大小,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected void SetChildAlongAxisWithScale(RectTransform rect, int axis, float pos, float size, float scaleFactor)
{
// other code ...
rect.anchorMin = Vector2.up;
rect.anchorMax = Vector2.up;
Vector2 sizeDelta = rect.sizeDelta;
sizeDelta[axis] = size;
rect.sizeDelta = sizeDelta;
Vector2 anchoredPosition = rect.anchoredPosition;
anchoredPosition[axis] = (axis == 0) ? (pos + size * rect.pivot[axis] * scaleFactor) : (-pos - size * (1f - rect.pivot[axis]) * scaleFactor);
rect.anchoredPosition = anchoredPosition;
}

代码实现大部分和上面讲的类似,只不过这个方法里面多了一步设置当前 UI 元素 RectTransform 的尺寸大小(修改了 sizeDelta 的值)。由此可知,这个方法不仅可以修改位置,还可以修改 UI 元素的尺寸大小。

  1. SetLayoutInputForAxis 方法用来设置当前 Layout Group 相关的信息,如最小尺寸等。

  2. 最后是两个比较简单的工具方法 GetStartOffsetGetAlignmentOnAxis

GetAlignmentOnAxis 方法根据当前 Layout Group 设置的 m_ChildAlignment 以及方向 axis 计算得到一个偏移值;如果是水平方向,从左到中间到右边依次返回 0 - 0.5 - 1;垂直方向从上到中间到下也是依次返回 0 - 0.5 - 1。

GetStartOffset 方法根据一个需求尺寸计算某个方向上的偏移值。

到这里 LayoutGroup 主要的内容以及源码就基本分析完了。总结一下,LayoutGroup 作为 Layout Controllers 的一个抽象基类,它定义了 Layout Controller 中需要常用的到属性以及方法,对于具体如何计算子元素的位置或是尺寸交给它的子类去实现,接下来就将开始对于具体 Layout Group 的分析。

在 Auto Layout 系统中,不同的 Layout Group 组件用来实现特定的布局;如 Horizontal Layout Group 可以将子 UI 元素在水平方向上依次布局,Vertical Layout Group 可以做到将子元素依次在垂直方向上排列布局,而 Grid Layout Group 可以将子元素依次按格子放置;下面就具体来看看这几种 Layout Group。

HorizontalOrVerticalLayoutGroup

在这之前,需要首先分析一下 HorizontalOrVerticalLayoutGroup 类,它继承自 LayoutGroup,是对 HorizontalLayoutGroup 和 VerticalLayoutGroup 类的又一层抽象,包含了水平和垂直两个方向上的 Layout Group 布局时常用的一些控制变量以及方法。

成员变量

Property Description
m_Spacing Layout Group 中两个子元素之间的间距
m_ChildForceExpandWidth/Height 是否子元素强制撑满当前 Layout Group 下剩余可用空间
m_ChildControlWidth/Height Layout Group 是否控制子元素的宽或高
m_ChildScaleWidth/Height 在布局的时候 Layout Group 是否考虑子元素的 Scale

上面的属性变量都是可以在 Unity 编辑器中手动设置的,HorizontalOrVerticalLayoutGroup 类继承自 LayoutGroup 类,因此前面讲到过的 LayoutGroup 中可设置的属性变量在 HorizontalOrVerticalLayoutGroup 中也生效。

常用方法

HorizontalOrVerticalLayoutGroup 类中的方法比较少,但在 Auto Layout 系统中扮演了重要的作用,下面就来看看这几个方法。

GetChildSizes

首先看代码,再分析其作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void GetChildSizes(RectTransform child, int axis, bool controlSize, bool childForceExpand,
out float min, out float preferred, out float flexible)
{
if (!controlSize)
{
min = child.sizeDelta[axis];
preferred = min;
flexible = 0;
}
else
{
min = LayoutUtility.GetMinSize(child, axis);
preferred = LayoutUtility.GetPreferredSize(child, axis);
flexible = LayoutUtility.GetFlexibleSize(child, axis);
}
if (childForceExpand)
flexible = Mathf.Max(flexible, 1);
}

传入的第一个参数 child 是要获取大小的 RectTransform,第二个参数 axis 是方向值,controlSize 就是刚刚讲过的 Layout Group 是否控制子元素的宽高,childForceExpand 也是讲过的子元素强制撑满剩余空间的控制变量。代码很简单,如果不控制子元素的宽高则输出 min 和 preferred 都为子元素本身的宽高值,flexible 输出为 0;否则就会调用 LayoutUtility 类的 GetMinSizeGetPreferredSizeGetFlexibleSize 方法分别输出 min、preferred 和 flexible;最后若设置了子元素需要强制撑满剩余空间,就将 flexible 置为它和 1 之间比较大的一个值输出。这里又用到了 LayoutUtility 这个类,在本文最后面会详细分析一下这个类中的一些方法。

CalcAlongAxis 方法

这个方法用来计算当前 Layout Group 下 m_TotalMinSizem_TotalPreferredSizem_TotalFlexibleSize 等属性。

具体分析这个方法之前,来看看一次重新构建 Layout 的流程:

CanvasUpdateRegistry 类收到系统调用的 willRenderCanvases 消息,从而调用自身的 PerformUpdate 方法促使 Layout 重新构建开始;LayoutRebuilder 类的 Rebuild 方法被调用,在 Rebuild 方法内部首先调用了具体 Layout Group 组件的 CalculateLayoutInputHorizontal 方法,然后是 SetLayoutHorizontal 方法,紧接着调用 CalculateLayoutInputVertical 方法,最后是 SetLayoutVertical 方法。

在具体 Layout Group 类的 CalculateLayoutInputHorizontal 方法中,首先调用 LayoutGroup 类的 CalculateLayoutInputHorizontal 方法去计算当前需要被 Layout 的子元素,然后调用 CalcAlongAxis 方法开始计算,这里看看主要代码部分:

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
26
27
28
29
30
31
32
33
34
35
36
bool alongOtherAxis = (isVertical ^ (axis == 1));
for (int i = 0; i < rectChildren.Count; i++)
{
RectTransform child = rectChildren[i];
float min, preferred, flexible;
GetChildSizes(child, axis, controlSize, childForceExpandSize, out min, out preferred, out flexible);
if (useScale)
{
float scaleFactor = child.localScale[axis];
min *= scaleFactor;
preferred *= scaleFactor;
flexible *= scaleFactor;
}
if (alongOtherAxis)
{
totalMin = Mathf.Max(min + combinedPadding, totalMin);
totalPreferred = Mathf.Max(preferred + combinedPadding, totalPreferred);
totalFlexible = Mathf.Max(flexible, totalFlexible);
}
else
{
totalMin += min + spacing;
totalPreferred += preferred + spacing;
totalFlexible += flexible;
}
}
if (!alongOtherAxis && rectChildren.Count > 0)
{
totalMin -= spacing;
totalPreferred -= spacing;
}
totalPreferred = Mathf.Max(totalMin, totalPreferred);
SetLayoutInputForAxis(totalMin, totalPreferred, totalFlexible, axis);

遍历需要 Layout 的子元素集合 rectChildren,遍历过程中获取子元素的 minSize、preferredSize 和 flexibleSize 等数据,如果需要缩放就对获取到的这些数据缩放处理;然后根据要计算的轴方向是否与当前 Layout Group 的方向相同使用不同的方式计算所需数据:

  • 如果同向(如 Layout Group 方向水平,计算的也是水平轴方向,此时 Layout Group 中的元素是依次水平排开的,所以计算水平的宽度需要累加)那么就累加所有子元素的 min、preferred 和 flexible 数据到 totalMin、totalPreferred 和 totalFlexible 中(totalMin 和 totalPreferred 还要累加 spacing),这种情况下最后还要剔除掉最后累加的一次 spacing,就得到了 totalMin、totalPreferred 和 totalFlexible 的数据,再调用 LayoutGroup 的 SetLayoutInputForAxis 方法保存这些数据。

  • 如果不同向,比如 Layout Group 方向水平,计算的是垂直轴方向,此时 Layout Group 中的元素是依次水平排开的,所以垂直方向上的高度计算不用累加,此时保持 totalMin、totalPreferred 和 totalFlexible 是所有子元素单独计算时得到的最大的一个值即可,最后也是调用 LayoutGroup 的 SetLayoutInputForAxis 方法保存这些数据。

  • 这样的计算同样适合 Layout Group 是垂直方向的情况。

Layout Group 的属性计算完毕,下面就轮到了每个子元素的位置和尺寸大小数据的计算。

SetChildrenAlongAxis 方法

这个方法主要就是用来设置子元素的位置以及尺寸大小,它有两个参数 axis 表示当前设置的方向以及 isVertical 表示当前的 Layout Group 是否是垂直方向的。

代码中,首先计算了相关的控制变量以及当前设置的对齐方式。如下:

1
2
3
4
5
6
7
float size = rectTransform.rect.size[axis];
bool controlSize = (axis == 0 ? m_ChildControlWidth : m_ChildControlHeight);
bool useScale = (axis == 0 ? m_ChildScaleWidth : m_ChildScaleHeight);
bool childForceExpandSize = (axis == 0 ? m_ChildForceExpandWidth : m_ChildForceExpandHeight);
float alignmentOnAxis = GetAlignmentOnAxis(axis);
bool alongOtherAxis = (isVertical ^ (axis == 1));

然后根据计算得到的 alongOtherAxis 来使用不同的方式对子元素布局,这里以 Horizontal Layout Group 计算为例来分析(Vertical Layout Group 情况类似),有以下两种情况:

  • 为子元素计算布局的方向与当前 Layout Group 本身方向相同

当 Layout Rebuilder 在发起重新构建过程中,首先调用 SetLayoutHorizontal 方法,进而调用 SetChildrenAlongAxis 方法;此时的 Layout Group 本身的方向是水平的,同时也是在水平方向上为各个子元素计算布局,因此属性 alongOtherAxis 值为 false

当为水平方向的 Layout Group 中的子元素计算水平方向上的布局时,首先初始化 padding 带来的 offset:

1
float pos = (axis == 0 ? padding.left : padding.top);

然后计算当前 Layout Group 除 preferredSize 之外的剩余可用空间,如果有剩余空间,就判断是否有子元素设置了 flexibleSize,如果有则根据 flexibleSize 均分剩余空间得到 itemFlexibleMultiplier;如果没有一个子元素设置 flexibleSize,就再对 pos 进行偏移:

1
2
3
4
5
6
7
8
9
10
float itemFlexibleMultiplier = 0;
float surplusSpace = size - GetTotalPreferredSize(axis);
if (surplusSpace > 0)
{
if (GetTotalFlexibleSize(axis) == 0)
pos = GetStartOffset(axis, GetTotalPreferredSize(axis) - (axis == 0 ? padding.horizontal : padding.vertical));
else if (GetTotalFlexibleSize(axis) > 0)
itemFlexibleMultiplier = surplusSpace / GetTotalFlexibleSize(axis);
}

紧接着判断 Layout Group 的 TotalMinSize 和 TotalPreferredSize 是否不等,如果不等就根据这两个值计算一个插值 minMaxLerp,这个插值会在后面计算最终子元素尺寸的时候用到。代码如下所示:

1
2
3
float minMaxLerp = 0;
if (GetTotalMinSize(axis) != GetTotalPreferredSize(axis))
minMaxLerp = Mathf.Clamp01((size - GetTotalMinSize(axis)) / (GetTotalPreferredSize(axis) - GetTotalMinSize(axis)));

最后就是计算子元素的布局部分,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (int i = 0; i < rectChildren.Count; i++)
{
RectTransform child = rectChildren[i];
float min, preferred, flexible;
GetChildSizes(child, axis, controlSize, childForceExpandSize, out min, out preferred, out flexible);
float scaleFactor = useScale ? child.localScale[axis] : 1f;
float childSize = Mathf.Lerp(min, preferred, minMaxLerp);
childSize += flexible * itemFlexibleMultiplier;
if (controlSize)
{
SetChildAlongAxisWithScale(child, axis, pos, childSize, scaleFactor);
}
else
{
float offsetInCell = (childSize - child.sizeDelta[axis]) * alignmentOnAxis;
SetChildAlongAxisWithScale(child, axis, pos + offsetInCell, scaleFactor);
}
pos += childSize * scaleFactor + spacing;
}

遍历每一个子元素(对子元素来说是按照从上到下的顺序),然后分为以下几个步骤处理:

  • 使用 GetChildSizes 计算当前方向上需要的各个尺寸大小

  • 根据上一步计算得到的 minMaxLerp 插值对尺寸大小进行处理

  • 如果子元素设置了 flexibleSize,使用前面计算得到的 itemFlexibleMultiplier 和 flexibleSize 去计算子元素需要分配的额外尺寸,并累加到子元素的尺寸大小 childSize

  • 设置子元素的大小以及位置,根据是否需要控制子元素的大小调用 SetChildAlongAxisWithScale 方法不同的重载实现

  • 位置 pos 累加,继续循环计算下一个子元素开始布局

  • 为子元素计算布局的方向与当前 Layout Group 本身方向不同

当 Layout Rebuilder 在发起重新构建过程中,执行完 SetLayoutHorizontal 方法后紧接着执行 SetLayoutVertical 方法,进而同样也是调用 SetChildrenAlongAxis 方法;此时的 Layout Group 本身的方向是水平的,同时在垂直方向上为各个子元素计算布局,因此属性 alongOtherAxis 值为 true

首先计算当前 Layout Group 除去 padding 之后的可用空间 innerSize:

1
float innerSize = size - (axis == 0 ? padding.horizontal : padding.vertical);

然后就是计算子元素的另一个方向上布局部分,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for (int i = 0; i < rectChildren.Count; i++)
{
RectTransform child = rectChildren[i];
float min, preferred, flexible;
GetChildSizes(child, axis, controlSize, childForceExpandSize, out min, out preferred, out flexible);
float scaleFactor = useScale ? child.localScale[axis] : 1f;
float requiredSpace = Mathf.Clamp(innerSize, min, flexible > 0 ? size : preferred);
float startOffset = GetStartOffset(axis, requiredSpace * scaleFactor);
if (controlSize)
{
SetChildAlongAxisWithScale(child, axis, startOffset, requiredSpace, scaleFactor);
}
else
{
float offsetInCell = (requiredSpace - child.sizeDelta[axis]) * alignmentOnAxis;
SetChildAlongAxisWithScale(child, axis, startOffset + offsetInCell, scaleFactor);
}
}

遍历所有的子元素,对于每个子元素垂直方向上的布局计算同样大致有以下几步:

  • 使用 GetChildSizes 计算当前方向上(与上面遍历的相对的另一个方向)需要的各个尺寸大小

  • 根据得到的子元素的 minSize、preferredSize、flexibleSize 以及当前 Layout Group 这个方向上的尺寸 size 去计算子元素需要的尺寸大小 requiredSpace,这里又分为几种情况:

    • requiredSpace 最小值是子元素设置的 minSize

    • 如果子元素设置了 flexibleSize,那么 requiredSpace 最大值是 Layout Group 的尺寸大小,否则 requiredSpace 最大值是子元素设置的 preferredSize;

    • Layout Group 除去 padding 之后的可用空间 innerSize 被限定在上面最大值和最小值之间的值就是最终的 requiredSpace

  • 根据 requiredSpace 和 scaleFactor 调用 GetStartOffset 方法计算子元素在这个方向上的位置偏移量

  • 最后根据 LayoutGroup 组件是否控制子元素的尺寸调用 SetChildAlongAxisWithScale 方法不同的重载实现来设置子元素当前方向上的布局

到这里当前这个方向上的子元素布局计算也就完成了,可以看出在当前进行的“另一个”方向(相对上一次当前方法被调用设置的方向)为子元素布局的过程中,子元素在这个方向上的位置偏移并没有累加上一个子元素的尺寸。这也是很好理解的,比如我们分析的 Horizontal Layout Group 的两次计算,由于这个 Layout Group 本身是水平方向依次排列子元素,因此在第一次调用 SetChildrenAlongAxis 方法计算水平方向上子元素的布局时,需要循环累加每个子元素的尺寸作为下一个子元素位置的偏移量,这样才能达到依次排开的效果;而在第二次调用 SetChildrenAlongAxis 方法计算垂直方向上子元素的布局时,由于垂直方向上不用依次上下排开,所有就是为每个子元素单独去计算其位置偏移量。

上面的计算过程对于 Vertical Layout Group 也适用,只不过计算方向相反。

HorizontalOrVerticalLayoutGroup 类主要的成员属性以及主要的方法都分析完了,作为 HorizontalLayoutGroup 和 VerticalLayoutGroup 的抽象基类,这些成员属性和方法实现了这两个类主要的功能,剩下的留给那两个类的工作就是串联整个流程并实现对其子元素的布局操作。

HorizontalLayoutGroup

HorizontalLayoutGroup 组件可以被添加到一个 GameObject 上,用来控制其子元素依次水平排布,在 Unity 编辑器中可以配置相关属性,如下:

HorizontalLayoutGroup 类中覆写了四个方法:

CalculateLayoutInputHorizontal 方法

1
2
3
4
5
public override void CalculateLayoutInputHorizontal()
{
base.CalculateLayoutInputHorizontal();
CalcAlongAxis(0, false);
}

调用父类(这里是 LayoutGroup 类)的 CalculateLayoutInputHorizontal 方法查找需要处理布局的子元素,然后调用父类(这里是 HorizontalOrVerticalLayoutGroup 类)的 CalcAlongAxis 方法计算当前 Layout Group 水平方向上布局子元素所需要的一些数据。

SetLayoutHorizontal 方法

1
2
3
4
public override void SetLayoutHorizontal()
{
SetChildrenAlongAxis(0, false);
}

调用父类(这里是 HorizontalOrVerticalLayoutGroup 类)的 SetChildrenAlongAxis 方法在水平方向布局子元素。

CalculateLayoutInputVertical 方法和 SetLayoutVertical 方法

这两个方法也是分别调用父类的 CalcAlongAxisSetChildrenAlongAxis 方法在垂直方向上布局子元素。

当系统发起重新构建时,这四个方法会依次被调用。最后看看 Horizontal Layout Group 布局计算过程中的几个关键点:

  • 把所有子元素的 minWidth 和 spacing 相加得到 Horizontal Layout Group 的 totalMinWidth

  • 同样累加的方式计算 Horizontal Layout Group 的 totalPreferredWidth

  • 同样累加的方式(spacing 不累加)计算 totalFlexibleWidth

  • Horizontal Layout Group 的宽如果小于或等于它的 totalMinWidth,所有子元素的宽也是它们各自的 minWidth(由插值 minMaxLerp 决定)

  • Horizontal Layout Group 的宽越接近 totalPreferredWidth,所有子元素的宽也越接近它们各自的 preferredWidth(由插值 minMaxLerp 决定)

  • 如果 Horizontal Layout Group 当前方向上的尺寸比 totalPreferredWidth 还宽,那么会给所有子元素依据其 flexibleWidth 分配剩余空间

VerticalLayoutGroup

VerticalLayoutGroup 组件可以被添加到一个 GameObject 上,用来控制其子元素依次垂直排布,在 Unity 编辑器中可以配置相关属性,如下:

VerticalLayoutGroup 类同样覆写了那四个方法,被 Auto Layout 系统调用的顺序也和 HorizontalLayoutGroup 中一样。由于 Vertical Layout Group 自身是用来垂直布局子元素的,因此首先水平方向计算中自身的 totalMinSize、totalPreferredSize 和 totalFlexibleSize 都不会累加,子元素布局的位置在水平方向也不会循环累加所有子元素尺寸依次排开,只有在后面开始计算垂直方向布局时才会进行累加、依次排布操作。

同样最后看看 Vertical Layout Group 布局计算过程中的几个关键点:

  • 把所有子元素的 minHeight 和 spacing 相加得到 Vertical Layout Group 的 totalMinHeight

  • 同样累加的方式计算 Vertical Layout Group 的 totalPreferredHeight

  • 同样累加的方式(spacing 不累加)计算 totalFlexibleWidth

  • Vertical Layout Group 的高如果小于或等于它的 totalMinHeight,所有子元素的高也是它们各自的 minHeight(由插值 minMaxLerp 决定)

  • Vertical Layout Group 的高越接近 totalPreferredHeight,所有子元素的高也是它们各自的 preferredHeight(由插值 minMaxLerp 决定)

  • 如果 Vertical Layout Group 当前方向上的尺寸比 totalPreferredHeight 还高,那么会给所有子元素依据其 flexibleHeight 分配剩余空间

LayoutRebuilder 中的一些问题

关于 Rebuild

LayoutRebuilder 类的 Rebuild 方法是处理所有 Layout 重新构建的地方,代码如下:

1
2
3
4
5
6
7
8
public void Rebuild(CanvasUpdate executing)
{
// other code ...
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputHorizontal());
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutHorizontal());
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputVertical());
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutVertical());
}

主要调用了 PerformLayoutCalculationPerformLayoutControl 方法,这两个方法分别用于计算 Layout Group 需要的尺寸数据以及用于布局子元素;同时这两个都被调用了两次,分别用于两个方向上的布局。

PerformLayoutCalculation 方法

这个方法就是用来计算 Layout Element 的 minSize、preferredSize 和 flexibleSize 等数据,我们可以看一下具体代码看看计算过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void PerformLayoutCalculation(RectTransform rect, UnityAction<Component> action)
{
// other code ...
rect.GetComponents(typeof(ILayoutElement), components);
if (components.Count > 0 || rect.GetComponent(typeof(ILayoutGroup)))
{
for (int i = 0; i < rect.childCount; i++)
PerformLayoutCalculation(rect.GetChild(i) as RectTransform, action);
for (int i = 0; i < components.Count; i++)
action(components[i]);
}
}

注意看这里面的代码,是为子元素递归调用的当前方法来计算所需数据,这样做原因是父元素计算尺寸大小时需要依赖子元素的尺寸大小,因此子元素需要先计算。最终计算调用的是 Layout Element 的 CalculateLayoutInputHorizontal 方法。

PerformLayoutControl 方法

调用这个方法用于为子元素布局,还是先看看具体的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void PerformLayoutControl(RectTransform rect, UnityAction<Component> action)
{
// other code ...
rect.GetComponents(typeof(ILayoutController), components);
if (components.Count > 0)
{
for (int i = 0; i < components.Count; i++)
if (components[i] is ILayoutSelfController)
action(components[i]);
for (int i = 0; i < components.Count; i++)
if (!(components[i] is ILayoutSelfController))
action(components[i]);
for (int i = 0; i < rect.childCount; i++)
PerformLayoutControl(rect.GetChild(i) as RectTransform, action);
}
}

代码中首先为实现了 ILayoutSelfController 接口的组件所在 UI 元素的子元素布局,紧接着为实现了 ILayoutGroup 接口的组件所在 UI 元素的子元素布局,最后为子元素递归调用这个方法,继续为子元素中的元素去计算布局。这里的顺序和上面 PerformLayoutControl 方法中顺序相反,这是因为子元素布局有可能需要使用父元素的 availableSize,因此需要先计算父元素的布局信息再计算子元素的布局。

当水平方向和垂直方向(注意水平方向计算在前,垂直方向计算在后)都经过这两个步骤,Layout Elements 就拥有了新的尺寸大小和位置信息。

关于 Rebuild 中的 Layout Element 和 Layout Controller

在 LayoutRebuilder 的 MarkLayoutForRebuild 方法中,如果一个 UI 元素中同时绑定了实现了 ILayoutController 接口的组件和实现了 ILayoutElement 接口的组件,那么这个 UI 元素会被添加到 CanvasUpdateRegistry 中(比如绑定了 HorizontalLayoutGroup 组件的 UI 元素、或者绑定了 Text 组件和 ContentSizeFilter 组件的 UI 元素),当触发重新构建的时候 Auto Layout 系统又会根据这些组件以及其相应的设置去计算自身或子元素的布局数据。

LayoutUtility 类

在前面的代码分析中,很多地方都使用了这个类中的辅助方法,这些方法用于计算 Layout Element 的 minimum、preferred 和 flexible sizes 等数据。

下面就以 GetMinSize 方法为例子去分析这个类中的方法是如何得到需要的数据的,这个方法代码如下:

1
2
3
4
5
6
public static float GetMinSize(RectTransform rect, int axis)
{
if (axis == 0)
return GetMinWidth(rect);
return GetMinHeight(rect);
}

通过一层层跳转,最后调用的是 GetLayoutProperty 去计算,这个方法用来获取一个 UI 元素上布局所需的属性信息,它有四个参数:

Parameter Description
rect 获取 Layout 属性的 UI 元素所在的 RectTransform
property 需要计算的属性
defaultValue 没有找到实现了 ILayoutElement 接口的组件时使用这个默认值
source 输出参数,得到布局属性的那个实现了 ILayoutElement 接口的组件
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
26
27
28
rect.GetComponents(typeof(ILayoutElement), components);
for (int i = 0; i < components.Count; i++)
{
var layoutComp = components[i] as ILayoutElement;
if (layoutComp is Behaviour && !((Behaviour)layoutComp).isActiveAndEnabled)
continue;
int priority = layoutComp.layoutPriority;
if (priority < maxPriority)
continue;
float prop = property(layoutComp);
if (prop < 0)
continue;
if (priority > maxPriority)
{
min = prop;
maxPriority = priority;
source = layoutComp;
}
else if (prop > min)
{
min = prop;
source = layoutComp;
}
}

上面给出了主要的代码,从当前 UI 元素的所有实现了 ILayoutElement 接口的组件中取寻找。这里首先判断了 layoutPriority 这个属性,前面文章中讲到过这个优先级越大可以保证其所在的 ILayoutElement 接口组件的值被优先使用,这里刚好证明了这一点;接下来是获取属性,如果属性小于 0 直接被忽略;最后保存优先级大的 ILayoutElement 接口组件所在的属性值,如果优先级和之前的 ILayoutElement 接口组件 相等,则使用属性值更大的那个保存。

参考