前言

在上一篇中我们分析了 LayoutRebuilder 的实现,了解了一个 UI 元素是如何将自身注册到 LayoutSystem 中的。但 LayoutRebuilder 所做的事情仅仅是在 Rebuild 函数中调用 ILayoutElementILayoutController 接口来实现布局的更新,而真正的布局计算是由 LayoutElementLayoutGroup 来完成的。

本篇将介绍 LayoutElementLayoutGroup 的实现,关于这些布局组件的使用请参考 LayoutSystem

LayoutElement

LayoutElement 脚本用于控制 UI 元素的布局属性,包括最小宽高、首选宽高、弹性宽高等。通过添加该脚本,可以覆盖已有的布局属性,使 UI 的布局更加灵活。接下来让我们逐行分析 LayoutElement 的源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class LayoutElement : UIBehaviour, ILayoutElement, ILayoutIgnorer
{
[SerializeField] private bool m_IgnoreLayout = false;
[SerializeField] private float m_MinWidth = -1;
[SerializeField] private float m_MinHeight = -1;
[SerializeField] private float m_PreferredWidth = -1;
[SerializeField] private float m_PreferredHeight = -1;
[SerializeField] private float m_FlexibleWidth = -1;
[SerializeField] private float m_FlexibleHeight = -1;
[SerializeField] private int m_LayoutPriority = 1;

// ...
}

首先,可以看到 LayoutElement 继承自 UIBehaviour,实现了 ILayoutElementILayoutIgnorer 接口。ILayoutElement 接口定义了布局元素的属性,ILayoutIgnorer 接口用于控制是否忽略布局。

接口的实现方面,对于 ILayoutElement 接口,LayoutElement 实现了最小宽高、首选宽高、弹性宽高等属性。这些属性在 LayoutGroup 中会被用来计算布局。此外,CalculateLayoutInputHorizontalCalculateLayoutInputVertical 函数均为空实现,这意味着 LayoutElement 的布局属性不需要计算,而是直接使用用户设置的值。

1
2
3
4
5
6
7
8
9
10
public virtual float minWidth { get { return m_MinWidth; } set { if (SetPropertyUtility.SetStruct(ref m_MinWidth, value)) SetDirty(); } }
public virtual float minHeight { get { return m_MinHeight; } set { if (SetPropertyUtility.SetStruct(ref m_MinHeight, value)) SetDirty(); } }
public virtual float preferredWidth { get { return m_PreferredWidth; } set { if (SetPropertyUtility.SetStruct(ref m_PreferredWidth, value)) SetDirty(); } }
public virtual float preferredHeight { get { return m_PreferredHeight; } set { if (SetPropertyUtility.SetStruct(ref m_PreferredHeight, value)) SetDirty(); } }
public virtual float flexibleWidth { get { return m_FlexibleWidth; } set { if (SetPropertyUtility.SetStruct(ref m_FlexibleWidth, value)) SetDirty(); } }
public virtual float flexibleHeight { get { return m_FlexibleHeight; } set { if (SetPropertyUtility.SetStruct(ref m_FlexibleHeight, value)) SetDirty(); } }
public virtual int layoutPriority { get { return m_LayoutPriority; } set { if (SetPropertyUtility.SetStruct(ref m_LayoutPriority, value)) SetDirty(); } }

public virtual void CalculateLayoutInputHorizontal() {}
public virtual void CalculateLayoutInputVertical() {}

对于 ILayoutIgnorer 接口,LayoutElement 实现了 ignoreLayout 属性,用于控制是否忽略布局。

1
public virtual bool ignoreLayout { get { return m_IgnoreLayout; } set { if (SetPropertyUtility.SetStruct(ref m_IgnoreLayout, value)) SetDirty(); } }

SetPropertyUtility

SetPropertyUtility 是一个工具类,用于设置不同类型的属性值,如果新值与旧值不同,则返回 true,否则返回 false。这样做的目的是为了避免重复设置相同的值,减少不必要的计算。

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
internal static class SetPropertyUtility
{
public static bool SetColor(ref Color currentValue, Color newValue)
{
if (currentValue.r == newValue.r && currentValue.g == newValue.g && currentValue.b == newValue.b && currentValue.a == newValue.a)
return false;

currentValue = newValue;
return true;
}

public static bool SetStruct<T>(ref T currentValue, T newValue) where T : struct
{
if (EqualityComparer<T>.Default.Equals(currentValue, newValue))
return false;

currentValue = newValue;
return true;
}

public static bool SetClass<T>(ref T currentValue, T newValue) where T : class
{
if ((currentValue == null && newValue == null) || (currentValue != null && currentValue.Equals(newValue)))
return false;

currentValue = newValue;
return true;
}
}

可以看出,SetPropertyUtility 分别针对 Colorstructclass 类型的属性提供了不同的设置方法。值得注意的是 SetStructSetClass 方法,它们分别用于值类型和引用类型的属性设置,关于 C# 中的值类型和引用类型可以参考 C# 值类型和引用类型

值类型的比较是通过 EqualityComparer<T>.Default.Equals 方法实现的,而引用类型的比较则是通过类内部的 Equals 方法实现的。这里的 Equals 方法是由 object 类提供的虚方法,可以被子类重写,用于比较两个对象是否相等。

SetDirty

SetDirty 函数用于标记布局元素需要重新计算布局,这里实际上是一种 “脏标记” 的设计模式,被标记的元素会被添加到布局系统的更新队列中,等待下一次的布局计算而不是立刻更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// Mark the LayoutElement as dirty.
/// </summary>
/// <remarks>
/// This will make the auto layout system process this element on the next layout pass. This method should be called by the LayoutElement
/// whenever a change is made that potentially affects the layout.
/// </remarks>
protected void SetDirty()
{
if (!IsActive())
return;
LayoutRebuilder.MarkLayoutForRebuild(transform as RectTransform);
}

