编程语言
首页 > 编程语言> > UGUI源码解析(LayoutSystem布局系统)

UGUI源码解析(LayoutSystem布局系统)

作者:互联网

Runtime类图

ILayoutElement

布局元素接口,是布局的接收方。如果某个类实现了这个接口,那么就会在应用布局时自动完成对它的位置信息及大小的布局计算。


ILayoutController、ILayoutSelfController、ILayoutGroup

布局控制接口,布局的实施方,制定布局规则。

如果一个组件正在驱动它自己的RectTransform,它应该实现接口ILayoutSelfController。如果一个组件正在驱动子组件的RectTransforms,它应该实现ILayoutGroup。


ILayoutIgnorer

忽略布局接口,忽略开关开启状态,将忽略该物体的布局

LayoutRebuilder

核心布局流程

实现了ICanvasElement接口。标记UGUI组件在需要布局处理时会通过标记的方式将自身RectTransform封装成一个LayoutRebuilder对象添加进CanvasUpdateSystem中的布局队列(LayoutRebuildQueue)中等待被重建。

static ObjectPool<LayoutRebuilder> s_Rebuilders = new ObjectPool<LayoutRebuilder>(null, x => x.Clear());
public static void MarkLayoutForRebuild(RectTransform rect)
{
    if (rect == null || rect.gameObject == null)
        return;
    var comps = ListPool<Component>.Get();
    bool validLayoutGroup = true;
    RectTransform layoutRoot = rect;
    var parent = layoutRoot.parent as RectTransform;
    //从物体父级路径寻中寻找是否存在布局组件(ILayoutGroup)
    while (validLayoutGroup && !(parent == null || parent.gameObject == null))
    {
        validLayoutGroup = false;
        parent.GetComponents(typeof(ILayoutGroup), comps);
        for (int i = 0; i < comps.Count; ++i)
        {
            var cur = comps[i];
            if (cur != null && cur is Behaviour && ((Behaviour)cur).isActiveAndEnabled)
            {
                validLayoutGroup = true;
                layoutRoot = parent;
                break;
            }
        }
        parent = parent.parent as RectTransform;
    }
    // 检查自身是否满足布局要求
    if (layoutRoot == rect && !ValidController(layoutRoot, comps))
    {
        ListPool<Component>.Release(comps);
        return;
    }
	//添加进CanvasUpdateSystem中
    MarkLayoutRootForRebuild(layoutRoot);
    ListPool<Component>.Release(comps);
}
private static void MarkLayoutRootForRebuild(RectTransform controller)
{
    if (controller == null)
        return;
    //生成一个rebuilder对象
    var rebuilder = s_Rebuilders.Get();
    //初始化数据
    rebuilder.Initialize(controller);
    //将rebuilder对象注册进CanvasUpdate中,等待Canvas的重建命令
    if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder))
        s_Rebuilders.Release(rebuilder);
}

当重建指令触发时,将对自身即其子级路径中的所有ILayoutElement与ILayoutController执行相应的接口。

//CanvasUpdateSystem触发重建
public void Rebuild(CanvasUpdate executing)
{
    switch (executing)
    {
        case CanvasUpdate.Layout:
            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;
    }
}

在PerformLayoutControl方法中,获取传入参数rect上的所有ILayoutController组件,调用StripDisabledBehavioursFromList移除无效组件,然后遍历它们,按照先子后父的顺序回调传入的方法参数完成相应的布局计算。

其他重要方法

在构造函数中添加RectTransform.reapplyDrivenProperties(为需要重新应用其驱动属性的 RectTransform 调用的事件)的回调,内部实际上调用了MarkLayoutForRebuild。

ForceRebuildLayoutImmediate

根据传过来的layoutRoot强制布局。

LayoutComplete

把自己从s_Rebuilders中释放,并放入对象池中,以便下次再次使用。在CanvasUpdateRegistry中,会调用所有元素的LayoutComplete方法。


经过对LayoutRebuilder的分析,布局接口的触发规则已经被摸清楚了。虽然UGUI组件中有一些组件都继承了ILayoutElement接口(例如:Image,Text,ScrollRect,InputField),但它们并不会涉及对接口方法的实现。这是因为这些组件主要是布局操作的接收方,只需要通过该接口被布局实施方所发现即可。而UGUI中负责这些接收物体的布局设置功能主要是由LayoutGroup衍生的子类组件来完成。

LayoutElement

内部属性的意义如下:

Min Width 最小宽度,只要设置了值,不管布局组件怎么设置,他永远最小也有这么大
Min Height 最小高度,结论同上
Preferred Width 优选宽度,会优先选择这个值作为布局计算参考
Preferred Height 优选高度,结论同上
Flexible Width 如果有额外的空间,会利用这个作用权重来求值获取最后的结果
Flexible Height 结论同上

LayoutGroup

是布局组件的基类(GridLayoutGroup、HorizontalOrVerticalLayoutGroup)。

基础属性及方法

protected RectOffset m_Padding:内部边距,调整实际用于布局区域的大小

protected TextAnchor m_ChildAlignment :子物体对齐方式

CalculateLayoutInputHorizontal:收集子节点下没有被ignoreLayout的物体。

//LayoutGroup 
public virtual void CalculateLayoutInputHorizontal()
{
    //清空list,准备收集子节点下没有被ignoreLayout的物体
    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();
}

抽象方法CalculateLayoutInputVerticalSetLayoutHorizontalSetLayoutVertical

protected float GetStartOffset(int axis, float requiredSpaceWithoutPadding)

根据axis和requiredSpaceWithoutPadding计算开始偏移StartOffset。

protected float GetAlignmentOnAxis(int axis)

根据传入得轴axis和childAlignment计算对齐某个方向需要的值。

protected void SetLayoutInputForAxis(float totalMin, float totalPreferred, float totalFlexible, int axis)

为给定的轴赋值布局相关的属性值。

protected void SetChildAlongAxis(RectTransform rect, int axis, float pos)

protected void SetChildAlongAxis(RectTransform rect, int axis, float pos, float size)

沿指定的轴设置子元素的位置和大小,在内部实际调用的是SetChildAlongAxisWithScale

HorizontalOrVerticalLayoutGroup

基础属性及方法

protected float m_Spacing:子物体之间的间隔

protected bool m_ChildForceExpandHeight

protected bool m_ChildForceExpandHeight

组件控制子物体填充区域开关,若可以修改尺寸则会改变子物体尺寸填充区域,若不可以修改尺寸,则根据区域大小均衡分布子物体。

protected bool m_ChildControlWidth

protected bool m_ChildControlHeight

组件控制子物体尺寸开关,开启时组件可以更改物体尺寸。

protected bool m_ChildScaleWidth

protected bool m_ChildScaleHeight

布局组在调整元素大小和布局时是否考虑其子布局元素的比例。 ---宽度高度对应于每个子布局元素的Rect变换组件中的“ 比例”>“ X”和“ 比例”>“ Y”值。

void CalcAlongAxis(int axis, bool isVertical)

主要是做LayoutGroup的一些初始化参数的计算。

void SetChildrenAlongAxis(int axis, bool isVertical)

沿指定的轴设置子元素的位置和大小,在计算时加入了布局属性的计算。

GetChildSizes(RectTransform child, int axis, bool controlSize, bool childForceExpand,
            out float min, out float preferred, out float flexible)

获取某个子物体child的min、preferred、flexible的大小

HorizontalLayoutGroup、VerticalLayoutGroup及布局流程

以HorizontalLayoutGroup为例,布局流程:

首先被执行的是ILayoutElementCalculateLayoutInputHorizontal方法。该方法将收集其子节点下所有没有被标记ignoreLayout的物体(m_RectChildren)。

// HorizontalLayoutGroup
public override void CalculateLayoutInputHorizontal()
{
    //  基类(LayoutGroup)方法
    base.CalculateLayoutInputHorizontal();
    CalcAlongAxis(0, false);
}

CalcAlongAxis 主要是做LayoutGroup的一些初始化参数的计算。

//LayoutGroup 
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);
    //根据轴设置 m_TotalXXX值
    SetLayoutInputForAxis(totalMin, totalPreferred, totalFlexible, axis);
}

接着会执行ILayoutControllerSetLayoutHorizontal方法。这在GridLayoutGroupHorizontalLayoutGroupVerticalLayoutGroup中有不同的处理。

//HorizontalLayoutGroup
public override void SetLayoutHorizontal()
{
    //根据轴设置子物体的布局
    SetChildrenAlongAxis(0, false);
}

LayoutGroup组件如何调整子物体的位置与大小一句话概括:利用了Unity RectTransform中的一个方法

SetInsetAndSizeFromParentEdge(RectTransform.Edge edge, float inset, float size);

布局物体的方法主要是在 选择出目标边(Edge),计算出距离(inset),计算出子物体的大小(size)

