Unity3D UGUI 源码学习 LayoutGroup

Layout相关的组件用于对各种UI组件(RectTransform)完成自动布局。开发者可以自由地进行放置UI组件,然后通过LayoutGroup等来控制自动布局,如调整宽高尺寸、等间距放置、水平或竖直对齐等。相关的类都位于Layout文件夹内。

接口

自动布局涉及到的接口主要以下的这些:

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
public interface ILayoutElement
{
// After this method is invoked, layout horizontal input properties should return up-to-date values.
// Children will already have up-to-date layout horizontal inputs when this methods is called.
void CalculateLayoutInputHorizontal();
// After this method is invoked, layout vertical input properties should return up-to-date values.
// Children will already have up-to-date layout vertical inputs when this methods is called.
void CalculateLayoutInputVertical();

// Layout horizontal inputs
float minWidth { get; }
float preferredWidth { get; }
float flexibleWidth { get; }
// Layout vertical inputs
float minHeight { get; }
float preferredHeight { get; }
float flexibleHeight { get; }

int layoutPriority { get; }
}

public interface ILayoutController
{
void SetLayoutHorizontal();
void SetLayoutVertical();
}

// An ILayoutGroup component should drive the RectTransforms of its children.
public interface ILayoutGroup : ILayoutController
{
}

// An ILayoutSelfController component should drive its own RectTransform.
public interface ILayoutSelfController : ILayoutController
{
}

// An ILayoutIgnorer component is ignored by the auto-layout system.
public interface ILayoutIgnorer
{
bool ignoreLayout { get; }
}

结合注释,基本上意思也都很明了。自动布局相关几乎所有的类都会继承或间接继承ILayoutElement,从而成为自动布局系统的一部分,可以调用CalculateLayoutInputXxx()计算并更新各参数,如minWidthpreferredWidth等,从而获取到其在自动布局过程中所需要的输入参数。另一个重要的接口是ILayoutController,它提供了组件用于控制其子元素自动布局的方法SetLayoutHorizontal()SetLayoutVertical()

在自动布局系统中,除了上边这些接口之外,还有一个在CanvasUpdateRegistry.cs中定义的接口ICanvasElement

1
2
3
4
5
6
7
8
9
10
11
public interface ICanvasElement
{
void Rebuild(CanvasUpdate executing);
Transform transform { get; }
void LayoutComplete();
void GraphicUpdateComplete();
// due to unity overriding null check
// we need this as something may not be null
// but may be destroyed
bool IsDestroyed();
}

实际上Canvas上的所有元素都会继承ICanvasElement,其Rebuild方法可以根据指定的阶段状态来执行重新构建动作。

类和抽象类

自动布局系统中涉及到的主要的类和抽象类如下:

1
2
3
4
5
6
7
8
9
10
11
public abstract class LayoutGroup : UIBehaviour, ILayoutElement, ILayoutGroup

public class LayoutElement : UIBehaviour, ILayoutElement, ILayoutIgnorer

public abstract class HorizontalOrVerticalLayoutGroup : LayoutGroup

public class GridLayoutGroup : LayoutGroup

public class HorizontalLayoutGroup : HorizontalOrVerticalLayoutGroup

public class VerticalLayoutGroup : HorizontalOrVerticalLayoutGroup

LayoutGroup是自动布局的抽象基类,LayoutElement是自动布局控制的元素的基类,其它的都是衍生自LayoutGroup的类,如我们在Unity中常用的HorizontalLayoutGroup等。

除了上边这些类,还有一个用于布局重建的包装类LayoutRebuilder,它实现了之前说到的接口ICanvasElement

1
public class LayoutRebuilder : ICanvasElement

后边会详细说到LayoutRebuilder的作用。另外还有一个静态类LayoutUtility,提供了一些辅助完成自动布局的工具方法。

1
public static class LayoutUtility

最后还有两个实现了接口ILayoutSelfController的类,AspectRatioFitterContentSizeFitter,在文章的最后讨论:

1
2
public class AspectRatioFitter : UIBehaviour, ILayoutSelfController
public class ContentSizeFitter : UIBehaviour, ILayoutSelfController