其他

LayoutElement 还实现了一些在 UIBehaviour 中的生命周期函数,逻辑几乎都是调用 SetDirty 函数,用于触发一次布局更新。

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
protected override void OnEnable()
{
base.OnEnable();
SetDirty();
}

protected override void OnTransformParentChanged()
{
SetDirty();
}

protected override void OnDisable()
{
SetDirty();
base.OnDisable();
}

protected override void OnDidApplyAnimationProperties()
{
SetDirty();
}

protected override void OnBeforeTransformParentChanged()
{
SetDirty();
}

LayoutGroup

LayoutGroup 是一个抽象类,用于控制子物体的布局。LayoutGroup 会根据布局属性来计算子物体的大小和位置。LayoutGroup 有三个子类:HorizontalLayoutGroupVerticalLayoutGroupGridLayoutGroup,分别用于水平布局、垂直布局和网格布局。

LayoutGroup 的类图如下:

image.png

让我们逐一分析。

LayoutGroup

LayoutGroup 是所有布局组件的基类,继承自 UIBehaviour,实现了 ILayoutElementILayoutController 接口。

成员属性

LayoutGroup 定义了一些布局属性,包括填充、布局方向等。这些属性会影响子物体的布局。

1
2
3
4
5
6
7
8
public abstract class LayoutGroup : UIBehaviour, ILayoutElement, ILayoutGroup
{
[SerializeField] protected RectOffset m_Padding = new RectOffset();
public RectOffset padding { get { return m_Padding; } set { SetProperty(ref m_Padding, value); } }

[SerializeField] protected TextAnchor m_ChildAlignment = TextAnchor.UpperLeft;
public TextAnchor childAlignment { get { return m_ChildAlignment; } set { SetProperty(ref m_ChildAlignment, value); } }
}

上述的属性在各种布局组件中都有所体现。

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[System.NonSerialized] private RectTransform m_Rect;
protected RectTransform rectTransform
{
get
{
if (m_Rect == null)
m_Rect = GetComponent<RectTransform>();
return m_Rect;
}
}

protected DrivenRectTransformTracker m_Tracker;

private Vector2 m_TotalMinSize = Vector2.zero;
private Vector2 m_TotalPreferredSize = Vector2.zero;
private Vector2 m_TotalFlexibleSize = Vector2.zero;

此外,LayoutGroup 还维护了自身的 RectTransformDrivenRectTransformTracker 和布局计算所需的 min、preferred、flexible 大小。

需要注意的是 DrivenRectTransformTracker,在 Unity 官方文档 中可以得知:

DrivenRectTransformTracker 结构用于指定其正在驱动的 RectTransform

驱动 RectTransform 意味着被驱动 RectTransform 的值由该组件控制。这些被驱动的值无法在 Inspector 中加以编辑(它们显示为已禁用)。此外在保存场景时也不会保存它们,这可防止不需要的场景文件更改。

无论何时该组件正在更改被驱动 RectTransform 的值,其都应首先调用 Clear 方法,然后使用 Add 方法来添加其正在驱动的所有 RectTransform。此外还应在该组件的 OnDisable 回调中调用 Clear 方法。

总体而言就是实现下图的效果:

image.png

还有一些用于用于返回布局属性的函数,值得注意的是 layoutPriority 的返回值被指定为 0。

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
/// <summary>
/// See LayoutElement.minWidth
/// </summary>
public virtual float minWidth { get { return GetTotalMinSize(0); } }

/// <summary>
/// See LayoutElement.preferredWidth
/// </summary>
public virtual float preferredWidth { get { return GetTotalPreferredSize(0); } }

/// <summary>
/// See LayoutElement.flexibleWidth
/// </summary>
public virtual float flexibleWidth { get { return GetTotalFlexibleSize(0); } }

/// <summary>
/// See LayoutElement.minHeight
/// </summary>
public virtual float minHeight { get { return GetTotalMinSize(1); } }

/// <summary>
/// See LayoutElement.preferredHeight
/// </summary>
public virtual float preferredHeight { get { return GetTotalPreferredSize(1); } }

/// <summary>
/// See LayoutElement.flexibleHeight
/// </summary>
public virtual float flexibleHeight { get { return GetTotalFlexibleSize(1); } }

/// <summary>
/// See LayoutElement.layoutPriority
/// </summary>
public virtual int layoutPriority { get { return 0; } }

生命周期相关函数

LayoutElement 类似,LayoutGroup 也实现了一些生命周期相关函数,用于触发布局更新。不同之处在于,LayoutGroupSetDirty 函数会根据当前的 CanvasUpdateRegistry 状态来决定是否需要将布局延迟到下一帧。

也正是因此,在 OnDisable 函数中才不能直接调用 SetDirty 函数,因为无需等待下一帧。且可以发现,OnDisable 函数中的确将 m_Tracker 做了清空操作。

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
37
protected override void OnEnable()
{
base.OnEnable();
SetDirty();
}

protected override void OnDisable()
{
m_Tracker.Clear();
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
base.OnDisable();
}

/// <summary>
/// Callback for when properties have been changed by animation.
/// </summary>
protected override void OnDidApplyAnimationProperties()
{
SetDirty();
}

protected void SetDirty()
{
if (!IsActive())
return;

if (!CanvasUpdateRegistry.IsRebuildingLayout())
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
else
StartCoroutine(DelayedSetDirty(rectTransform));
}

IEnumerator DelayedSetDirty(RectTransform rectTransform)
{
yield return null;
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
}

ILayoutElement 接口实现