//LayoutGroup 
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)); // 当二者不同时为true  例(水平 y轴,垂直 x轴)
    if (alongOtherAxis)
    {
        //在水平或垂直布局中,另外一条轴的布局操作相对简单一些
        //实际尺寸,根据padding计算
        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)
            {
                // 根据轴选取矩形的边,以及距离、尺寸,设置子物体的位置(API:SetInsetAndSizeFromParentEdge)
                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)
            {
                // 根据轴选取矩形的边,以及距离、尺寸,设置子物体的位置(API:SetInsetAndSizeFromParentEdge)
                SetChildAlongAxis(child, axis, pos, childSize);
            }
            else
            {
                float offsetInCell = (childSize - child.sizeDelta[axis]) * alignmentOnAxis;
                SetChildAlongAxis(child, axis, pos + offsetInCell);
            }
            //更新距离,累计子物体尺寸与间隔
            pos += childSize + spacing;
        }
    }
}

GridLayoutGroup

与HorizontalOrVerticalLayoutGroup相似,不同点在于纵横组件只针对自身的单一轴进行布局,而网格组件则要涵盖两条轴上的布局逻辑。首先被执行的是ILayoutElementCalculateLayoutInputHorizontal方法。

public override void CalculateLayoutInputHorizontal()
{
    //LayoutGroup 基类方法
    base.CalculateLayoutInputHorizontal();
    //若对排列有约束限制,则初始化设置参数
    //这里是横轴则只获取列数的限制
    //CalculateLayoutInputVertical中则会获取minRows
    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));
    }
    //同HorizontalOrVerticalLayoutGroup组件,初始化参数
    SetLayoutInputForAxis(
        padding.horizontal + (cellSize.x + spacing.x) * minColumns - spacing.x,
        padding.horizontal + (cellSize.x + spacing.x) * preferredColumns - spacing.x,
        -1, 0);
}

GridLayoutGroup的布局实现原理上是与HorizontalOrVerticalLayoutGroup相同的,依靠SetInsetAndSizeFromParentEdge方法实现子物体尺寸与位置的设置。

//执行两条轴的布局
public override void SetLayoutHorizontal()
{
    SetCellsAlongAxis(0);
}

public override void SetLayoutVertical()
{
    SetCellsAlongAxis(1);
}

根据设置的参数计算出startOffset(初始位置),cellSize+spacing(尺寸+间隔) 对子物体进行设置。

for (int i = 0; i < rectChildren.Count; i++)
{
	...
    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]);
}

ContentSizeFilter

是用于调整组件区域使其自适的组件,一般用于与ScrollRect滑动列表以及纵横布局组件搭配,实现动态数量的滑动列表效果,以及与Text组件一起使用,可以根据文字长短进行区域尺寸的变化。

继承了ILayoutSelfController接口(ILayoutController接口的衍生),和LayoutGroup一样被布局系统所处理。而和LayoutGroup不同的地方在于,ContentSizeFitter不改变子物体的大小和位置,而是根据子物体(ILayoutElement)来改变自身的尺寸。
 

ContentSizeFitter Enable阶段会设置布局标记(脏标记),来触发Rebuild

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

protected void SetDirty()
{
	...
    //封装成LayoutRebuilder等待被重建
    LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
}

当Canvas触发重建过程,其封装成的LayoutRebuilder执行了Rebuild方法。ContentSizeFitter 自身并没有继承ILayoutElement,所有跳过Rebuild的CalculateLayoutInputHorizontal/Vertical部分,执行了它的SetLayoutHorizontal/Vertical接口方法。

public virtual void SetLayoutHorizontal()
{
    ...
    //根据轴进行尺寸的改变
    HandleSelfFittingAlongAxis(0);
}
public virtual void SetLayoutVertical()
{
    HandleSelfFittingAlongAxis(1);
}
private void HandleSelfFittingAlongAxis(int axis)
{
    //获取目标轴的适应类型
    FitMode fitting = (axis == 0 ? horizontalFit : verticalFit);
    //不强制的类型时不会进行尺寸改变
    if (fitting == FitMode.Unconstrained)
    {
        m_Tracker.Add(this, rectTransform, DrivenTransformProperties.None);
        return;
    }
    //添加Tracker的部分无法被修改
    m_Tracker.Add(this, rectTransform, (axis == 0 ? DrivenTransformProperties.SizeDeltaX : DrivenTransformProperties.SizeDeltaY));

    //根据类型选择适应的尺寸
    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));
}