LayoutGroup

LayoutGroup开始,一步一步看自动布局系统它是如何完成自动布局工作的。LayoutGroup是各种自动布局的基类。在UGUI的使用中,编辑器或者运行时,对于一个LayoutGroup,当激活它、调整它的RectTransform、增删它的子节点时,都会触发它的子节点的自动布局重建动作。我们可以先看一看LayoutGroup的这几个方法。

标记更新布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected override void OnEnable()
{
base.OnEnable();
SetDirty();
}

protected override void OnRectTransformDimensionsChange()
{
base.OnRectTransformDimensionsChange();
if (isRootLayoutGroup)
SetDirty();
}

protected virtual void OnTransformChildrenChanged()
{
SetDirty();
}

共同点:它们都调用了一个SetDirty方法。注意OnRectTransformDimensionsChange中有一个isRootLayoutGroup的属性判断。只有该LayoutGroup没有受控于父节点的ILayoutGroup时才会触发SetDirty

1
2
3
4
5
6
7
8
9
10
private bool isRootLayoutGroup
{
get
{
Transform parent = transform.parent;
if (parent == null)
return true;
return transform.parent.GetComponent(typeof(ILayoutGroup)) == null;
}
}

SetDirty添加脏标记,核心内容其实是LayoutRebuilder.MarkLayoutForRebuild(rectTransform)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}

DelayedSetDirty是一个协程方法,在下一帧调用LayoutRebuilder.MarkLayoutForRebuild(rectTransform)。来看CanvasUpdateRegistry.IsRebuildingLayout()的判断。CanvasUpdateRegistry这个类之前在Graphics的章节见到过,它是一个单例类,各UI向其注册更新事件,以在适当的时机被其调用更新对应的内容。

1
2
3
4
public static bool IsRebuildingLayout()
{
return instance.m_PerformingLayoutUpdate;
}

后边会说到m_PerformingLayoutUpdate取值变化的过程(何时为false何时为true),以及CanvasUpdateRegistry如何调用LayoutRebuilderRebuild方法。在此之前先来看看MarkLayoutForRebuild是个什么东西:

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 static void MarkLayoutForRebuild(RectTransform rect)
{
if (rect == null)
return;

var comps = ListPool<Component>.Get();
RectTransform layoutRoot = rect;
while (true)
{
var parent = layoutRoot.parent as RectTransform;
if (!ValidLayoutGroup(parent, comps))
break;
layoutRoot = parent;
}

// We know the layout root is valid if it's not the same as the rect,
// since we checked that above. But if they're the same we still need to check.
if (layoutRoot == rect && !ValidController(layoutRoot, comps))
{
ListPool<Component>.Release(comps);
return;
}

MarkLayoutRootForRebuild(layoutRoot);
ListPool<Component>.Release(comps);
}

大概的逻辑是从传入的rect开始,自下而上寻找,直到找到根部的LayoutGroup(即该LayoutGroup没有直接的父级LayoutGroup控制)。对根LayoutGroup调用MarkLayoutRootForRebuild。这其中涉及到一些静态方法,下边是它们的定义:

1
2
3
4
5
6
7
8
9
10
private static bool ValidLayoutGroup(RectTransform parent, List<Component> comps)
{
if (parent == null)
return false;

parent.GetComponents(typeof(ILayoutGroup), comps);
StripDisabledBehavioursFromList(comps);
var validCount = comps.Count > 0;
return validCount;
}

用于验证某个RectTransform是否挂载有有效的LayoutGroup:获取所有的ILayoutGroup组件,剔除其中无效的(isActiveAndEnabled为false),如果剩余的还有LayoutGroup,它就是有效的。剔除的方法定义如下:

1
2
3
4
static void StripDisabledBehavioursFromList(List<Component> components)
{
components.RemoveAll(e => e is Behaviour && !((Behaviour)e).isActiveAndEnabled);
}

除了验证LayoutGroup之外,还会验证是否挂载有有效的ILayoutController,与前边的很相似:

1
2
3
4
5
6
7
8
9
10
private static bool ValidController(RectTransform layoutRoot, List<Component> comps)
{
if (layoutRoot == null)
return false;

layoutRoot.GetComponents(typeof(ILayoutController), comps);
StripDisabledBehavioursFromList(comps);
var valid = comps.Count > 0;
return valid;
}

经过重重验证的筛选,最终得到了所谓的layoutRoot,就对它执行下边的方法:

1
2
3
4
5
6
7
8
9
10
private static void MarkLayoutRootForRebuild(RectTransform controller)
{
if (controller == null)
return;

var rebuilder = s_Rebuilders.Get();
rebuilder.Initialize(controller);
if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder))
s_Rebuilders.Release(rebuilder);
}

首先从池里取一个LayoutRebuilder,用controller来初始化它,然后把它注册给CanvasUpdateRegistry。这里调用的是CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(...)方法,如果之前有添加过相同的rebuilder,则会返回false,那么就立即释放掉rebuilder

执行自动布局

执行自动布局的动作主要是CanvasUpdateRegistry调用向其注册了布局重建的LayoutRebuilder对象的Rebuild方法。之前有说到m_PerformingLayoutUpdate,现在回来继续,来看m_PerformingLayoutUpdate是何时发生变化的,主要的逻辑是在PerformUpdate方法内(当CanvasUpdateRegistry被创建时,其PerformUpdate会被添加到Canvas.willRenderCanvases里)。

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
private static readonly Comparison<ICanvasElement> s_SortLayoutFunction = SortLayoutList;
private void PerformUpdate()
{
CleanInvalidItems();

m_PerformingLayoutUpdate = true;

m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);
for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)
{
for (int j = 0; j < m_LayoutRebuildQueue.Count; j++)
{
var rebuild = instance.m_LayoutRebuildQueue[j];
try
{
if (ObjectValidForUpdate(rebuild))
rebuild.Rebuild((CanvasUpdate)i);
}
catch (Exception e)
{
Debug.LogException(e, rebuild.transform);
}
}
}

for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i)
m_LayoutRebuildQueue[i].LayoutComplete();

instance.m_LayoutRebuildQueue.Clear();
m_PerformingLayoutUpdate = false;

// ... 略去无关代码

}

处理Layout相关的代码在函数内的前半部分,首先将m_PerformingLayoutUpdate置为true,然后对m_LayoutRebuildQueue进行排序,然后是两层for循环,外层是CanvasUpdate即更新阶段从0到PostLayoutPrelayoutLayoutPostLayout),内层才是队列中的各元素,逐个调用Rebuild方法,并传入参数是更新阶段的枚举值。

排序使用的是静态方法SortLayoutList,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static int SortLayoutList(ICanvasElement x, ICanvasElement y)
{
Transform t1 = x.transform;
Transform t2 = y.transform;

return ParentCount(t1) - ParentCount(t2);
}

private static int ParentCount(Transform child)
{
if (child == null)
return 0;

var parent = child.parent;
int count = 0;
while (parent != null)
{
count++;
parent = parent.parent;
}
return count;
}

优先重建ParentCount值小的LayoutRebuilder,即在场景树上更靠近根节点的LayoutGroup。CanvasUpdateRegistry调用各rebuilder的Rebuild方法来执行重建动作,然后调用布局重建完成的回调LayoutCompleteLayoutComplete的内容就是释放掉rebuilder放回池里,我们重点看Rebuild的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void Rebuild(CanvasUpdate executing)
{
switch (executing)
{
case CanvasUpdate.Layout:
// It's unfortunate that we'll perform the same GetComponents querys for the tree 2 times,
// but each tree have to be fully iterated before going to the next action,
// so reusing the results would entail storing results in a Dictionary or similar,
// which is probably a bigger overhead than performing GetComponents multiple times.
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());
break;
}
}