LayoutGroup 实现了 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
29
30
31
32
33
34
35
36
37
[System.NonSerialized] private List<RectTransform> m_RectChildren = new List<RectTransform>();
protected List<RectTransform> rectChildren { get { return m_RectChildren; } }

// ILayoutElement Interface
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();
}

public abstract void CalculateLayoutInputVertical();

CalculateLayoutInputHorizontal 函数中,会收集所有需要布局的子物体,并将其保存在 m_RectChildren 中。有几点需要注意:

  1. 如 Unity 文档所言,在设置子物体的布局(即驱动 RectTransform)之前,的确调用了 m_Tracker.Clear() 方法以清空上一轮的布局设置。

  2. 对于 ILayoutIgnorer 的处理逻辑为:只有当子物体上所有实现了 ILayoutIgnorer 接口的组件都设置了 ignoreLayouttrue 时,才会忽略该子物体的布局。否则会正常布局该子物体。

  3. 由于 LayoutRebuilder 的处理顺序为先水平布局再垂直布局,而此处计算布局的逻辑和方向无关,只是收集子物体。因此只实现了 CalculateLayoutInputHorizontal 函数,CalculateLayoutInputVertical 函数为空实现。

布局设置相关函数

虽然 LayoutGroup 实现了 ILayoutController 接口,但 SetLayoutHorizontalSetLayoutVertical 函数均为空实现。LayoutGroup 仅实现了布局设置的一些通用功能函数。

  1. GetStartOffset

    GetStartOffset 函数用于计算子物体在给定轴上的起始位置。

    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
    /// <summary>
    /// Returns the calculated position of the first child layout element along the given axis.
    /// </summary>
    /// <param name="axis">The axis index. 0 is horizontal and 1 is vertical.</param>
    /// <param name="requiredSpaceWithoutPadding">The total space required on the given axis for all the layout elements including spacing and excluding padding.</param>
    /// <returns>The position of the first child along the given axis.</returns>
    protected float GetStartOffset(int axis, float requiredSpaceWithoutPadding)
    {
    float requiredSpace = requiredSpaceWithoutPadding + (axis == 0 ? padding.horizontal : padding.vertical);
    float availableSpace = rectTransform.rect.size[axis];
    float surplusSpace = availableSpace - requiredSpace;
    float alignmentOnAxis = GetAlignmentOnAxis(axis);
    return (axis == 0 ? padding.left : padding.top) + surplusSpace * alignmentOnAxis;
    }

    /// <summary>
    /// Returns the alignment on the specified axis as a fraction where 0 is left/top, 0.5 is middle, and 1 is right/bottom.
    /// </summary>
    /// <param name="axis">The axis to get alignment along. 0 is horizontal and 1 is vertical.</param>
    /// <returns>The alignment as a fraction where 0 is left/top, 0.5 is middle, and 1 is right/bottom.</returns>
    protected float GetAlignmentOnAxis(int axis)
    {
    if (axis == 0)
    return ((int)childAlignment % 3) * 0.5f;
    else
    return ((int)childAlignment / 3) * 0.5f;
    }

    其中,alignmentOnAxis 是一个值为 0、0.5、1 的浮点数,用于表示子物体在给定轴上的对齐方式。GetAlignmentOnAxis 函数用于计算该值,实现得十分巧妙。

    ChildAlignment 是一个 TextAnchor 类型的枚举,定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public enum TextAnchor
    {
    UpperLeft,
    UpperCenter,
    UpperRight,
    MiddleLeft,
    MiddleCenter,
    MiddleRight,
    LowerLeft,
    LowerCenter,
    LowerRight
    }

    通过取余和取整操作,可以将 TextAnchor 转换为 alignmentOnAxis 的值,并用于计算子物体的起始位置。

  2. SetLayoutInputForAxis

    SetLayoutInputForAxis 用于更新布局属性,包括最小宽高、首选宽高、弹性宽高等。

    1
    2
    3
    4
    5
    6
    protected void SetLayoutInputForAxis(float totalMin, float totalPreferred, float totalFlexible, int axis)
    {
    m_TotalMinSize[axis] = totalMin;
    m_TotalPreferredSize[axis] = totalPreferred;
    m_TotalFlexibleSize[axis] = totalFlexible;
    }
  3. SetChildAlongAxis

    SetChildAlongAxis 用于设置子物体在给定轴上的位置和大小,包括了两个重载版本,第二个版本可以额外接收一个 size 参数,用于设置子物体的大小,适用于需要改变子物体大小的情况。

    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
    protected void SetChildAlongAxis(RectTransform rect, int axis, float pos)
    {
    if (rect == null)
    return;

    SetChildAlongAxisWithScale(rect, axis, pos, 1.0f);
    }

    protected void SetChildAlongAxisWithScale(RectTransform rect, int axis, float pos, float scaleFactor)
    {
    if (rect == null)
    return;

    m_Tracker.Add(this, rect,
    DrivenTransformProperties.Anchors |
    (axis == 0 ? DrivenTransformProperties.AnchoredPositionX : DrivenTransformProperties.AnchoredPositionY));

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

    可以看出,在 SetChildAlongAxisWithScale 中,会将该子物体的 RectTransform 添加到 m_Tracker 中,表示该子物体的位置和大小由当前布局组件控制。然后根据传入的值设置子物体的位置和大小。但在 SetChildAlongAxis 中,scaleFactor 被固定为 1.0,因此只对子物体有移动的效果。

    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
    protected void SetChildAlongAxis(RectTransform rect, int axis, float pos, float size)
    {
    if (rect == null)
    return;

    SetChildAlongAxisWithScale(rect, axis, pos, size, 1.0f);
    }

    protected void SetChildAlongAxisWithScale(RectTransform rect, int axis, float pos, float size, float scaleFactor)
    {
    if (rect == null)
    return;

    m_Tracker.Add(this, rect,
    DrivenTransformProperties.Anchors |
    (axis == 0 ?
    (DrivenTransformProperties.AnchoredPositionX | DrivenTransformProperties.SizeDeltaX) :
    (DrivenTransformProperties.AnchoredPositionY | DrivenTransformProperties.SizeDeltaY)
    )
    );

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

    在第二个版本的重载中,除了设置 rect.anchoredPosition 外,还设置了 rect.sizeDelta,用于改变子物体的大小。

    两个重载函数的 m_Tracker 设置并不相同,代表了子物体被布局组件控制的程度不同,以一个例子来说明:

    image.png

    当父物体的 HorizontalLayoutGroup 不勾选 Control Child Size 时,子物体虽然 PosX 与 PosY 均被布局组件控制,但 Width 与 Height 仍然由子物体自身控制。此时在设置布局时,调用的就是没有 size 参数的 SetChildAlongAxisWithScale 函数。

    image.png

    当父物体的 HorizontalLayoutGroup 勾选 Control Child Size 时,子物体的 Width 与 Height 也被布局组件控制。在设置布局时,调用的就是有 size 参数的 SetChildAlongAxisWithScale 函数。

SetProperty

SetProperty 函数用于设置布局属性,如果新值与旧值不同,则返回 true,否则返回 false。这里的 SetProperty 函数与 SetPropertyUtility 类似,用于避免重复设置相同的值。

1
2
3
4
5
6
7
protected void SetProperty<T>(ref T currentValue, T newValue)
{
if ((currentValue == null && newValue == null) || (currentValue != null && currentValue.Equals(newValue)))
return;
currentValue = newValue;
SetDirty();
}

在设置完布局属性后,会调用 SetDirty 函数,标记布局组件需要重新计算布局。感觉这一部分的逻辑设计有些冗余,SetProperty 函数的逻辑与 SetPropertyUtility 函数的逻辑几乎一致,但这里似乎是为了更广泛的泛型考虑以及 SetDirty 函数的调用。

小结

LayoutGroup 是所有布局组件的基类,实现了一些布局设置的通用功能函数,如 CalculateLayoutInputHorizontal 用于收集子物体,SetLayoutInputForAxis 用于更新布局属性,SetChildAlongAxis 用于设置子物体的位置和大小。LayoutGroup 的子类会使用这些函数并真正地实现 SetLayoutHorizontalSetLayoutVertical 接口,用以实现具体的布局逻辑。

LayoutUtility

LayoutUtility 是一个工具类,用于获取给定 RectTransform 的布局属性,与直接访问 ILayoutElement 接口相比,LayoutUtility 提供了更多数值上的处理,此外还处理了一个 RectTransform 上有多个 ILayoutElement 组件的情况。

GetLayoutProperty

GetLayoutProperty 函数用于获取给定 RectTransform 的布局属性,函数内会处理多个 ILayoutElement 组件的情况,当一个 RectTransform 上有多个 ILayoutElement 组件时,会返回 LayoutPriority 最高的组件中最大的属性

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
37
38
39
40
41
42
43
44
45
46
public static float GetLayoutProperty(RectTransform rect, System.Func<ILayoutElement, float> property, float defaultValue, 
out ILayoutElement source)
{
source = null;
if (rect == null)
return 0;
float min = defaultValue;
int maxPriority = System.Int32.MinValue;
var components = ListPool<Component>.Get();
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 this layout components has lower priority than a previously used, ignore it.
if (priority < maxPriority)
continue;
float prop = property(layoutComp);
// If this layout property is set to a negative value, it means it should be ignored.
if (prop < 0)
continue;

// If this layout component has higher priority than all previous ones,
// overwrite with this one's value.
if (priority > maxPriority)
{
min = prop;
maxPriority = priority;
source = layoutComp;
}
// If the layout component has the same priority as a previously used,
// use the largest of the values with the same priority.
else if (prop > min)
{
min = prop;
source = layoutComp;
}
}

ListPool<Component>.Release(components);
return min;
}

首先,可以由函数签名看到,GetLayoutProperty 实际上可以获取任意属性,只需要传入一个 Func<ILayoutElement, float> 类型的委托即可。其次,函数内部会遍历 RectTransform 上的所有 ILayoutElement 组件,找到 LayoutPriority 最高的组件中最大的属性值。

需要注意的是,这里会直接忽略属性值小于 0 的组件,当一个组件的 minSize 等属性设置为负数时,表示忽略该组件的布局。

1
2
3
4
5
public static float GetLayoutProperty(RectTransform rect, System.Func<ILayoutElement, float> property, float defaultValue)
{
ILayoutElement dummy;
return GetLayoutProperty(rect, property, defaultValue, out dummy);
}

该函数还有一个重载版本,用于获取给定 RectTransform 的布局属性,但不需要输出 ILayoutElement 组件。

其他函数

LayoutUtility 向外提供的其他静态函数全都基于 GetLayoutProperty 实现,如 GetMinSizeGetPreferredSizeGetFlexibleSize 等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static float GetMinSize(RectTransform rect, int axis)
{
if (axis == 0)
return GetMinWidth(rect);
return GetMinHeight(rect);
}

public static float GetMinWidth(RectTransform rect)
{
return GetLayoutProperty(rect, e => e.minWidth, 0);
}

public static float GetMinHeight(RectTransform rect)
{
return GetLayoutProperty(rect, e => e.minHeight, 0);
}

可以看到全部属性的默认返回值均被设置为 0,即不会占据布局控件。

此外,在获取 PreferredSize 时,做了一些特殊处理:

1
2
3
4
public static float GetPreferredWidth(RectTransform rect)
{
return Mathf.Max(GetLayoutProperty(rect, e => e.minWidth, 0), GetLayoutProperty(rect, e => e.preferredWidth, 0));
}

该函数并不会直接返回 preferredWidth,而是返回 minWidthpreferredWidth 中的最大值

HorizontalOrVerticalLayoutGroup

HorizontalOrVerticalLayoutGroupLayoutGroup 的子类,用于实现水平和垂直布局。在类中实现了两个重要的函数: CalcAlongAxisSetChildrenAlongAxis 用于在不同的轴上计算和设置子物体的位置与大小。派生自该类的 HorizontalLayoutGroupVerticalLayoutGroup 只需要以不同的方式调用这两个函数即可实现水平和垂直布局。

从代码结构的角度思考,在 LayoutGroup 与具体的 xxxLayoutGroup 之间再增加一层抽象层是一种很好的设计,这样可以将更多可复用的逻辑放在 HorizontalOrVerticalLayoutGroup 中,避免了大量的冗余代码,且具备了更好的可维护性与可扩展性。

布局属性

HorizontalOrVerticalLayoutGroup 提供了一系列的可配置属性,用于规定布局时的行为,这些属性在 LayoutSystem 的使用 中有详细介绍。

image.png

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
public abstract class HorizontalOrVerticalLayoutGroup : LayoutGroup
{
[SerializeField] protected float m_Spacing = 0;
public float spacing { get { return m_Spacing; } set { SetProperty(ref m_Spacing, value); } }

[SerializeField] protected bool m_ChildForceExpandWidth = true;
public bool childForceExpandWidth { get { return m_ChildForceExpandWidth; } set { SetProperty(ref m_ChildForceExpandWidth, value); } }

[SerializeField] protected bool m_ChildForceExpandHeight = true;
public bool childForceExpandHeight { get { return m_ChildForceExpandHeight; } set { SetProperty(ref m_ChildForceExpandHeight, value); } }

[SerializeField] protected bool m_ChildControlWidth = true;
public bool childControlWidth { get { return m_ChildControlWidth; } set { SetProperty(ref m_ChildControlWidth, value); } }

[SerializeField] protected bool m_ChildControlHeight = true;
public bool childControlHeight { get { return m_ChildControlHeight; } set { SetProperty(ref m_ChildControlHeight, value); } }

[SerializeField] protected bool m_ChildScaleWidth = false;
public bool childScaleWidth { get { return m_ChildScaleWidth; } set { SetProperty(ref m_ChildScaleWidth, value); } }

[SerializeField] protected bool m_ChildScaleHeight = false;
public bool childScaleHeight { get { return m_ChildScaleHeight; } set { SetProperty(ref m_ChildScaleHeight, value); } }

// ...
}

CalcAlongAxis

CalcAlongAxis 函数用于遍历子物体并计算在指定轴上 LayoutGroup 本身的 min、preferred、flexible 大小。

先说结论,CalcAlongAxis 函数的处理逻辑为:当要计算的轴与布局方向一致时,会将子物体的 min、preferred、flexible 大小累加,当要计算的轴与布局方向不一致时,会将子物体的 min、preferred、flexible 大小取最大值;最后会调用 SetLayoutInputForAxis 函数更新布局属性。

点击展开代码
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
protected void CalcAlongAxis(int axis, bool isVertical)
{
float combinedPadding = (axis == 0 ? padding.horizontal : padding.vertical);
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 totalMin = combinedPadding;
float totalPreferred = combinedPadding;
float totalFlexible = 0;

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;

// Increment flexible size with element's flexible size.
totalFlexible += flexible;
}
}

