CSS-like layout for Unity UI - Lessions from web development workflow
Unity UI really is a pain, especially for mobile - the environment which the engine is really used widely in. You put everything into place for one screen aspect ratio, and then you switch to another screen aspect ratio and suddenly, the whole thing breaks.
This is a longstanding issue with Unity UI that has yet to be comprehensively covered by any guide I can find. Unity developers are just sort of... expected to figure this out. A major part of this issue is that Unity Editor's interface just naturally guides you toward using pixel count to layout UI elements, when this is absolutely the wrong way to handle multiple resolutions. Pixel numbers are absolute in Unity UI. Even with a Canvas Scaler set to a fixed reference resolution, it still cannot account for multiple aspect ratios.
And that established my motivation to make this post. I made it for those among us - the many Unity developers still stuck on the legacy Unity UI system. Yes, technically Unity's new UI Toolkit package is intended to replace this legacy UI system, and it runs on runtime now too. But the UI Toolkit package is still in preview at the time of writing and many in-production projects are still built on the legacy UI system. Unity UI will be here for a long time in the forseeable future, and I hope to ease the pain of people who work with it.
How did web developers do it? 2 words: Box model
Taking a step back, you will realize that web developers face much of the same conundrum, one which we will soon face when we fully transition to the world of UI Toolkit. One markup - multiple resolutions and aspect ratios. How do web developers do it? While we don't get access to the powerful stylesheet declarative language to help us with Unity UI, there is something else we can learn from the web yet.
Web developers visualize elements in boxes. Boxes nested in other boxes.
Take away the border and the padding, and you have Unity's RectTransform. RectTransform is a CSS box with only the margin and the content properties . No matter what offsets, positions, width, height, anchors or pivots you set to a RectTransform, it will always occupy a rectangular space on your canvas. You can switch to the Rect Tool while selecting a RectTransform to visualize this better, or if that's not visualization enough, add an image (temporarily) on that RectTransform.
The white space in the above image is the RectTransform's equivalence of the Content space in web box model . Once again: this holds true for any offsets, positions, width, height, anchors or pivot you set to a RectTransform. The space that a blank image occupies is the Content space of the its RectTransform.
Much like CSS boxes, the Content space of a parent RectTransform provides the relative position to the RectTransforms underneath it in the hierarchy. The default 0-position of a child RectTransform is right in the center of its parent RectTransform's Content space.
What about the margin of a RectTransform? I did say RectTransform still takes the margin property from CSS box, didn't I?
Well, by default, a RectTransform is created with absolute positioning and sizing, hence it is agnostic of its parent's edge positions and size.
In order to make a RectTransform conform to its parent's size (not just using its parent's position as a relative origin), we have to talk about...
Anchors - AKA the secret sauce to solve almost all of your Unity UI problems
If you expand the Anchors property of a RectTransform, you will find that it contains two Vector2s - Min and Max. A picture speaks a thousand words, this is roughly how anchors work:
Try setting a RectTransform's anchors like the above values. You will notice some changes in the Inspector: The Pos X, Pos Y, Width and Height fields have been replaced by Left, Top, Right and Bottom fields. When you set all of the new 4 fields to 0, you have a Content space that is laid out as if it has been styled with the following CSS properties:
Indeed, this red RectTransform now conforms to the size of its parent:
Furthermore, if you now enter a positive number into one of the 4 fields Left, Top , Right and Bottom, you will see the edges of the child RectTransform being pushed in from that direction. Enter a negative number and the edges will be pushed out. That's another factor in RectTransform's margin calculation.
In CSS terms, RectTransform's margin calculation takes in both pixel numbers and percentage numbers. It applies the percentage margins first (via anchors), and then apply the pixel margins on top of that (via offsets) .
If you want to only apply pixel margins, set your Min Anchor to (0, 0) and your Max Anchor to (1, 1). This anchor mode is in fact, one of the the anchor presets that Unity have on the Inspector interface:
If you want to only apply percentage margin, zero out the offset numbers and turn on "Raw edit mode". That's the "R" button to the right of the RectTransform's Inspector. I'll let its description do all the talking:
But if RectTransform works by stretching itself from anchor point to anchor point, how does it achieve absolute sizing by default again?
Let's ask a different question: Now knowing how anchors work, how do you think a RectTransform will behave when its anchors have the same x or y value? It turns out that doing this would disable anchor-stretching on the dimension where the anchors converge. Margins work a little differently in this case. You will notice that when anchors converge on a particular dimension, some of the fields among Pos X, Pos Y, Width and Height will return to the Inspector, depending on the dimension converged.
In the above screenshot for example, the red RectTransform now has absolute width of 120 pixel and at the 0-position on the x axis, the red RectTransform will try to align its center position to the 70% point on the width of its parent. This is a pretty unusual setup and shows where RectTransform is the least similar to CSS box. You usually don't want to layout UI elements like this. The mix of absolute sizing and percentage margin doesn't play well when you start changing screen resolutions.
You can however minimize the effect of absolute sizing on the margin of one side of the axis in this scenario. With the above setup, try setting the Pivot X to 1, zero out the Pos X field if you don't have Raw edit mode on. You now have a box with 30% right margin and completely disregards left margin. Changing the absolute width of this RectTransform will only move its left edge.
This is because the Pivot field controls the point on the RectTransform that Unity UI will try to align with the anchor-converged point of its parent. Note that pivot doesn't do anything on the axis where you don't have converged anchors, like the Y axis in this example. You can apply additional pixel margin when you have absolute sizing and only one margin applied too - by changing the Pos X / Pos Y fields. However, unlike the Left, Top, Right and Bottom fields, the Pos fields always move right/up with positive numbers, and left/down with negative numbers. So for example, in order to have right margin at 30% + 10px, you actually have to set Pivot X = 1, Anchor X both 0.7 and Pos X = -10.
Alright, that sums up all the way you can control the "box model". Back to the issue I pointed out at the beginning of this post. Using pixel count to lay out your UI leads to all sorts of problems. After reading this far, I hope you have gotten an idea of how to solve this issue .
Use anchors to setup percentage-based layouts. I also call this anchor-based layout.
This is the same layout example at the beginning of the post, but scaled with anchors:
When RectTransforms are constrained by percentage, they respect each other's space and stretch only up to where you allow them to.
If you are using the letterboxing method I outlined in this previous post, you should be aware that anchor-based layout is the only layout methodology that works with a letterboxed UI.
There are a couple of other things that you should take into account when working with anchors.
What about the texts?
Whether you're using Unity UI's legacy Text component or TextMeshPro, the only way to tell both text components to stretch to their parents is by enabling Best Fit/Auto Size field and give them a generous range. After doing so, control the text's relative size to its parent RectTransform by controlling the anchors of the text's RectTransform instead of the Font Size field.
What about object-fit?
If you came from a web development background and currently working with Unity UI, even with the box model visualization for your Unity UI workflow, there comes a certain point where you will inevitably miss having an object-fit CSS property.
We have a typical scenario like this:
We have the green space all stretched out where it needs to, but now we needs to put a panel frame in it. It needs to stretch out to as much of the green space as it can, but not spill outside it. And at the same time it must maintain its shape. In CSS, this problem is solved by a very simple property: object-fit: contain;. How do you achieve the same thing in Unity UI?
Sure, you could tick on the Preserve Aspect Ratio field of your Image component, but this immediately presents some issues:
- You are limited to using Image type Simple or Filled. If you're using a Sliced Image for the frame of your panel, you'll be out of luck.
- You have very little flexibility in controlling the aspect ratio of the image. You can't slightly stretch or squish the image, you have to follow the aspect ratio of your sprite asset.
- You are not actually changing the Content space of RectTransform for the panel, hence things become very hard to keep in place when you try to put something inside your panel frame. Visualizing the box model when you tick on Preserve Aspect Ratio field of an Image component, you will see that the RectTransform of the Image component still fully stretch out. Its child RectTransforms unfortunately won't conform to the space of the image, they recognize only the parent RectTransform as they should, according to the box model.
So this is not the object-fit equivalence that we're looking for. The real solution has to fit the RectTransform itself.
This is where a little-known component called AspectRatioFitter comes in. If you read my letterboxing post, you already got a cursory introduction to this component. Event then, it bears going more in-depth into it here, since Unity officially doesn't have any introductory material for this component - no blog post, no text tutorial, no video tutorial. The company just expects you to discover AspectRatioFitter by yourself and google its documentation.
Well, its documentation page is here. It is a recommended reading before you return to this post. I would not change a word in that documentation page. But how does AspectRatioFitter fill the role of object-fit for RectTransform? Well, if you still need a little more help after reading the documentation page, here's how:
- Fit In Parent mode is equivalent to object-fit: contain.
- Envelope Parent mode is equivalent to object-fit: cover.
- object-fit: fill doesn't really need AspectRatioFitter to achieve. You will quickly realize this is the Stretch anchor preset for RectTransform.
- object-fit: none also doesn't need AspectRatioFitter to achieve. This is in fact just the absolute sizing mode for RectTransform (ie when its anchors converge on both axis).
- Width Controls Height and Height Controls Width are unique modes of AspectRatioFitter. These 2 modes are extremely helpful when you are trying to - for example - put a perfect square on a 1080x1920 rectangle. You would like the width of the square to always be 20% of the parent rectangle's width, but 20% of 1080 is how much percentage of 1920 again? Don't care, create a container with anchor delta X of 0.2, put your image inside that container, add an AspectRatioFitter on it, pick "Width Controls Height" mode.
- object-fit: scale-down is in fact equivalent to Fit In Parent mode inside one extra parent when you think about it. What this property value does in CSS is simply putting an upper limit on the size that you can stretch to with contain value, which is equivalent to Fit in Parent mode.
AspectRatioFitter in some ways are even more flexible than what you can do if you had CSS. You can choose an aspect ratio different from what your image asset has. And you don't necessarily have to fit around the center of the parent box like in CSS. To quote the documentation to refresh your memory:
It's worth keeping in mind that when a Rect Transform is resized - whether by an Aspect Ratio Fitter or something else - the resizing is around the pivot. This means that the pivot can be used to control the alignment of the rectangle. For example, a pivot placed at the top center will make the rectangle grow evenly to both sides, and only grow downwards while the top edge remain at its position.
Coming back to the example at the beginning of this section, here is how it looks now, properly fitted using AspectRatioFitter:
What about layout group?
Even though Unity provides anchors to individually lay out UI elements by percentage, its builtin support for percentage-based layout is non-existent when it comes to layout groups. Layout groups exist to automatically re-adjust the positions and sizes of RectTransforms as you add and remove them on runtime.
The 3 layout groups that ship with Unity UI are completely pixel-based, and we already went through how pixel-based scaling can cause problems on multiple screen aspect ratios. Fortunately, writing our own LayoutGroup class is always an option.
I have attempted to write the 3 equivalents of the builtin LayoutGroup classes that layout child elements by ratios instead of pixels. They will take control of the child RectTransforms' anchors for this purpose, but I tried to make them not as intrusive as the builtin LayoutGroups. The 3 ratio-based LayoutGroups are contained in a unitypackage here.
But in case my classes are not enough, here's a quick Custom LayoutGroup Scripting Crash Course (TM):
- Inherit from UnityEngine.UI.LayoutGroup, override whatever methods the compiler wants you to override. CalculateLayoutInputHorizontal is where your layout code goes, ignore the misleading name.
- You will inherit a Child Alignment property from LayoutGroup. To get the child alignment in normalized number, call GetAlignmentOnAxis, pass in 0 if you're getting x-axis alignment, 1 if y-axis.
- rectChildren is the list of controllable child RectTransforms you also inherit from LayoutGroup. Don't modify this list, Unity already added and removed child RectTransforms from this list for you. Just iterate through this list and modify its elements' RectTransforms in your layout code.
- m_Tracker is a special field inherited from LayoutGroup that controls which field in the child RectTransforms will be grayed out (indicates being controlled by your custom LayoutGroup). You can still make changes to child RectTransforms with no field being grayed out, however. Call Add from this field to register child RectTransforms as well as fields to control on them.
- The 2 Inspector-visible properties that you inherit from LayoutGroup are Padding (pixel-based) and ChildAlignment. If your custom LayoutGroup doesn't make use of either of them hidden, you have to write an Editor script to hide the field(s) you don't use.
I hope this post has helped you establish a better Unity UI workflow. This post was motivated by seeing a lot of my colleagues struggling with getting the UI in their projects to look right. Thank you for reading.
Comments
Post a Comment