只处理CanvasUpdate.Layout阶段的重建动作,核心内容四行代码,涉及到两个函数PerformLayoutCalculationPerformLayoutControl,分别用于计算参数和设置尺寸,先水平方向后竖直方向。下边是PerformLayoutCalculationPerformLayoutControl的定义,前边的注释中提到了在这两个方法中会重复调用相同的GetComponents,虽然开销会比较大但无法避免,把结果缓存起来(使用Dictionary等)会有更多的额外性能开销。

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
private void PerformLayoutCalculation(RectTransform rect, UnityAction<Component> action)
{
if (rect == null)
return;

var components = ListPool<Component>.Get();
rect.GetComponents(typeof(ILayoutElement), components);
StripDisabledBehavioursFromList(components);

// If there are no controllers on this rect we can skip this entire sub-tree
// We don't need to consider controllers on children deeper in the sub-tree either,
// since they will be their own roots.
if (components.Count > 0 || rect.GetComponent(typeof(ILayoutGroup)))
{
// Layout calculations needs to executed bottom up with children being done before their parents,
// because the parent calculated sizes rely on the sizes of the children.

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

ListPool<Component>.Release(components);
}

PerformLayoutCalculation需要传入两个参数,一个RectTransform和一个UnityAction<Component>。函数内部也会对子节点递归调用PerformLayoutCalculation,在完成子孙结点的控制之后,最后再处理自己身上的各ILayoutElement组件。action的内容正是CalculateLayoutInputXxx,如水平方向是:

1
e => (e as ILayoutElement).CalculateLayoutInputHorizontal()

ILayoutElement负责提供接口CalculateLayoutInputHorizontal,详细的功能逻辑则由实现此接口的类来具体实现。竖直方向的计算同理。在执行完CalculateLayoutInputXxx()之后,该ILayoutElement中的各数值都是计算和更新后的状态了,此时可以该调用ILayoutControllerSetLayoutXxx方法来更新布局。

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
private void PerformLayoutControl(RectTransform rect, UnityAction<Component> action)
{
if (rect == null)
return;

var components = ListPool<Component>.Get();
rect.GetComponents(typeof(ILayoutController), components);
StripDisabledBehavioursFromList(components);

// If there are no controllers on this rect we can skip this entire sub-tree
// We don't need to consider controllers on children deeper in the sub-tree either,
// since they will be their own roots.
if (components.Count > 0)
{
// Layout control needs to executed top down with parents being done before their children,
// because the children rely on the sizes of the parents.

// First call layout controllers that may change their own RectTransform
for (int i = 0; i < components.Count; i++)
if (components[i] is ILayoutSelfController)
action(components[i]);

// Then call the remaining, such as layout groups that change their children, taking their own RectTransform size into account.
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);
}

ListPool<Component>.Release(components);
}

PerformLayoutControl同样是传入两个参数,RectTransform和一个UnityAction<Component>,在方法中,获取rect所有的ILayoutController组件,先对其中所有的ILayoutSelfController调用传入的action,接下来对其中的非ILayoutSelfController执行action,最后对rect的子节点递归地调用PerformLayoutControl。真正的逻辑就藏在action里边,以水平方向的参数计算方法为例,action是一个lambda表达式:

1
e => (e as ILayoutController).SetLayoutHorizontal()

此处调用的就是SetLayoutXxx

CalculateLayoutInputXxx

这是ILayoutElement提供的方法,对应水平方向和竖直方向分别是CalculateLayoutInputHorizontalCalculateLayoutInputVertical。暂以水平方向为例,当调用CalculateLayoutInputHorizontal之后,ILayoutElement的水平方向各个参数都可以取到更新后的值,如minWidthpreferredWidthflexibleWidth。这三个值含义如下:

  • minWidth:需要为此对象分配的最小宽度
  • preferredWidth:如果空间充足的话,应当为此对象分配的宽度
  • flexibleWidth:如果有多余的空间的话,可以为此对象额外分配的相对宽度

具体这三个参数是如何被计算出来并更新的,我们从LayoutGroup开始,恰好LayoutGroup自己就有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
32
// 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();
}