if (!alongOtherAxis && rectChildren.Count > 0)
{
totalMin -= spacing;
totalPreferred -= spacing;
}
totalPreferred = Mathf.Max(totalMin, totalPreferred);
SetLayoutInputForAxis(totalMin, totalPreferred, totalFlexible, axis);
}

接下来我们逐行分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected void CalcAlongAxis(int axis, bool isVertical)
{
float combinedPadding = (axis == 0 ? padding.horizontal : padding.vertical);
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 totalMin = combinedPadding;
float totalPreferred = combinedPadding;
float totalFlexible = 0;

bool alongOtherAxis = (isVertical ^ (axis == 1));

// ...
}

首先,根据 axisisVertical 参数,确定了一些布局属性,并且定义了 totalMintotalPreferredtotalFlexible 三个变量,用于保存布局组件的 min、preferred 和 flexible 大小。

比较有意思的是 alongOtherAxis 变量,该变量的含义是当前的计算方向与布局方向是否一致。这里使用了异或运算符 ^,由于 axis 为 0 时代表水平方向,为 1 时代表垂直方向,因此 isVertical ^ (axis == 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
26
27
28
29
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;

// Increment flexible size with element's flexible size.
totalFlexible += flexible;
}
}

不知道大家是否还记得 rectChildren 是何时被收集的?答案就是 LayoutGroup.CalculateLayoutInputHorizontal 函数。

