Over the past week I've been thinking about UI layouts. In this blog post I'll discuss what I think the best way to do automatic UI layouts is. This system is based of this project's GUI library, but keep in mind not all of these features have been implemented; this blog post is mostly theoretical.
Manual vs automatic
There are 2 types of UI layouts, manual and automatic. In a manual layout, the programmer must specify where each UI element is placed themselves. This is notably used in the Windows Desktop API:
CreateWindow("Button", "OK", WS_CHILD | WS_VISIBLE,
16, 16, 80, 23, /*x, y, width, height*/
window, NULL, instance, NULL
This is as flexible as it's possible to get, but unfortunately it has many drawbacks. For example, you have to do DPI scaling manually, you have to know the correct sizes of each standard control, you have to work out the gaps between elements, etc.
Therefore it's probably better to have automatic UI layouts, where the UI library positions each element for you.
UI element tree
To automatically layout a UI, we need to arrange the elements in a tree-like structure. Each element is responsible for positioning its child elements.
An element can also ask another element how large it'd like to be by measuring
it. When an element lays out its children, it'll likely need to measure them as it does so. Measuring an element may also cause it to need to measure its own children. For this reason, measured sizes should be cached to avoid unnecessary recalculation.
The layout element
In our GUI library, the layout element is called a Grid. However, as we'll learn, we need to do more things than just making grids.
For simplicity, we'll have one type of element that is dedicated to providing automatic layouting of its children.
It's basic operation is to measure its child elements and place them in order. It has two main parameters - the inset size and the gap size.
Insets represents the space between the top, left, right, and bottom of the layout element and its contents. The gap size represents the space between each child element. The gap size is constant for the whole grid.
Table layout mode
The layout element will be able to take 3 different layout modes - table, flow and wrap. We'll start with the table layout mode.
In table layout mode, a layout element has a fixed number of columns and rows (collectively called bands
). Each child element occupies a single cell (defined by a column and row pair) in the layout element, and each band has a fixed size obtained by measuring its children.
Here's an example with 2 columns and 2 rows:
To obtain the size of a band, the layout measures each child element in that band in the corresponding axis. It then takes the size of the largest reported size
. This means that not all of the elements in a band necessarily take up all the space the cell has to offer.
Here's an example of this occurring. Columns are separated by orange lines, and rows by purple lines. Note that some of the smaller elements have some empty space surrounding them in their cell, and that each band is equal in size to its largest element.
When this happens, the programmer can choose to which of the 9 corners of a cell the element is aligned.
This means you create layouts like this:
Sometimes, a table may not use up all of its available space.
In this case, it's possible that the programmer might want one of the elements to push
on its cell's boundaries, and take up the remaining space. Let's say we instruct the element in cell (0, 1) to push vertically.
But what happens if multiple cells have the push layout flag? The remaining space is distributed based on their weights, such that the remaining space they receive is proportional to their weight.
For example, let's consider a single axis of a layout element with size 200px. It contains three elements, A, B and C, which report measured sizes of 10px, 30px and 40px, and have weights of 1, 1 and 3 respectively.
Expanding, shrinking and collapsing
1. Calculate the remaining space:
200 - (10 + 30 + 40) = 120px
2. Calculate the total weight:
1 + 1 + 3 = 5
3. Calculate the weight fraction of each element:
4. Calculate the additional space for each element:
A: 1/5 * 120 = 24px
B: 1/5 * 120 = 24px
C: 3/5 * 120 = 72px
5. Add the additional space to each element:
A: 10 + 24 = 34px
B: 30 + 24 = 54px
C: 40 + 72 = 112px
We saw before in our 2x2 table that we can give each element
- One of:
- One of:
And, as shown in the previous section on weighting, we can give each cell
a remaining-space weight for each axis.
We may now want to further indicate to an element
how it should occupy the space provided by its containing cell
, rather than just forcing an element to its measured sized and then aligning it within the cell.
To do this, for each axis, we'll introduce EXPAND, SHRINK and COLLAPSE flags, any combination of which can be used in addition to the alignment flags when applied to an element.
When we add the EXPAND flag, an element will expand to match the size of its cell. This makes the alignment flag in the corresponding axis essentially useless.
The COLLAPSE flag will set the reported size of the element to 0.
In this example, all the elements have default flags, except for the bottom label, which has the OS_CELL_H_COLLAPSE flag set.
Because the bottom label has the OS_CELL_H_COLLAPSE flag set, it reports it size as 0 to the table element. So when the leftmost column is picking a size it chooses the size of the top label - as that is the widest reported size it receives. Therefore for bottom label gets clipped.
As the COLLAPSE flag can generate cases in which an element's cell is smaller than its preferred size, you may want the element to SHRINK rather than be clipped.
Consider a button, which has a preferred width of 80px.
Without the OS_CELL_H_SHRINK flag, the button is forced to its preferred width, while the cell is smaller because it reports its width as 0px. When the OS_CELL_H_SHRINK flag is added, the button is happy to shrink past its preferred width.
|Expand||OS_CELL_H/V_EXPAND||Allow the element to expand past its preferred size to match the cell's
|maximum = infinity, else maximum = preferred|
|Shrink||OS_CELL_H/V_SHRINK||Allow the element to shrink below its preferred size to match the cell's size||minimum = 0, else minimum = preferred|
|Collapse||OS_CELL_H/V_COLLAPSE||Report the element's size when measured as 0px||reported = 0, else reported = preferred|
What happens when a layout element cannot fit all of its elements within its bounds?
Think about it - even if the root layout element reports its size as 800px by 600px, if the window is 640px by 480px it's bounds are going to be 640px by 480px no matter what!
For each axis, there are three strategies:
- Clip - all the cells that can't fit are clipped
- Scroll - a scrollbar is provided to move cells in and out of view
- Compress - the cell at the edge of the layout is compressed until its size reached 0px at which point it is clipped
When using the Compress strategy, the SHRINK element flag also becomes relevant, since a cell can be smaller than the measured size.
So far we've assumed that each element occupies one cell, and each cell contains one element (or is empty). But sometimes we might want an element to span multiple cells in a table.
Elements still need to have a base
cell. This is where their "remaining size" is placed if necessary (after all non-multi-cell positioned elements are considered).
Consider the following 2x6 table, in which the two long lines of text are set to occupy both columns in their row.
On the left, the long lines have their base cells in the first column, while on the right, the long lines have their base cells in the second column.
When these tables are laid out, first the non-multi-cell positioned elements are considered, causing the second column to match the width of the "This is a short line of text" element.
Then the multi-cell positioned elements are considered. The widths of the all columns they occupy are subtracted from their reported width. In this case, this is only the second column, as the first column is at 0px currently. This width is then added to their respective base columns. On the left, this causes the first column to take the remaining size of the long labels, whereas on the right, this causes the second column to further increase in size, leaving the first column at 0px.
This has gotten a bit longer than I expected. I'll be splitting this into a part 2, where we'll discuss wrapping! (and all the awkward problems it brings.)
Thank you for reading this far... please leave any comments with feedback, or ask about parts of the post you didn't understand.
Read part 2.