注意这里ILayoutIgnorer接口,LayoutElement实现了此接口,可以将ignoreLayout属性设为true,则表示该对象不参与其父级的自动布局。上边的方法中,挂载LayoutGroup组件的对象遍历自己的子节点,剔除ignoreLayout全部为true的子节点。最后将有效的自己点放入m_RectChildren

似乎没有做什么具体的计算,只是更新了有效的子节点,接下来有:

1
2
3
public virtual float minWidth { get { return GetTotalMinSize(0); } }
public virtual float preferredWidth { get { return GetTotalPreferredSize(0); } }
public virtual float flexibleWidth { get { return GetTotalFlexibleSize(0); } }

这三个值都是存在Vector2里的,索引0表示水平方向:

1
2
3
4
5
6
7
8
9
10
11
12
protected float GetTotalMinSize(int axis)
{
return m_TotalMinSize[axis];
}
protected float GetTotalPreferredSize(int axis)
{
return m_TotalPreferredSize[axis];
}
protected float GetTotalFlexibleSize(int axis)
{
return m_TotalFlexibleSize[axis];
}

这些Vector2的值是如何更新的呢,在LayoutGroup里有这么一个方法:

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

这个方法是唯一有可能更新三个Vector2的值的地方了,查看一下引用,果然发现,在LayoutGroup的衍生的抽象类HorizontalOrVerticalLayoutGroup里有:

1
2
3
4
5
protected void CalcAlongAxis(int axis, bool isVertical)
{
// .. 略去无关代码
SetLayoutInputForAxis(totalMin, totalPreferred, totalFlexible, axis);
}

而这个CalcAlongAxis是在哪被调用的呢,查看引用,终于看到了HorizontalOrVerticalLayoutGroup的衍生类,熟悉的HorizontalLayoutGroup,有这么个方法:

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

这么一来就一切了然了。当HorizontalLayoutGroup重建时,会调用CalculateLayoutInputHorizontal方法,方法内部首先调用基类的同名方法,更新有效的子节点列表,然后CalcAlongAxis进行一系列的加加减减计算,把三个属性(minWidth等)的值更新到对应的Vector2(m_TotalMinSize等)里,然后通过minWidth这样的属性就可以获取到更新后的值了。

回过头来看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
37
38
39
40
41
protected void CalcAlongAxis(int axis, bool isVertical)
{
float combinedPadding = (axis == 0 ? padding.horizontal : padding.vertical);
bool controlSize = (axis == 0 ? m_ChildControlWidth : m_ChildControlHeight);
bool childForceExpandSize = (axis == 0 ? childForceExpandWidth : 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 (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);
}
  • 第一个要注意的点就是参数,axis表示调用该方法是为了计算更新水平方向0还是竖直方向1的输入参数,而isVertical这是指当前的Layout是为了控制水平方向false还是竖直方向true

  • 函数内部,首先是获取padding值,是否控制子节点尺寸,是否控制子节点间隔;

  • 初始化totalMintotalPreferredtotalFlexible三个值;

  • 获取alongOtherAxisaxisisVertical表示的方向不相同时,此值为true;

  • 遍历rectChildren(即之前基类中的m_RectChildren),使用GetChildSizes获取三个out参数minpreferredflexibleGetChildSizes内容比较深,稍后详述;

  • 接下来会用到一个spacing参数,是一开始指定好的:

    1
    2
    [SerializeField] protected float m_Spacing = 0;
    public float spacing { get { return m_Spacing; } set { SetProperty(ref m_Spacing, value); } }
  • 根据计算的方向与Layout自身控制的方向是否一致来更新totalMintotalPreferredtotalFlexible;如果方向是一致的,那么三个totalXxx的值都要加上当前遍历的子节点的这三个值(及spacing);

  • 遍历结束后,如果计算方向与Layout控制的方向一致,且子元素数量大于0,则减去一次spacing

  • totalPreferred进行修正,应当不小于totalMin

  • 最后把计算得出的参数赋值给LayoutGroup中的三个Vector2成员

以上是大体流程,接下来展开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);
}