接下来,会遍历所有的子物体,首先获取子物体的 min、preferred、flexible 大小,然后根据 useScale 变量,判断是否需要乘以子物体的缩放比例。

然后,根据计算轴是否与布局方向一致,分别计算 totalMintotalPreferredtotalFlexible 的值。当计算轴与布局方向不一致时,会取子物体中对应属性的最大值,否则会将子物体的 min、preferred、flexible 大小累加,这里还额外计算了 spacing 的值。

1
2
3
4
5
6
7
if (!alongOtherAxis && rectChildren.Count > 0)
{
totalMin -= spacing;
totalPreferred -= spacing;
}
totalPreferred = Mathf.Max(totalMin, totalPreferred);
SetLayoutInputForAxis(totalMin, totalPreferred, totalFlexible, axis);

随后会减去多计算的一次 spacing,并将 totalPreferred 的值设置为 totalMintotalPreferred 中的最大值。最后,调用 LayoutGroup.SetLayoutInputForAxis 函数更新布局属性。

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

看一下 GetChildSizes 函数的实现,该函数用于获取子物体的 min、preferred、flexible 大小。其中,controlSize 参数用于标识子物体的大小是否由布局组件控制,childForceExpand 参数用于标识子物体是否强制扩展大小。

如果子物体的大小不由布局组件控制,那么 min、preferred 大小均为子物体的 sizeDelta,flexible 大小为 0。否则,分别调用 LayoutUtility 中的对应函数获取子物体的大小。

