Unity UI - 布局(一)

前言

对于 Unity UI,可以通过其 Rect Transform 组件设置来改变它在 Canvas 或是某个父元素中的位置或大小;这种模式下,可以手动在 Unity 编辑器界面按需调整 UI 元素的位置以、尺寸大小以及旋转角度等属性。但有些时候这种灵活的布局方式反而显得更累赘,亦或是不能满足项目需求;比如游戏商店中需要结构化的显示一个商品列表,对于需要显示多少个商品 UI 在开发时期是未知的,但是每个商品的结构是大致相同的,此时另一种常用的布局方式 - Auto Layout 就派上用场了。下面就以布局为基础,从源码去分析整个 Unity UI 是如何布局的。

Basic Layout

Basic Layout 俗称 Rect Transform 布局,这中布局方式很简单,通过修改 UI 元素 RectTransform 组件的各个属性的值,来灵活的显示一个 UI 元素。在 《从锚点来谈 Rect Transform 组件》 一文中分析了 RectTransform 组件,对于如何设置一个 UI 元素的位置以及尺寸大小都有了一个了解,这里不再过多详述。

下面就直奔本文重点 Auto Layout。

Auto Layout

Auto Layout 提供了嵌套布局组(Horizontal Groups、Vertical Groups 和 Grid Group)中放置结构化的 UI 元素的解决方案。

Auto Layout 允许根据 UI 元素包含的内容大小自动调整自身的大小尺寸,从而使 UI 元素看起来更完美。Auto Layout 系统是 Rect Transform 布局系统的一层扩展,因此它的目标对象也是 Unity UI。在探讨 Auto Layout 之前,首先需要搞清楚这个系统中常用的组件以及一些概念。

Layout Elements

什么是 Layout Elements?对于一个绑定了 Rect Transform 组件的对象来说,如果它的尺寸大小可以被其他组件(比如一个 Layout Controller)去设置,那么这个对象就可以作为一个 Layout Element。一个 Layout Element 都需要实现 ILayoutElement 接口,所以它默认会拥有相关属性和一些方法,下面我们就来看看 ILayoutElement。

ILayoutElement

如果一个组件实现了该接口,那么这个组件所在的 UI 元素在 Auto Layout 系统中就是一个 Layout Element,它会让这个 UI 元素拥有一些默认属性和方法。

属性:

  • Minimum width - 当前 Layout Element 最小分配的宽度值

  • Minimum height - 当前 Layout Element 最小分配的高度值

  • Preferred width - 在分配其它宽度之前如果宽度足够,为当前 Layout Element 分配这个首选的宽度值

  • Preferred height - 在分配其它高度之前如果高度足够,为当前 Layout Element 分配这个首选的高度值

  • Flexible width - 如果还有其它可用宽度空间,和同级的元素利用各自的这个值去计算各自的 Layout Element 额外相对宽度(这里的计算方式是将剩余的可用宽度分成同级所有的 Flexible width 相加这么多份,每个元素最终分配得到的宽度是求得的平均每份的大小乘以 Flexible width)

  • Flexible height - 如果还有其它可用高度空间,和同级的元素利用各自的这个值去计算各自的 Layout Element 分配额外相对高度(这里的计算方式是将剩余的可用高度分成同级所有的 Flexible height 相加这么多份,每个元素最终分配得到的高度是求得的平均每份的大小乘以 Flexible height)

  • LayoutPriority - 当前 Layout Element 各组上面的属性使用的优先级。如果多个实现了 ILayoutElement 接口的组件绑定在同一个 UI 元素上,可以通过设定这个值来确定最终使用哪一组属性值,优先使用这个值高的组件所在的那一组属性值。如果优先级都相等,则使用属性中值比较大的那个。

方法:

  • CalculateLayoutInputHorizontal 方法,计算 minWidth、preferredWidth 和 flexibleWidth 的值。

  • CalculateLayoutInputVertical 方法,计算 minHeight、preferredHeight 和 flexibleHeight 的值。

ILayoutIgnorer

如果实现了这个接口,那么其所在的组件对应的 UI 元素会根据 ignoreLayout 方法的具体实现来决定是否被 Auto Layout 系统给忽略;一个位于 LayoutGroup 下的 UI 元素绑定了实现了该接口的组件且 ignoreLayout 方法返回 true,那么 LayoutGroup 就不会控制其 RectTransform。

LayoutElement 组件

给 UI 元素绑定这个组件可以覆写一个或多个布局属性(ILayoutElement 接口中有介绍过),LayoutElement 类实现了 ILayoutElement 接口,所以它能够覆写相关属性。在布局中,Flexible 尺寸是在所有的 Preferred 尺寸分配完成之后才进行计算分配的,如果一个元素没有设置 Preferred 但设置了 Flexible,那么在布局的时候会首先设置其尺寸为 Minimum,等到所有其它设置了 Preferred 的布局完成之后才开始当前这个元素的 Flexible 尺寸计算。