主要是分了两种情况,是否controlSize,如果是false,那么就取子节点真实的尺寸,min即是sizeDelta对应维度的值,preferredminflexible为0;如果是true,情况就比较复杂了。这里把获取这三个尺寸的逻辑封装到了LayoutUtility的静态方法里,稍后展开。最后注意的一点就是,如果childForceExpandflexible会取到一个最大为1的值(这里应该就是把它设置为正数)。

继续深入,打开LayoutUtility看到的是一环套一环:

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

public static float GetPreferredSize(RectTransform rect, int axis)
{
if (axis == 0)
return GetPreferredWidth(rect);
return GetPreferredHeight(rect);
}

public static float GetFlexibleSize(RectTransform rect, int axis)
{
if (axis == 0)
return GetFlexibleWidth(rect);
return GetFlexibleHeight(rect);
}

我们重点看水平方向,注意看默认值都是取得0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static float GetMinWidth(RectTransform rect)
{
return GetLayoutProperty(rect, e => e.minWidth, 0);
}

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

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

终于快到头了,GetLayoutProperty注意这里的property是传入的一个lambda表达式,也就是上边的e => e.minWidth等,rect是我们关心的自动布局中的子节点:

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
51
public static float GetLayoutProperty(RectTransform rect, System.Func<ILayoutElement, float> property, float defaultValue)
{
ILayoutElement dummy;
return GetLayoutProperty(rect, property, defaultValue, out dummy);
}

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

获取这个节点上的所有ILayoutElement,遍历之,float prop = property(layoutComp)获取到我们要取的参数值,如minWidthpreferredWidthflexibleWidth等,保存到prop,同时会把当前遍历到的ILayoutElement记录为source。如果遍历过程中遇到有优先级priority更高的或者尺寸更大的,则会替换掉旧值。

值得一提的是,很多UI控件如ImageText等也都实现了ILayoutElement,如何取到这三个值,这一逻辑也会由各控件自己去实现,截取Image中的一段代码如下,此处不展开讨论,留到后边Image的章节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public virtual float minWidth { get { return 0; } }

public virtual float preferredWidth
{
get
{
if (activeSprite == null)
return 0;
if (type == Type.Sliced || type == Type.Tiled)
return Sprites.DataUtility.GetMinSize(activeSprite).x / pixelsPerUnit;
return activeSprite.rect.size.x / pixelsPerUnit;
}
}

public virtual float flexibleWidth { get { return -1; } }

SetLayoutXxx

CalculateLayoutInputXxx之后,各ILayoutElement都可以取到正确的值,此时就该进行下一步了,根据这些值来自动布局所控制的子节点。水平方向和竖直方向分别是SetLayoutHorizontalSetLayoutVertical,我们依旧看水平方向,在HorizontalLayoutGroup中:

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

进一步看SetChildrenAlongAxis,就又回到它的父类HorizontalOrVerticalLayoutGroup

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
protected void SetChildrenAlongAxis(int axis, bool isVertical)
{
float size = rectTransform.rect.size[axis];
bool controlSize = (axis == 0 ? m_ChildControlWidth : m_ChildControlHeight);
bool childForceExpandSize = (axis == 0 ? childForceExpandWidth : childForceExpandHeight);
float alignmentOnAxis = GetAlignmentOnAxis(axis);

bool alongOtherAxis = (isVertical ^ (axis == 1));
if (alongOtherAxis)
{
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 requiredSpace = Mathf.Clamp(innerSize, min, flexible > 0 ? size : preferred);
float startOffset = GetStartOffset(axis, requiredSpace);
if (controlSize)
{
SetChildAlongAxis(child, axis, startOffset, requiredSpace);
}
else
{
float offsetInCell = (requiredSpace - child.sizeDelta[axis]) * alignmentOnAxis;
SetChildAlongAxis(child, axis, startOffset + offsetInCell);
}
}
}
else
{
float pos = (axis == 0 ? padding.left : padding.top);
if (GetTotalFlexibleSize(axis) == 0 && GetTotalPreferredSize(axis) < size)
pos = GetStartOffset(axis, GetTotalPreferredSize(axis) - (axis == 0 ? padding.horizontal : padding.vertical));

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

float itemFlexibleMultiplier = 0;
if (size > GetTotalPreferredSize(axis))
{
if (GetTotalFlexibleSize(axis) > 0)
itemFlexibleMultiplier = (size - GetTotalPreferredSize(axis)) / GetTotalFlexibleSize(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 childSize = Mathf.Lerp(min, preferred, minMaxLerp);
childSize += flexible * itemFlexibleMultiplier;
if (controlSize)
{
SetChildAlongAxis(child, axis, pos, childSize);
}
else
{
float offsetInCell = (childSize - child.sizeDelta[axis]) * alignmentOnAxis;
SetChildAlongAxis(child, axis, pos + offsetInCell);
}
pos += childSize + spacing;
}
}
}