由上述分析,ContentSizeFitter主要依靠LayoutUtility.GetMinSize与LayoutUtility.GetPreferredSize实现尺寸的变化,这两个方法会从物体自身寻找ILayoutElement,从中获取minWidth与preferredWidth,不存在则返回默认值0。
因为ContentSizeFitter自身并未继承ILayoutElement,所以仅仅只有ContentSizeFitter时会将尺寸变为0。
如图所示:当垂直适应被选择为Prefered Size时,因为不存在ILayoutElement组件,所以Height被设置成了默认值0。

CanvasScaler

画布自适应

首先让我们思考一下为什么需要画布自适应吧,假使在没有画布自适应的情况下,我们会因为无法确定屏幕物理分辨率还有屏幕宽高比而无法确认我们的图片资源应该要画多大,以及我们组件需要摆放的位置还有渲染大小都无法确定。所以我们需要假定的屏幕矩形,以及假定的单位像素,用于运行在真实屏幕中时,映射到屏幕上,当然这个映射关系应该是可配置的,参数是可以调整的,那么让我们来看一下Unity为我们提供的CanvasScaler组件,他实现了什么功能吧。

首先在这里普及一个概念,Pixels Per Unit 这是导入图片时需要设置的图片像素单位大小,指代的意思是一个Unity3D单位对应多少个图片像素 ,默认值为100,一般情况下这个值只需要统一就可以了,使用默认值既可。

基于像素大小的缩放模式

此模式下屏幕分辨率完全与图片像素对齐,也就是说在屏幕分辨率小的情况下,图片显示在屏幕中会变大,屏幕分辨率大的情况下,图片显示会变小,

 ScaleFactor: 缩放因子,用来缩放画布下的元素

基于屏幕宽高比匹配的自适应

ReferenceResolution: 参考分辨率,这个很重要,这个一般作为美术出图的规范,如果在这里基调下进行开发,美术给定个参考图应与此参考分辨率一致

ScreenMatchMode

基于物理大小的缩放

 就像你们理解的那样基于真实的屏幕的大小,参考值是一个屏幕DPI。DPI是单位面积内的像素点数。DPI是一个量度单位,有很多可选单位。

总结
通常我们使用适应基于屏幕宽高比匹配的自适应,因为在这种情况下,不管玩家在什么分辨率下或者是宽高比下,屏幕内的元素显示,相对于屏幕大小,总是趋近于相同的。

RectTransform锚点自适应

 
以上这个自适应的例子很好的诠释了自适应的应用,

上方四个属性(例:Left)描述了当前图片和锚点的位置关系,

Anchors 这个属性是一个屏幕0-1的映射,调整这个值可以控制物体锚点的位置,并且是基于屏幕百分比的。

AspectRatioFitter

调整RectTransform大小,以适应指定的宽高比。

属性

AspectMode aspectMode 执行宽高比的模式

float aspectRatio 应用的宽高比,宽度除以高度

OnEnable方法

调用SetDirty(true),启动协程延时更新,延时后调用UpdateRect

OnDisable方法

调用LayoutRebuilder.MarkLayoutForRebuild,重建Layout。

OnRectTransformDimensionsChange方法

RectTransform大小发生变化时,调用UpdateRect。

UpdateRect方法

在编辑器模式下,没有运行的状态,默认AspectMode.None时,限制宽高比(RectTransform宽度除以高度)在0.001到1之间。
当选择模式AspectMode.HeightControlsWidth(以高度为基准,控制宽度),调用 rectTransform.SetSizeWithCurrentAnchors方法,在水平轴上,以rectTransform.rect.height * m_AspectRatio设置宽度的值。
当选择模式AspectMode.WidthControlsHeight(以宽度为基准,控制高度),调用 rectTransform.SetSizeWithCurrentAnchors方法,在垂直轴上,以rectTransform.rect.width / m_AspectRatio设置高度的值。
当选择模式AspectMode.FitInParent时,当aspectRatio比例小于父对象的宽高比,以父对象的宽度以基准,按照aspectRatio设置高度。当aspectRatio比例大于父对象的宽高比,以父对象的高度以基准,按照aspectRatio设置宽度。
当选择模式AspectMode.EnvelopeParent时,当aspectRatio比例小于父对象的宽高比,以父对象的高度以基准,按照aspectRatio设置宽度。当aspectRatio比例大于父对象的宽高比,以父对象的宽度以基准,按照aspectRatio设置高度。

Unity UGUI自适应布局系统详解_菜鸟的学习历程-CSDN博客

Unity/Auto Layout -- 理解Layout Elements(布局元素)_路人王小二的博客-CSDN博客

标签:LayoutSystem,布局,void,float,RectTransform,源码,组件,UGUI,axis
来源: https://blog.csdn.net/dmk17771552304/article/details/119815383