这里需要注意,当强制扩展大小时,flexible 大小会取 flexible 与 1 中的最大值,即至少为 1。因此,小于 1 的 flexible 作用与 1 相同。这点在我们制作 UI 时要尤为注意。

SetChildrenAlongAxis

SetChildrenAlongAxis 函数用于设置子物体在指定轴上的位置和大小,这个函数还是挺长的,这里就不放完整代码了,让我们直接一步步地分析。

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

首先还是一些布局属性的获取,不知道大家是否还记得 GetAlignmentOnAxis,该函数定义在 LayoutGroup 中,返回值是一个 0、0.5、1 的浮点数,用于计算子物体的起始位置。

沿着其他轴排列

函数还是根据 alongOtherAxis 分为了两部分逻辑,先来看简单的沿着其他轴的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
float innerSize = size - (axis == 0 ? padding.horizontal : padding.vertical);
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);
}
}

当沿着其他轴排列时,布局的设置就比较简单了,无需考虑其他子物体的位置,只需要根据布局属性设置子物体的位置和大小即可。这里会根据 controlSize 的值,分别调用不同的 SetChildAlongAxisWithScale 重载。

可以看到,alignmentOnAxis 这一属性只在 controlSizefalse 时才会被使用。由于不调整子物体的大小,因此需要根据 alignmentOnAxis 来计算子物体的起始位置。而在 controlSizetrue 时,子物体的大小由布局组件控制,实际上全部都以 UpperLeft 的方式排列

沿着布局方向排列

至于沿着布局方向排列的情况,逻辑就复杂了许多,我们继续分析:

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
float pos = (axis == 0 ? padding.left : padding.top);
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);
}

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

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

首先,还是计算出了一些布局要用到的参数

1
2
3
float pos = (axis == 0 ? padding.left : padding.top);
float itemFlexibleMultiplier = 0;
float surplusSpace = size - GetTotalPreferredSize(axis);

其中,pos 代表子物体的起始位置,itemFlexibleMultiplier 代表每一单位的 flexible 能够分配到多少空间,surplusSpace 代表供 flexible 分配的剩余空间。

可能这里 flexible 的分配方式有些难以理解,我们以公式说明,假设所有子物体的 flexible 之和为 ftotalf_{\text{total}},剩余空间为 SS,当前子物体的 flexiblefif_i,则当前子物体能够分配到的额外空间 sis_i 为:

si=Sftotalfis_i = \frac{S}{f_{\text{total}}} \cdot f_i

这里预先计算处理的 itemFlexibleMultiplier 就是 Sftotal\frac{S}{f_{\text{total}}}

1
2
3
4
5
6
7
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);
}

接下来,根据剩余空间是否大于 0,以及 flexible 的总和是否大于 0,来移动起始位置以及决定是否需要分配额外空间。

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

这里又有一个数学计算,minMaxLerp 代表了能够为单个子物体分配的空间在 min 与 preferred 之间的比例。在遍历子物体的过程中,使用 Mathf.Lerp(min, preferred, minMaxLerp) 即可得到子物体在布局方向上的大小。

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

最后就是遍历子物体并设置子物体的位置和大小了,这里的逻辑与沿着其他轴排列的逻辑类似,但有两点不同:

  1. 子物体的大小由自身的大小及 minMaxLerpitemFlexibleMultiplier 这两个布局属性共同决定。

  2. 子物体的起始位置要进行排列,因此在遍历时会为 pos 加上子物体的大小和 spacing

小结

HorizontalOrVerticalLayoutGroupLayoutGroup 的子类,用于实现水平和垂直布局。在类中实现了两个重要的函数: CalcAlongAxisSetChildrenAlongAxis 用于在不同的轴上计算和设置子物体的位置与大小。由于需要考虑多种布局属性,因此这两个函数也较为复杂,但带来的好处就是 HorizontalLayoutGroupVerticalLayoutGroup 只需要少量的代码即可实现水平和垂直布局。

HorizontalLayoutGroup & VerticalLayoutGroup

苦尽甘来,经历了 LayoutGroupHorizontalOrVerticalLayoutGroup 的洗礼后,我们终于可以坐享其成了!

HorizontalLayoutGroupVerticalLayoutGroup 的代码看上去是如此的简洁美观,但它们的背后确是 HorizontalOrVerticalLayoutGroup 在干各种脏活累活,让我们说:谢谢 HorizontalOrVerticalLayoutGroup