依然是按照alongOtherAxis来处理不同逻辑,三个GetTotalXxxSize方法,还有GetChildSizes,都是前边已经很熟悉的套路了。还是来一步一步看:

  • 初始化参数:sizecontrolSizechildForceExpandSize以及一个alignmentOnAxis。最后这个浮点数值处理得很巧妙,其实是从TextAnchor这么一个枚举值中提取出来一个对应的轴向的浮点数的值:

    1
    2
    3
    4
    5
    6
    7
    protected float GetAlignmentOnAxis(int axis)
    {
    if (axis == 0)
    return ((int)childAlignment % 3) * 0.5f;
    else
    return ((int)childAlignment / 3) * 0.5f;
    }

    附上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
    }

    通过上边的取法,当输入的axis为0表示水平方向时,所有的XxxLeftXxxCenterXxxRight对应会取到00.51。处理竖直方向时也类似。

  • 接下来做一个alongOtherAxis的判断,和前边CalcAlongAxis里一模一样;

  • 根据alongOtherAxis的值区分两种情况,两种情况都是需要遍历rectChildren,且对各个子节点调用SetChildAlongAxis方法来最终设置子节点沿当前处理的轴向的位置;

  • alongOtherAxis为true时,当前处理的轴向与该自动布局组件控制的方向是一致的,首先是初始化参数posminMaxLerpitemFlexibleMultiplier。这三个值会被用来计算子节点的位置:

    • pos表示第一个子节点(即将放置的子节点)的起始位置,后边遍历的过程中这个值会逐渐递增;
    • minMaxLerp表示当preferred大于min时,将会根据该LayoutGroup的尺寸和preferred作比较,得出在minpreferred之间插值的系数,同时又有Clamp01以确保不会超过preferred
    • itemFlexibleMultiplier表示如果LayoutGroup尺寸真的比preferred更大时,将对各子节点的尺寸方法的系数(前提是子节点的flexible为正数)
  • 遍历子节点,根据上一步初始化的三个值,及子节点的尺寸参数,调用SetChildAlongAxis来控制子节点的位置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    protected void SetChildAlongAxis(RectTransform rect, int axis, float pos)
    {
    if (rect == null)
    return;

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

    rect.SetInsetAndSizeFromParentEdge(axis == 0 ? RectTransform.Edge.Left : RectTransform.Edge.Top, pos, rect.sizeDelta[axis]);
    }

    m_Tracker.Add(...)会对子节点做出限制,子节点的RectTransform尺寸参数将由父节点驱动;

  • alongOtherAxis为false时,计算位置参数,并根据controlSize将各个子节点对齐;

自控制布局

前边说到的各种LayoutGroup都是控制它们的子节点,后边这两位则是控制它们自己:AspectRatioFitterContentSizeFitter

1
2
public class AspectRatioFitter : UIBehaviour, ILayoutSelfController
public class ContentSizeFitter : UIBehaviour, ILayoutSelfController

AspectRatioFitter

首先是定义了一个枚举类型:

1
public enum AspectMode { None, WidthControlsHeight, HeightControlsWidth, FitInParent, EnvelopeParent }

