The 2024 Wheel Reinvention Jam just concluded. See the results.

Automatic UI Layouts (part 1)

nakst
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:

1
2
3
4
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:



Element alignment

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.

  • Top-left
  • Top
  • Top-right
  • Left
  • Center
  • Right
  • Bottom-left
  • Bottom
  • Bottom-right




This means you create layouts like this:



Weighting

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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:
    A: 1/5
    B: 1/5
    C: 3/5
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


Expanding, shrinking and collapsing

We saw before in our 2x2 table that we can give each element alignment flags:
  • One of:
    • OS_CELL_H_LEFT
    • OS_CELL_H_CENTER
    • OS_CELL_H_RIGHT
  • One of:
    • OS_CELL_V_TOP
    • OS_CELL_V_CENTER
    • OS_CELL_V_BOTTOM


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.

To summarise:

TypeFlagBehaviourSizing effect
ExpandOS_CELL_H/V_EXPANDAllow the element to expand past its preferred size to match the cell's
size
maximum = infinity, else maximum = preferred
ShrinkOS_CELL_H/V_SHRINKAllow the element to shrink below its preferred size to match the cell's sizeminimum = 0, else minimum = preferred
CollapseOS_CELL_H/V_COLLAPSEReport the element's size when measured as 0pxreported = 0, else reported = preferred


Overflow modes

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.

Multi-cell positioning

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.

Part 2...

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.
Some typos/errors:
This means you create layouts like this:

I suppose you meant "This means you can create...".

4. Calculate the additional space for each element:
A: 1/5 * 110 = 24px
B: 1/5 * 110 = 24px
C: 3/5 * 110 = 72px

In those lines, 110 should be 120.

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 by 640px by 480px no matter what!

"going to be"