它还实现了 ILayoutIgnorer 接口,最终设置的 Ignore Layout 的值就是 ignoreLayout 方法的返回值。

LayoutElement 类继承自 UIBehaviour 类,因此它具有 Unity 普通对象具有的完整的声明周期。在其内部除了生命周期的回调方法之外,有一个比较重要的方法 SetDirty,代码如下:

1
2
3
4
5
6
protected void SetDirty()
{
if (!IsActive())
return;
LayoutRebuilder.MarkLayoutForRebuild(transform as RectTransform);
}

方法中主要就是调用了 LayoutRebuilder 类的 MarkLayoutForRebuild 方法,将当前 UI 元素或父节点元素标记为可以重新构建的,在下次系统重新布局 UI 元素是就会重新构建计算这个 UI 元素的大小尺寸。

在其他生命周期的回调方法中,基本上都是唯一调用了 SetDirty 方法去标记可重新构建。

ILayoutController

ILayoutController 是 Auto Layout 系统中所有布局控制器基础接口,它内部有且仅有两个方法 SetLayoutHorizontalSetLayoutVertical。在 Auto Layout 时,首先回调 SetLayoutHorizontal 方法处理水平方向上的布局计算,然后回调 SetLayoutVertical 方法处理垂直方向上的布局计算;在这两个方法中可以调用 LayoutUtility 类的辅助方法去计算自身或子元素的 Minimum width(height)、Preferred width(height) 和 Flexible width(height)。

ILayoutController 接口并没有直接被其他类去实现,它被分成了两个单独的接口 ILayoutSelfController 和 ILayoutGroup;在 Auto Layout 系统中前者用于控制自身 UI 元素的 Rect Transform (如实现了这个接口的 ContentSizeFitter 和 AspectRatioFitter 两个类),后者用于控制其子元素的 Rect Transform 组件(如 LayoutGroup 类)。

ILayoutGroup 和 ILayoutSelfController 两个接口中除了继承而来的两个方法,暂时没有其他任何成员变量或方法。

看完 ILayoutController 接口,下面就来看看具体的 Layout Controller 实现类。我们知道,Layout Controller 可以控制一个或多个 Layout Elements 的尺寸大小或位置,不仅仅是控制其子元素同时可以可控制自身所在的 UI 元素。

ContentSizeFitter

ContentSizeFitter 类实现了 ILayoutSelfController 接口,因此它能够控制自身 UI 元素的 Rect Transform。比如给 Text 组件所在的对象上添加 ContentSizeFitter 组件,我们就可以控制当前 Text UI 元素如何根据内容去布局。

ContentSizeFitter 类继承自 UIBehaviour 类,因此它具有完整的 Unity 生命周期。

在 ContentSizeFitter 类中有一个枚举 FitMode,它有三个值:

  1. Unconstrained - 不控制当前 UI 元素的 Rect Transform

  2. MinSize - 设置当前 UI 元素的 Rect Transform 尺寸为最小尺寸

  3. PreferredSize - 设置当前 UI 元素的 Rect Transform 尺寸为首选尺寸

ContentSizeFitter 类有两个 FitMode 类型的成员变量 m_HorizontalFitm_VerticalFit,分别用来控制水平和垂直两个方向上的尺寸,这两个变量可以在 Unity 编辑器中手动设置。当 ContentSizeFitter 组件控制自身 UI 元素的尺寸时,其使用的 MinSize 和 PreferredSize 由 Layout Element 提供;在 Unity 生命周期的回调方法中,均会调用当前类的 SetDirty 方法,这个方法也是标记当前所在的 UI 元素是可以被重新构建的,这点和 LayoutElement 组件相似。

同时 ContentSizeFitter 类 还实现了 SetLayoutHorizontalSetLayoutVertical 方法,在这两个方法的实现中均调用了 HandleSelfFittingAlongAxis 方法,这个方法是这个类的重点,下面就来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void HandleSelfFittingAlongAxis(int axis)
{
FitMode fitting = (axis == 0 ? horizontalFit : verticalFit);
if (fitting == FitMode.Unconstrained)
{
// other code ...
return;
}
// other code ...
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));
}

从上面的代码可以看出,如果当前方向上的 FitMode 是 FitMode.Unconstrained 则不会做任何设置;否则就使用 LayoutUtility 类的辅助方法来获取相应的尺寸大小,然后调用当前 UI 元素 RectTransform 的 SetSizeWithCurrentAnchors 方法设置上新的尺寸大小。

对于 LayoutUtility 类中的辅助方法获取尺寸大小,在稍后部分会详细分析源码,这里只要知道通过辅助方法获取到的就是需要的尺寸大小。

整个 ContentSizeFitter 工作的流程大致是: 当 UI 元素收到 Unity 声明周期方法回调(如 OnRectTransformDimensionsChangeOnEnable 等),通过调用 SetDirty 方法标记当前所在的 UI 元素可以重新构建,当下次构建发起时会调用当前类的 SetLayoutHorizontalSetLayoutVertical 方法(在 LayoutRebuilder 类的 Rebuild 方法中被回调),进而调用 HandleSelfFittingAlongAxis 方法去重新设置 UI 元素的尺寸大小。