其核心逻辑在UpdateRect,在OnEnableOnRectTransformDimensionsChange等时机都会调用此方法:

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
51
52
53
54
55
56
57
58
59
private void UpdateRect()
{
if (!IsActive())
return;

m_Tracker.Clear();

switch (m_AspectMode)
{
#if UNITY_EDITOR
case AspectMode.None:
{
if (!Application.isPlaying)
m_AspectRatio = Mathf.Clamp(rectTransform.rect.width / rectTransform.rect.height, 0.001f, 1000f);

break;
}
#endif
case AspectMode.HeightControlsWidth:
{
m_Tracker.Add(this, rectTransform, DrivenTransformProperties.SizeDeltaX);
rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, rectTransform.rect.height * m_AspectRatio);
break;
}
case AspectMode.WidthControlsHeight:
{
m_Tracker.Add(this, rectTransform, DrivenTransformProperties.SizeDeltaY);
rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, rectTransform.rect.width / m_AspectRatio);
break;
}
case AspectMode.FitInParent:
case AspectMode.EnvelopeParent:
{
m_Tracker.Add(this, rectTransform,
DrivenTransformProperties.Anchors |
DrivenTransformProperties.AnchoredPosition |
DrivenTransformProperties.SizeDeltaX |
DrivenTransformProperties.SizeDeltaY);

rectTransform.anchorMin = Vector2.zero;
rectTransform.anchorMax = Vector2.one;
rectTransform.anchoredPosition = Vector2.zero;

Vector2 sizeDelta = Vector2.zero;
Vector2 parentSize = GetParentSize();
if ((parentSize.y * aspectRatio < parentSize.x) ^ (m_AspectMode == AspectMode.FitInParent))
{
sizeDelta.y = GetSizeDeltaToProduceSize(parentSize.x / aspectRatio, 1);
}
else
{
sizeDelta.x = GetSizeDeltaToProduceSize(parentSize.y * aspectRatio, 0);
}
rectTransform.sizeDelta = sizeDelta;

break;
}
}
}

没有什么特别复杂的逻辑,根据当前的m_AspectMode调整自身的rectTransform的参数值,其中涉及到另外的两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
private float GetSizeDeltaToProduceSize(float size, int axis)
{
return size - GetParentSize()[axis] * (rectTransform.anchorMax[axis] - rectTransform.anchorMin[axis]);
}

private Vector2 GetParentSize()
{
RectTransform parent = rectTransform.parent as RectTransform;
if (!parent)
return Vector2.zero;
return parent.rect.size;
}

ContentSizeFitter

AspectRatioFitter不同,当激活或发生变化时,会调用SetDirty()

1
2
3
4
5
6
7
protected void SetDirty()
{
if (!IsActive())
return;

LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
}

进而会有继承/实现自ILayoutControllerSetLayoutXxx

1
2
3
4
5
6
7
8
9
10
public virtual void SetLayoutHorizontal()
{
m_Tracker.Clear();
HandleSelfFittingAlongAxis(0);
}

public virtual void SetLayoutVertical()
{
HandleSelfFittingAlongAxis(1);
}

这里才是ContentSizeFitter的核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void HandleSelfFittingAlongAxis(int axis)
{
FitMode fitting = (axis == 0 ? horizontalFit : verticalFit);
if (fitting == FitMode.Unconstrained)
{
// Keep a reference to the tracked transform, but don't control its properties:
m_Tracker.Add(this, rectTransform, DrivenTransformProperties.None);
return;
}

m_Tracker.Add(this, rectTransform, (axis == 0 ? DrivenTransformProperties.SizeDeltaX : DrivenTransformProperties.SizeDeltaY));

// Set size to min or preferred size
if (fitting == FitMode.MinSize)
rectTransform.SetSizeWithCurrentAnchors((RectTransform.Axis)axis, LayoutUtility.GetMinSize(m_Rect, axis));
else
rectTransform.SetSizeWithCurrentAnchors((RectTransform.Axis)axis, LayoutUtility.GetPreferredSize(m_Rect, axis));
}

由于之前有调用过CalculateLayoutInputXxx,所以LayoutUtility.GetMinSizeLayoutUtility.GetPreferredSize是该节点更新之后的值,


本系列其它文章详见Unity3D UGUI 源码学习

REFERENCE