HorizontalLayoutGroup

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
37
38
public class HorizontalLayoutGroup : HorizontalOrVerticalLayoutGroup
{
protected HorizontalLayoutGroup()
{}

/// <summary>
/// Called by the layout system. Also see ILayoutElement
/// </summary>
public override void CalculateLayoutInputHorizontal()
{
base.CalculateLayoutInputHorizontal();
CalcAlongAxis(0, false);
}

/// <summary>
/// Called by the layout system. Also see ILayoutElement
/// </summary>
public override void CalculateLayoutInputVertical()
{
CalcAlongAxis(1, false);
}

/// <summary>
/// Called by the layout system. Also see ILayoutElement
/// </summary>
public override void SetLayoutHorizontal()
{
SetChildrenAlongAxis(0, false);
}

/// <summary>
/// Called by the layout system. Also see ILayoutElement
/// </summary>
public override void SetLayoutVertical()
{
SetChildrenAlongAxis(1, false);
}
}

唯一需要注意的点就是 CalculateLayoutInputHorizontal 中,在调用 CalcAlongAxis 之前,一定要先调用 base.CalculateLayoutInputHorizontal()layout 中的实现),因为需要先收集 rectChildren 的信息,再进行布局计算。

VerticalLayoutGroup

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
public class VerticalLayoutGroup : HorizontalOrVerticalLayoutGroup
{
protected VerticalLayoutGroup()
{}

public override void CalculateLayoutInputHorizontal()
{
base.CalculateLayoutInputHorizontal();
CalcAlongAxis(0, true);
}

public override void CalculateLayoutInputVertical()
{
CalcAlongAxis(1, true);
}

public override void SetLayoutHorizontal()
{
SetChildrenAlongAxis(0, true);
}

public override void SetLayoutVertical()
{
SetChildrenAlongAxis(1, true);
}
}

Unity 甚至没在这里写注释,可见其简单程度

GridLayoutGroup

GridLayoutGroup 用于实现网格布局,不同于 HorizontalLayoutGroupVerticalLayoutGroupGridLayoutGroup 直接继承自 LayoutGroup,并在类中实现了自己的布局逻辑。

布局属性

GridLayoutGroup 提供了一系列的可配置属性,用于规定布局时的行为。特别的地方在于,GridLayoutGroup 规定了网格的大小,即 cellSize,所有的子物体均按照该尺寸进行布局,不存在随着 PreferredSize 等属性变化的情况。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public enum Corner
{
/// <summary>
/// Upper Left corner.
/// </summary>
UpperLeft = 0,
/// <summary>
/// Upper Right corner.
/// </summary>
UpperRight = 1,
/// <summary>
/// Lower Left corner.
/// </summary>
LowerLeft = 2,
/// <summary>
/// Lower Right corner.
/// </summary>
LowerRight = 3
}

public enum Axis
{
/// <summary>
/// Horizontal axis
/// </summary>
Horizontal = 0,
/// <summary>
/// Vertical axis.
/// </summary>
Vertical = 1
}

/// <summary>
/// Constraint type on either the number of columns or rows.
/// </summary>
public enum Constraint
{
/// <summary>
/// Don't constrain the number of rows or columns.
/// </summary>
Flexible = 0,
/// <summary>
/// Constrain the number of columns to a specified number.
/// </summary>
FixedColumnCount = 1,
/// <summary>
/// Constraint the number of rows to a specified number.
/// </summary>
FixedRowCount = 2
}

首先是一些枚举类型,Corner 用于规定网格的起始位置,即以何种顺序排列子物体;Axis 用于规定网格的布局方向;Constraint 用于规定网格的约束条件,即固定行数或列数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[SerializeField] protected Corner m_StartCorner = Corner.UpperLeft;
public Corner startCorner { get { return m_StartCorner; } set { SetProperty(ref m_StartCorner, value); } }

[SerializeField] protected Axis m_StartAxis = Axis.Horizontal;
public Axis startAxis { get { return m_StartAxis; } set { SetProperty(ref m_StartAxis, value); } }

[SerializeField] protected Vector2 m_CellSize = new Vector2(100, 100);
public Vector2 cellSize { get { return m_CellSize; } set { SetProperty(ref m_CellSize, value); } }

[SerializeField] protected Vector2 m_Spacing = Vector2.zero;
public Vector2 spacing { get { return m_Spacing; } set { SetProperty(ref m_Spacing, value); } }

[SerializeField] protected Constraint m_Constraint = Constraint.Flexible;
public Constraint constraint { get { return m_Constraint; } set { SetProperty(ref m_Constraint, value); } }

[SerializeField] protected int m_ConstraintCount = 2;
public int constraintCount { get { return m_ConstraintCount; } set { SetProperty(ref m_ConstraintCount, Mathf.Max(1, value)); } }

接着是一些布局属性,这里不再赘述。

CalculateLayoutInput

GridLayoutGroup 自然也要实现 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
public override void CalculateLayoutInputHorizontal()
{
base.CalculateLayoutInputHorizontal();

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

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

CalculateLayoutInputHorizontal 函数中,会根据约束条件及子物体个数计算出 minColumnspreferredColumns,然后根据 cellSizespacing 设置布局组件的 minWidthpreferredWidth。可以看到,在无约束的情况下,会默认以 sqrt(rectChildren.Count) 作为列数。

需要注意的是在计算时用到了这样的技巧:Mathf.CeilToInt(rectChildren.Count / (float)m_ConstraintCount - 0.001f)

这里减去 0.001 应该是为了避免一些浮点数的计算误差,如计算结果为 3.0000001 时,Mathf.CeilToInt 会向上取整为 4,但实际上 3 列就足够了。这个技巧会在后续的计算中多次出现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public override void CalculateLayoutInputVertical()
{
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);
}

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

CalculateLayoutInputVertical 的计算逻辑与水平的情况有所差异,可以看到在无约束的情况下,会根据父物体的实际宽度计算出一行能够排列多少个子物体,再由此计算出所需的行数。

SetLayout

