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()
计算并更新各参数,如minWidth
、preferredWidth
等,从而获取到其在自动布局过程中所需要的输入参数。另一个重要的接口是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
的类,AspectRatioFitter
和ContentSizeFitter
,在文章的最后讨论:
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
如何调用LayoutRebuilder
的Rebuild
方法。在此之前先来看看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到PostLayout
(Prelayout
,Layout
和PostLayout
),内层才是队列中的各元素,逐个调用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
方法来执行重建动作,然后调用布局重建完成的回调LayoutComplete
。LayoutComplete
的内容就是释放掉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
阶段的重建动作,核心内容四行代码,涉及到两个函数PerformLayoutCalculation
和PerformLayoutControl
,分别用于计算参数和设置尺寸,先水平方向后竖直方向。下边是PerformLayoutCalculation
和PerformLayoutControl
的定义,前边的注释中提到了在这两个方法中会重复调用相同的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
中的各数值都是计算和更新后的状态了,此时可以该调用ILayoutController
的SetLayoutXxx
方法来更新布局。
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
。
这是ILayoutElement
提供的方法,对应水平方向和竖直方向分别是CalculateLayoutInputHorizontal
和CalculateLayoutInputVertical
。暂以水平方向为例,当调用CalculateLayoutInputHorizontal
之后,ILayoutElement
的水平方向各个参数都可以取到更新后的值,如minWidth
,preferredWidth
,flexibleWidth
。这三个值含义如下:
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值,是否控制子节点尺寸,是否控制子节点间隔;
初始化totalMin
,totalPreferred
和totalFlexible
三个值;
获取alongOtherAxis
,axis
和isVertical
表示的方向不相同时,此值为true;
遍历rectChildren
(即之前基类中的m_RectChildren
),使用GetChildSizes
获取三个out参数min
,preferred
和flexible
。GetChildSizes
内容比较深,稍后详述;
接下来会用到一个spacing
参数,是一开始指定好的:
1 2 [SerializeField] protected float m_Spacing = 0; public float spacing { get { return m_Spacing; } set { SetProperty(ref m_Spacing, value); } }
根据计算的方向与Layout自身控制的方向是否一致来更新totalMin
,totalPreferred
和totalFlexible
;如果方向是一致的,那么三个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
对应维度的值,preferred
同min
,flexible
为0;如果是true,情况就比较复杂了。这里把获取这三个尺寸的逻辑封装到了LayoutUtility
的静态方法里,稍后展开。最后注意的一点就是,如果childForceExpand
,flexible
会取到一个最大为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)
获取到我们要取的参数值,如minWidth
,preferredWidth
和flexibleWidth
等,保存到prop
,同时会把当前遍历到的ILayoutElement
记录为source
。如果遍历过程中遇到有优先级priority
更高的或者尺寸更大的,则会替换掉旧值。
值得一提的是,很多UI控件如Image
、Text
等也都实现了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
都可以取到正确的值,此时就该进行下一步了,根据这些值来自动布局所控制的子节点。水平方向和竖直方向分别是SetLayoutHorizontal
和SetLayoutVertical
,我们依旧看水平方向,在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
,都是前边已经很熟悉的套路了。还是来一步一步看:
初始化参数:size
,controlSize
,childForceExpandSize
以及一个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表示水平方向时,所有的XxxLeft
,XxxCenter
,XxxRight
对应会取到0
,0.5
,1
。处理竖直方向时也类似。
接下来做一个alongOtherAxis
的判断,和前边CalcAlongAxis
里一模一样;
根据alongOtherAxis
的值区分两种情况,两种情况都是需要遍历rectChildren
,且对各个子节点调用SetChildAlongAxis
方法来最终设置子节点沿当前处理的轴向的位置;
alongOtherAxis
为true时,当前处理的轴向与该自动布局组件控制的方向是一致的,首先是初始化参数pos
, minMaxLerp
, itemFlexibleMultiplier
。这三个值会被用来计算子节点的位置:
pos
表示第一个子节点(即将放置的子节点)的起始位置,后边遍历的过程中这个值会逐渐递增;
minMaxLerp
表示当preferred
大于min
时,将会根据该LayoutGroup
的尺寸和preferred
作比较,得出在min
和preferred
之间插值的系数,同时又有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都是控制它们的子节点,后边这两位则是控制它们自己:AspectRatioFitter
和ContentSizeFitter
。
1 2 public class AspectRatioFitter : UIBehaviour, ILayoutSelfController public class ContentSizeFitter : UIBehaviour, ILayoutSelfController
AspectRatioFitter 首先是定义了一个枚举类型:
1 public enum AspectMode { None, WidthControlsHeight, HeightControlsWidth, FitInParent, EnvelopeParent }
其核心逻辑在UpdateRect
,在OnEnable
、OnRectTransformDimensionsChange
等时机都会调用此方法:
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); }
进而会有继承/实现自ILayoutController
的SetLayoutXxx
:
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.GetMinSize
和LayoutUtility.GetPreferredSize
是该节点更新之后的值,
本系列其它文章详见Unity3D UGUI 源码学习
REFERENCE