原因
每次需要使用Unity?UGUI?的Layout?系統(tǒng),它總能給你一些意想不到的小問題敬察。然后要去看源碼,源碼又很零散币他,導致每次需要重寫Layout?控件都要重新理解坞靶,現(xiàn)將其總結出來,以便以后查閱蝴悉。
更新邏輯
首先從最常用的也最容易出問題的布局三劍客開始切入彰阴,HorizontalLayoutGroup、VerticalLayoutGroup 和GridLayoutGroup拍冠。
源碼中三劍客都繼承自LayoutGroup尿这。
LayoutGroup?中布局更新的重點在于SetDirty()?函數(shù),可以看到在OnEnable()庆杜、OnDisable()射众、OnDidApplyAnimationProperties()、OnRectTransformDimensionsChange()晃财、OnTransformChildrenChanged()?等多個涉及到需要更新布局的時機的函數(shù)中都有調用它叨橱。
SetDirty()?函數(shù)調用了UGUI?系統(tǒng)更新布局的核心類LayoutRebuilder?中的函數(shù)MarkLayoutForRebuild。
分析上述代碼可以看出:
當一個RectTransform?被標記需要更新時断盛,系統(tǒng)會向祖級變換遍歷ILayoutGroup?組件找到最頂層的ILayoutGroup?組件來更新布局罗洗。注意遍歷的過程中,一旦發(fā)現(xiàn)非ILayoutGroup?就會中斷郑临。這些是為什么連續(xù)兩層以上嵌套三劍客組件時栖博,布局經常會亂的原因之一。
看下ILayoutGroup?接口厢洞,在UGUI?源碼中仇让,實現(xiàn)ILayoutGroup?接口的只有LayoutGroup(三劍客)和ScrollRect,同時ILayoutGroup?接口需要實現(xiàn)ILayoutController躺翻。源碼中的注釋指出丧叽,如果一個組件驅動其所有子變換的布局,那么需要實現(xiàn)ILayoutGroup?接口公你,如果驅動其自身的布局踊淳,需要實現(xiàn)ILayoutSelfController。其中陕靠,另一個布局時常用到的組件ContentSizeFitter?就是實現(xiàn)了ILayoutSelfController?接口迂尝。如果需要重寫布局組件,注意實現(xiàn)這兩個接口剪芥。
繼續(xù)看LayoutRebuilder.MarkLayoutForRebuild?函數(shù)的處理流程垄开,在函數(shù)末尾,調用了MarkLayoutRootForRebuild税肪。在這個函數(shù)中溉躲,布局組件首先被包裹進LayoutRebuilder?實例中(從s_Rebuilders 對象池中獲得的)從而變?yōu)镮CanvasElement榜田,然后通過調用CanvansUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild?函數(shù)送到真正被注冊更新的地方,CanvasUpdateRegistry锻梳。
CanvasUpdateRegistry?是個單例箭券,在單例被創(chuàng)建的時候,將自身的更新函數(shù)PerformUpdate?注冊到了Canvas?組件預備渲染事件Canvas.willRenderCanvases?上疑枯,在PerformUpdate?中辩块,通過調用所有注冊過的ICanvasElement.Rebuild?函數(shù)完成了將所有注冊過的ICanvasElement?進行重建的任務。值得一提的是荆永,在重建之前庆捺,CanvasUpdateRegistry?還對所有注冊過的ICanvasElement?按照深度(即祖級變換的層數(shù))進行了排序,這使底層布局優(yōu)先于頂層布局完成更新屁魏,從而防止布局混亂(只能防止多級布局之間有間隔層級的布局)的原理。
布局邏輯
那么回看LayoutRebuilder?中如何實現(xiàn)ICanvasElement.Rebuild?的捉腥,這是布局控件真正更新布局時的核心氓拼。
查看PerformLayoutCalculation?函數(shù)
從代碼可以看出,更新布局控件時抵碟,系統(tǒng)遞歸地從最子級變換開始桃漾,自下而上查找實現(xiàn)ILayoutElement?接口的組件(注意遇到ILayoutGroup?組件時,遞歸不會停止)拟逮,運行組件實現(xiàn)的ILayoutElement.CalculateLayoutInputHorizontal?方法(和ILayoutElement.CalculateLayoutInputVertical?方法)撬统。值得注意的是,遍歷算法采用的是后序遍歷敦迄,即先計算子級再計算本級恋追。
接下來看PerformLayoutControl?函數(shù)
從代碼中可以看出,更新布局控件時罚屋,系統(tǒng)遞歸地從最頂級變換開始苦囱,自上而下查找每個層級中實現(xiàn)ILayoutController?接口的控件,運行他們實現(xiàn)的ILayoutController.SetLayoutHorizontal?方法(和ILayoutController.SetLayoutVertical?方法)。值得注意的是脾猛,第一撕彤,系統(tǒng)優(yōu)先計算了 ILayoutSelfController,即所有控制自身布局的布局組件會優(yōu)先計算猛拴,然后再處理三劍客這種控制所有子級布局的組件羹铅。第二,采用了先序遍歷愉昆,即先計算當前層級的职员,然后再計算子級的。也就是說撼唾,嵌套的三劍客組件中廉邑,是從頂級開始控制布局的哥蔚,所以連續(xù)嵌套的布局控件常常出現(xiàn)布局錯誤的問題。
最后需要注意蛛蒙,布局更新是先更新橫向布局糙箍,再更新縱向布局。是后序更新ILayoutElement?控件的橫向布局屬性然后先序遍歷ILayoutController?控件的橫向布局牵祟,接著開始以相同方式縱向更新深夯。
現(xiàn)在可以回到三劍客組件中,看看他們是如何實現(xiàn)ILayoutElement 和ILayoutController?接口的了诺苹。
布局三劍客
HorizontalLayoutGroup?和VerticalLayoutGroup?這兩個控件都是繼承自HorizontalOrVerticalLayoutGroup咕晋,在CalculateLayoutInputHorizontal?中,他們將自身子級別的所有非ILayoutIgnorer?組件緩存了下來收奔,以便后續(xù)的布局計算掌呜。
實現(xiàn)ILayoutElement.CalculateLayoutInputHorizontal?和ILayoutElement.CalculateLayoutInputVertical?函數(shù)時,他們都調用了CalcAlongAxis?函數(shù)坪哄。
87 行獲取當前計算的軸向相對于布局控件本身是否是另一個軸向(以下簡稱正交軸向)质蕉,也就是說,如果是在計算HorizontalLayoutGroup翩肌,當alongOtherAxis?為true?時模暗,那么我們現(xiàn)在正在計算橫向布局組件的所有子級的縱向布局。
如代碼所示念祭,在正交軸向上兑宇,算法將所有子級中min、prefered粱坤、flexible布局屬性(以下統(tǒng)稱布局屬性)中的最大值分別作為自身相應的布局屬性的值隶糕,而在與布局控件管理軸向相同的軸向(以下簡稱對應軸向)上,算法通過遍歷子層級變換比规,獲取子級控件的布局屬性相加后若厚,作為自身相應布局屬性的值,注意min?和preferred?屬性都加上了spacing?屬性蜒什。
這其中测秸,獲取子級布局屬性的關鍵點在GetChildSize?上。
當控件中控制子級大小選項不勾選的時候灾常,返回的就是子級Rect?的軸向大婿搿;當勾選控制子級大小時钞瀑,可以通過在子級變換上添加實現(xiàn)了ILayoutElement?接口的組件來左右布局控件的行為沈撞。
在LayoutUtility?中可以看到,獲取子級布局屬性的值雕什,實際上是獲取子級中所有實現(xiàn)ILayoutElement?接口的控件中缠俺,優(yōu)先級最高(當優(yōu)先級相同時显晶,返回最大值)的布局屬性大小。
在LayoutGroup?中壹士,布局優(yōu)先級的值被定義為0磷雇,是源碼中最小的級別。自行重寫布局控件的時候躏救,也需要注意下這個值的定義唯笙。
最后值得說明的是,之前有提到過盒使,多級連續(xù)的ILayoutElement?控件之間崩掘,更新ILayoutElement.CalculateLayoutInputXXX?的算法是后序遍歷,而不連續(xù)的多組布局控件在更新時少办,首先會按照深度來排序苞慢,因此總能保證各級的布局屬性層層依賴子級而計算正確。
接下來看ILayoutController.SetLayoutHorizontal?和ILayoutController.SetLayoutVertical英妓,它們內部都調用了SetChildrenAlongAxis?方法枉疼,這個方法是HorizontalLayoutGroup?和VerticalLayoutGroup?更新其子級布局的核心函數(shù)。
133-137?行獲取了布局控件上的ControlChildSize鞋拟、UseChildScale、ChildForceExpand設置惹资。
138?行將控件的ChildAlignment?設置拆分到軸向上贺纲,其中0表示左(橫向)上(縱向)、0.5表示中部褪测、1表示右(橫向)下(縱向)猴誊,后續(xù)計算子級位置時,需要根據(jù)這個信息來對齊子級侮措。
140?行獲取當前計算的軸向相對于布局控件本身是否是另一個軸向(以下簡稱控件的正交軸向)懈叹,也就是說,如果是在計算HorizontalLayoutGroup分扎,當alongOtherAxis?為true?時澄成,那么我們現(xiàn)在正在計算橫向布局組件的所有子級的縱向布局。
143 行獲取的是布局組件自身層級變換的大形废拧(以下簡稱自身大心础)減去軸向兩側的留白(雖然叫留白,但實際上可以通過設置負值變成overlap)菲饼,也就是控件上Padding?設置的Left\Right?或Top\Bottom肾砂,以下簡稱內部大小。
144?行宏悦,布局控件開始設置每個子級的布局镐确。
148?行包吝,使用了與CalcAlongAxis?中相同的函數(shù)GetChildSize?來獲取子級控件的布局屬性。
151 行源葫,獲取了子級控件需要的(當前計算軸向上的)空間诗越,以下簡稱需求空間。當ControlChildSize?布局設置設為true?時臼氨,在確保需求空間不會低于子級的Min?屬性的前提下掺喻,使用布局控件的內部大小、布局控件自身大写⒕亍(將padding?設置為負值時內部大小可以大于自身大懈邪摇)和子級的prefered?屬性中的較小值,此外當子級的flexible?布局屬性設置了大于0的值或者布局控件的ChildForceExpand?設置設為了true持隧,那么需求空間就是布局控件內部大小和布局控件自身大小中的較小值即硼;當ControlChildSize?布局設置設為false?時,因為子級的Min?屬性和Preferred?屬性都被設置為子級的自身大小屡拨,這個值為子級的自身大小和布局控件內部大小中的較小值(子級自身大小可以大于布局控件的內部或自身大兄凰帧)。
152?行呀狼,使用的函數(shù)名稱是GetStartOffset 裂允,返回的是子級(相對于布局控件的左\上)開始計算位置的偏移值。這里是布局控件的對齊邏輯發(fā)生的地方哥艇。下圖是計算ChildAlignment?設置為RightXXX?時的VerticalLayoutGroup?的正交軸向GetStartOffset?的示例圖绝编。
下圖是子級flexible?屬性設置大于0的值或者布局控件的ChildForceExpand?設置為true?時,GetStartOffset?計算示例圖貌踏。
153?行之后十饥,根據(jù)上述計算的數(shù)據(jù),開始設置每個子級的位置(及大凶嫒椤)逗堵。注意155行和160行調用的是兩個不同的重載函數(shù)。
當布局控件上的ControlChildSize?設置為ture?時眷昆,SetChildAlongAxisWithScale?將子級的大小設置為了151 中計算的需求空間大小蜒秤,這里是子級prefered?布局屬性設置和布局控件ControlChildSize、ChildForceExpand?等設置生效的地方亚斋。
隨后根據(jù)StartOffset垦藏、子級的RectTransform?設置計算并設置了子級在布局控件中正交軸向上的錨定位置。
SetChildrenAlongAxis?的后半部分是用來設置布局組件的所有子級伞访,在其所管理的軸向上(以下簡稱對應軸向)的布局的掂骏。
168行,算法獲取了控件對應軸向上的冗余空間厚掷,即布局控件自身大小減去布局控件本身的preferred?布局屬性(布局控件本身的屬性在前述的CalcAlongAxis?函數(shù)中已經完成了計算)弟灼。
173行级解,當存在冗余空間,并且子級沒有設置flexible?屬性的控件時田绑,需要在對應軸向上對齊子級勤哗,這里是布局控件對齊邏輯生效的地方。
175行掩驱,當存在冗余空間芒划,并且子級中有設置flexible?屬性的控件時,將冗余空間除以子級總體的flexible?屬性之和(也就是布局控件自身的flexible?屬性)獲得乘積因子欧穴,將這個乘積因子乘以單個子級的flexible?屬性就能獲得該子級分得的冗余空間民逼,注意,當冗余空間大于總Flexible?時涮帘,多出冗余的部分也根據(jù)這個乘積因子分配給了所有的子級拼苍,所以沒有173行的對齊操作。
178-180行调缨,當子級總Min?布局屬性不等于總Preferred?布局屬性時疮鲫,布局組件盡可能將自身空間分配給子級,通過計算布局控件自身大小減去子級總體Min?屬性大小獲得的可額外分配值弦叶,和子級總體Prefered?屬性大小減去子級總體Min?屬性大小獲得的嘗試額外分配值之間的比值俊犯,可以獲得鋪滿布局控件自身大小時實際的額外分配比,這個比值可以作為給子級分配空間時伤哺,在子級的min?和preferred?屬性之間插值的因子瘫析。180?行的截取操作指出,當子級總體的Min 屬性大于布局控件的自身大小時默责,不分配額外空間,當子級總體Preferred?屬性大于布局控件的自身大小時咸包,按照插值因子為每個子級分配額外空間桃序,當子級總體Preferred?屬性小于布局控件的自身大小時,按照子級的Preferred?屬性分配空間烂瘫。
182-201行,是布局所有子級的邏輯。189-190?行肢专,為每個子級計算了需要分配的大小妻枕。191-199行,與上述正交軸向的計算流程相同葛账,設置了子級的錨定位置和大小柠衅。200行,更新下一個子級的計算初始位置籍琳。
至此菲宴,HorizontalLayoutGroup?和VerticalLayoutGroup?就完成了布局更新任務贷祈。
接下來看下三劍客中的最后一劍GridLayoutGroup?是如何實現(xiàn)ILayoutElement?和ILayoutGroup?接口的。
和前述兩個布局控件相同喝峦,在CalculateLayoutInputHorizontal?中首先獲取了需要控制布局的所有子級(即沒有掛載任何實現(xiàn)了ILayoutIgnorer?接口的控件的子級)势誊。
145-157行 根據(jù)GridLayoutGroup?中Constraint?布局設置計算出橫軸向最低排布數(shù)量和期望排布數(shù)量。
前兩種設置的計算比較好理解谣蠢,當Constraint?設置為Flexible?時粟耻,期望的排布數(shù)量被設置為了子級數(shù)量的平方根,意味著期望在方形區(qū)域排布所有的子級眉踱。
159-162行 則根據(jù)最低排布數(shù)量和期望排布數(shù)量來計算控件自身的布局屬性挤忙,注意控件的flexible?屬性被設置為了-1。
在CalculateLayoutInputVertical?函數(shù)中勋锤,算法也是根據(jù)Constraint?設置來計算控件的布局行數(shù)饭玲。
181-184行 可以看見,當Constraint 設置為Flexible 時叁执,算法會準確計算出控件需要布局的行數(shù)茄厘。
187-188行?可以看到,GridLayoutGroup?組件縱向的Min?和Prefered?布局屬性都被設置為相同數(shù)值谈宛,同時Flexible?屬性設置為了-1次哈。
可以看到GridLayoutGroup?無視所有子級上的ILayoutElement?控件提供的布局屬性,而統(tǒng)一使用CellSize?屬性作為每個子級的布局屬性大小吆录。
然后來看SetLayoutHorizontal?和SetLayoutVertical?中窑滞,GridLayoutGroup?是如何排布子級布局的。
源碼中恢筝,這兩個函數(shù)都是調用了SetCellsAlongAxis?函數(shù)哀卫。
217-234行 查看代碼并且結合函數(shù)開頭的注釋可知,計算GridLayoutGroup?子級的橫向布局時(SetLayoutHorizontal?時)僅僅設置了子級的大小撬槽,并且注意到此改,子級的大小被強制設置為GridLayoutGroup?組件上的CellSize?設置的大小。
241-266行?根據(jù)GripLayoutGroup?組件上的Constraint?設置和兩個軸向上的自身大小侄柔,計算出組件軸向上可以容納的子級行列數(shù)共啃,注意當Constraint?設置為Flexible?的時候,GridLayoutGroup?是按照嘗試將自身大小鋪滿的期望去計算行列數(shù)目暂题。
268-269行?計算出控件在橫軸向和縱軸向排布的起始方向移剪,這里是GridLayoutGroup?控件上StartCorner?設置生效的地方。
271-283行?很好理解薪者,之前計算的行列數(shù)是布局控件兩個軸向上可以排布的數(shù)量纵苛,在這里結合GridLayoutGroup?控件上的StartAxis?設置(排布方向,即元素按行排列還是按列排列),計算出實際上會排列的行列數(shù)赶站。
285-288行?根據(jù)實際上排的的行列數(shù)計算出排列這些子級實際需要用掉的空間幔虏。
290-292行?根據(jù)需求空間和自身大小計算出開始排布子級的橫向及縱向偏移,這里是ChildAlignment?設置也就是對齊設置生效的敵方贝椿。
294-315行?使用上述計算的初始排布位置想括、實際排布數(shù)量等數(shù)據(jù),設置了每個子級的實際位置烙博,比較容易看懂瑟蜈。
以上,便是GridLayoutGroup?全部的布局邏輯渣窜。對比HorizontalLayoutGroup 和VerticalLayoutGroup?這兩個布局控件铺根,GridLayoutGroup?完全無視了子級的布局屬性,如同他的名字“網(wǎng)格布局組”乔宿,它只適合排布大小差異不大的子級位迂。?
局限
雖然自帶的幾種布局控件功能已經很強大,但他們因為自身的設計或者前述的更新邏輯的制約详瑞,也存在一些局限掂林,現(xiàn)將實際使用中遇到的問題以及解決方案羅列出來,方便以后查閱坝橡。
第一個最常見的泻帮,連續(xù)多級的三劍客布局組件與ContentSizeFitter?合用時的問題:Unity UGUI 解決多級布局混亂問題