布局的设置工作主要由 SetCellsAlongAxis 函数完成,查看该函数的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void SetCellsAlongAxis(int axis)
{
if (axis == 0)
{
// Only set the sizes when invoked for horizontal axis, not the positions.
for (int i = 0; i < rectChildren.Count; i++)
{
RectTransform rect = rectChildren[i];

m_Tracker.Add(this, rect,
DrivenTransformProperties.Anchors |
DrivenTransformProperties.AnchoredPosition |
DrivenTransformProperties.SizeDelta);

rect.anchorMin = Vector2.up;
rect.anchorMax = Vector2.up;
rect.sizeDelta = cellSize;
}
return;
}

// ...
}

首先是一个比较奇怪的写法,当 axis 为 0 也即水平方向时,设置了 m_Tracker,并设置子物体的 anchorMinanchorMaxsizeDelta,然后居然就直接返回了。可以看到这里并没有做任何的布局操作。

其中的原因与 LayoutSystem 的布局设置顺序有关,LayoutSystem 会先进行水平方向的局部设置再进行垂直方向的局部设置,按理来说应该按照 axis 的值分别设置不同方向的布局,但 GridLayoutGroup 是个例外,原因是网格状的布局中,水平与垂直方向的布局息息相关,如果强行拆开反而非常麻烦。因此 SetCellsAlongAxis 也是同时设置了两个方向的布局。

但是为了不违反 LayoutSystem 的布局顺序设计,这里还是在调用 SetCellsAlongAxis(0) 时,只设置锚点、大小等,不进行布局,将布局留给 SetCellsAlongAxis(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
26
27
28
29
30
31
float width = rectTransform.rect.size.x;
float height = rectTransform.rect.size.y;

int cellCountX = 1;
int cellCountY = 1;
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)));
}

接着,会根据父物体的大小及布局的属性,分别计算行与列各能容纳多少个元素,并将结果保存在 cellCountXcellCountY 中。

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;

int cellsPerMainAxis, actualCellCountX, actualCellCountY;
if (startAxis == Axis.Horizontal)
{
cellsPerMainAxis = cellCountX;
actualCellCountX = Mathf.Clamp(cellCountX, 1, rectChildren.Count);
actualCellCountY = Mathf.Clamp(cellCountY, 1, Mathf.CeilToInt(rectChildren.Count / (float)cellsPerMainAxis));
}
else
{
cellsPerMainAxis = cellCountY;
actualCellCountY = Mathf.Clamp(cellCountY, 1, rectChildren.Count);
actualCellCountX = Mathf.Clamp(cellCountX, 1, Mathf.CeilToInt(rectChildren.Count / (float)cellsPerMainAxis));
}

Vector2 requiredSpace = new Vector2(
actualCellCountX * cellSize.x + (actualCellCountX - 1) * spacing.x,
actualCellCountY * cellSize.y + (actualCellCountY - 1) * spacing.y
);
Vector2 startOffset = new Vector2(
GetStartOffset(0, requiredSpace.x),
GetStartOffset(1, requiredSpace.y)
);

接下来,会计算出每一行与每一列真实需要容纳的元素个数,并计算出所需的空间大小 requiredSpace 与起始位置 startOffset

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for (int i = 0; i < rectChildren.Count; i++)
{
int positionX;
int 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]);
}

最后,遍历所有的子物体,根据起始位置、行列数、大小与间距,设置子物体的位置与大小。可以看出 cornerXcornerY 在这里的作用,当它们取 1 时,会分别从行或者列的最后一个元素开始反向排列。

总结

image.png

本文分析了 LayoutSystem 中的 LayoutElementLayoutGroupHorizontalOrVerticalLayoutGroupHorizontalLayoutGroupVerticalLayoutGroup 以及 GridLayoutGroup 等的实现,内容比较多,且布局的计算都十分繁杂,想必大家看下来也会有些头晕。

在阅读源码时,我们经常难以做好理解总体框架与研究实现细节的平衡,太注重高屋建瓴的设计就只能停留在评论家而不是工程师;而过分纠结细节又会让人迷失在代码的海洋中,失去对整体的把控。平心而论,笔者在阅读源码时也常常陷入这种困境,但也一直在尝试找到一套最适合自己的方法。

回到 LayoutSystem 中,我们已经分析完了各种布局组件的实现方式,但请始终别忘记布局系统的整体脉络:

从整体 UGUI 的工作流程来看,布局系统从何而起?我想答案一定是 CanvasUpdateRegistry,它是连接 UGUI 与 Canvas 的桥梁,监听了 Canvas 即将刷新的事件,并调用 PerformUpdate 方法来处理 m_LayoutRebuildQueuem_GraphicRebuildQueue 两个队列。目前为止,我们还一直停留在 m_LayoutRebuildQueue 的范围内。

容器有了,那么是谁向 m_LayoutRebuildQueue 中添加 UI 组件,使其注册进 LayoutSystem 中的呢?这就是 LayoutRebuilder 所完成的任务,LayoutRebuilder 可以将一个待布局的 UI 组件( RectTransform )包装为一个 LayoutRebuilder 对象,并且分别调用其中的 ILayoutElementILayoutController 接口来完成布局的计算与设置。

然而,LayoutRebuilder 也只是调用接口中的函数,接口并不实现具体的布局计算与设置,这就是 LayoutElementLayoutGroup 们的作用了。也就上本文所介绍的形形色色的布局方式,它们都真正实现了 ILayoutElementILayoutController 接口,完成了布局的计算与设置。

至此,整个 LayoutSystem 基本就被我们分析完了。但实际上,LayoutSystem 还有一些内容,如 ContentSizeFitterAspectRatioFitterILayoutSelfController,这些就暂鸽吧~

下一章开始,我们将进入 Graphic 的世界,探讨 UGUI 中的绘制流程。