AspectRatioFitter

AspectRatioFitter 组件和 ContentSizeFitter 类似,都是用来控制元素自身的尺寸大小;它也继承自 UIBehaviour 类并且实现了 ILayoutSelfController 接口,与 ContentSizeFitter 不同的是,它是使用一个比例来控制 UI 元素的尺寸大小。

AspectRatioFitter 类中同样定义了一个枚举 AspectMode,表示如何根据横纵比例去控制尺寸大小,它有以下一些值:

  1. None - 不作任何设置

  2. WidthControlsHeight - 根据横纵比和宽度确定高度

  3. HeightControlsWidth - 根据横纵比和高度确定宽度

  4. FitInParent - 保持横纵比不变,让当前 UI 元素矩形去撑满父元素空间;同时当前元素的 width、height、position 和 anchors 都可能发生改变

  5. EnvelopeParent - 保持横纵比不变,让当前 UI 元素矩形去包裹住整个父元素空间;同时当前元素的 width、height、position 和 anchors 都可能发生改变

在 AspectRatioFitter 类中还定义了一个 AspectMode 类型的变量 m_AspectMode,用来表示当前所在 UI 元素的横纵比尺寸计算方式;另外还有变量 m_AspectRatio 来表示横纵比,这两个都是可以在 Unity 编辑器中手动配置的。

AspectRatioFitter 类的生命周期回调方法中都调用了当前类的 SetDirty 方法(OnDisable 回调除外,这个回调中只是简单标记当前所在 UI 元素可以被重新构建),SetDirty 方法中调用了当前类的 UpdateRect 方法,代码如下:

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
private void UpdateRect()
{
switch (m_AspectMode)
{
// other code ...
case AspectMode.HeightControlsWidth:
{
rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, rectTransform.rect.height * m_AspectRatio);
break;
}
case AspectMode.WidthControlsHeight:
{
rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, rectTransform.rect.width / m_AspectRatio);
break;
}
case AspectMode.FitInParent:
case AspectMode.EnvelopeParent:
{
// other code ...
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 被设置为不同的值,AspectRatioFitter 组件控制所在 UI 元素的方式也有所差别。

  • 当设置为 HeightControlsWidth (或 WidthControlsHeight),根据横纵比和宽度(或高度)去计算尺寸大小,然后进行设置。

  • 当设置为 FitInParent (或 EnvelopeParent),首先将当前所在 UI 元素的 RectTransform 的四个锚点设置到父元素的四个角落,同时 anchoredPosition 也被设置为 Vector2.zero (中心点位于锚点构成的矩形的中心原点);然后再根据 m_AspectMode 的设置值情况计算一个最合适的尺寸:

    • 当被设置 FitInParent 时,如果根据父元素高以及横纵比求得的宽大于父元素的宽时,表明此时超出了父元素的边界,所以将 UI 元素的宽设置为父元素的大小(sizeDelta.x 设置为 0),高度就根据横纵比以及父元素的宽求得。

    • 当被设置 EnvelopeParent 时,如果根据父元素高以及横纵比求得的宽小于父元素的宽时,表明这样得到的 UI 元素始终在父元素里面而不能包裹父元素,所以将 UI 元素的宽设置为父元素的大小(sizeDelta.x 设置为 0),高度就根据横纵比以及父元素的宽求得。

    • 当被设置 FitInParent 时,如果根据父元素高以及横纵比求得的宽小于父元素的宽时,这种情况下如果需要尽可能撑满父元素,优先高度撑满(sizeDelta.y 设置为 0),宽度同样根据横纵比以及父元素的宽求得。

    • 当被设置 EnvelopeParent 时,如果根据父元素高以及横纵比求得的宽大于父元素的宽时,此时如果 UI 元素要包裹父元素,高度不可能再小;因此优先高度包裹(sizeDelta.y 设置为 0),宽度同样根据横纵比以及父元素的宽求得。

计算得到 sizeDelta 之后,然后设置给 RectTransform 组件。

AspectRatioFitter 组件设置所在 UI 元素的尺寸大小整个流程就很简单了,在生命周期的回调方法(如 OnEnable)中,调用 SetDirty 方法进而调用 UpdateRect 方法去更新所在 UI 元素的尺寸大小(或者还包括 position、anchors 等信息)。

控制组件自身所在 UI 元素的 Layout Controller 的两个具体实现类到这里就分析完了,这两个类相对来说比较简单;ContentSizeFitter 组件根据不同的 FitMode 设置对 UI 元素进行调整,AspectRatioFitter 组件根据设置的 AspectMode 以及横纵比去调整尺寸、锚点位置等信息。

在后面的文章中,还将继续分析 Auto Layout 系统中控制子元素的 Layout Controller (如 LayoutGroup),以及 Auto Layout 中比较生涩的问题。

参考