In this article we're going to add layout panels to our toy UI library.
As we discussed much earlier in the tutorial, each element is responsible for the positioning of its child elements. Layout panels will implement an algorithm that automatically lays out its children either in a column or row. For those familiar with CSS Flexbox, the algorithm is not too dissimilar; however, I developed and fine-tuned this algorithm without knowledge of CSS Flexbox so they diverge on some of the fine details. The algorithm we use here can be extended to do more complicated things, however we will be implementing only the set of features that give the highest power to weight ratio.
Let's start by setting up the Panel
element and giving it 2 flags which the user can set to give the panel a background color. Look back at the previous article for a greater discussion of how this standard element setup all works.
typedef struct Panel {
#define PANEL_GRAY (1 << 1)
#define PANEL_WHITE (1 << 2)
Element e;
} Panel;
int _PanelMessage(Element *element, Message message, int di, void *dp) {
Panel *panel = (Panel *) element;
if (message == MSG_PAINT) {
// Again, I don't think this is a good way to do styling in the long term.
// But it's very nice for quickly getting things up and running.
// Anything better is out of scope for this tutorial.
if (element->flags & PANEL_GRAY) {
DrawBlock((Painter *) dp, element->bounds, 0xCCCCCC);
} else if (element->flags & PANEL_WHITE) {
DrawBlock((Painter *) dp, element->bounds, 0xFFFFFF);
} else {
// Transparent background.
}
}
return 0;
}
Panel *PanelCreate(Element *parent, uint32_t flags) {
return (Panel *) ElementCreate(sizeof(Panel), parent, flags, _PanelMessage);
}
For automatic layouting to be possible, the panel has to have some method of asking its child elements what size they should be. Naturally, we'll implement this with messages. We introduce the MSG_GET_WIDTH
and MSG_GET_HEIGHT
messages. An element is to return their measured width and height in response to receiving this message. Before we go any further, let's implement these for buttons and labels.
In _ButtonMessage
we add:
if (message == MSG_GET_HEIGHT) {
return 25;
} else if (message == MSG_GET_WIDTH) {
return 30 /* margin */ + GLYPH_WIDTH * button->textBytes /* text width */;
}
In _LabelMessage
we add:
if (message == MSG_GET_HEIGHT) {
return GLYPH_HEIGHT;
} else if (message == MSG_GET_WIDTH) {
return GLYPH_WIDTH * label->textBytes;
}
We'll also have to implement these messages in _PanelMessage
too, so that panels can be nested to create arbitrarily complex layouts.
Unfortunately, we need to complicate the definition of these messages slightly. While for buttons and labels we can compute their exact width and height, with more complicated elements this is not possible. Sometimes the dimension of the element on one axis is dependent on the space available on the other axis. For example, consider a paragraph of word-wrapped text. As the width of its container decreases, the text will wrap into more lines, thus increasing the height of the paragraph. Alternatively, consider a 16:9 image. As the width of its container decreases, to maintain the aspect ratio of the image, the image's height must decrease. Therefore, we shall specify that the di
parameter of MSG_GET_HEIGHT
should be set to the width that the element will ultimately be set to, or 0
if this value is not known. So, for example, an image display element might implement its MSG_GET_HEIGHT
as follows:
int ImageDisplayMessage(Element *element, Message message, int di, void *dp) {
ImageDisplay *display = (ImageDisplay *) element;
if (message == MSG_GET_HEIGHT) {
if (di) {
// di is the width the image display will be set to.
// So calculate the height based on the aspect ratio.
return di * display->aspectRatio;
} else {
// The width of the image display is not known.
// So return the height of the image at 100% zoom.
return display->height;
}
} else {
// ...
}
return 0;
}
In fact, we will also extend the definition of the MSG_GET_WIDTH
message in a similar manner. di
should be set to the expected height of the element, otherwise 0
.
Okay, we're ready to dive into implementing the layout algorithm now. We first add a #define PANEL_HORIZONTAL (1 << 0)
flag so the user can switch between a column and row layout. The initial plan is put each child element one after another either in a column or row, starting at the top or left side of the element respectively. In the column layout, the child elements will be horizontally centered. In the row layout, the child elements will be vertically centered. Please consult the helpful diagram.
In _PanelMessage
, we handle the MSG_LAYOUT
message by calling out to a new function, _PanelLayout
.
if (message == MSG_LAYOUT) {
_PanelLayout(panel, element->bounds);
ElementRepaint(element, NULL);
}
Let's dissect _PanelLayout
.
void _PanelLayout(Panel *panel, Rectangle bounds) {
// Get the horizontal flag from the panel element.
// If horizontal is true, we layout in a row.
// If horizontal is false, we layout in a column.
bool horizontal = panel->e.flags & PANEL_HORIZONTAL;
// The current layout position.
// For a column layout, it's the distance from the top side of the panel to the top side of the next child element.
// For a row layout, it's the distance from the left side of the panel to the left side of the next child element.
int position = 0;
// Calculate the horizontal and vertical space in the panel.
int hSpace = bounds.r - bounds.l;
int vSpace = bounds.b - bounds.t;
// For every child element...
for (uintptr_t i = 0; i < panel->e.childCount; i++) {
Element *child = panel->e.children[i];
if (horizontal) {
// ... similar to the column case below
} else {
// Ask the element for its preferred width and height.
int width = ElementMessage(child, MSG_GET_WIDTH, 0, 0);
int height = ElementMessage(child, MSG_GET_HEIGHT, width /* we're going to use the width it gave us, so compute the height with that */, 0);
// Make the rectangle of the child element.
Rectangle r = RectangleMake(
(hSpace - width) / 2 + bounds.l, // The left side of the child. Horizontally centered in the panel.
(hSpace + width) / 2 + bounds.l, // The right side of the child. Horizontally centered in the panel.
position + bounds.t, // The top side of the child. Use position as an offset from the panel's bounds.
position + height + bounds.t); // The bottom side of the child. Set to give it the height it returned above.
// Move the child element into place.
ElementMove(child, r, false);
// And increment the position variable.
position += height;
}
}
}
This is enough to get simple column and row layouts going! But there is still much more to do. As noted above, the panel needs to implement the MSG_GET_WIDTH
and MSG_GET_HEIGHT
messages so that we can nest panels.
If we have a horizontal panel and receive a MSG_GET_WIDTH
message, or we have a vertical panel and receive a MSG_GET_HEIGHT
, we can adapt the code in _PanelLayout
slightly so that it doesn't move any elements around, but just returns the final position
, and this is what we'll return. That is, the width of a horizontal panel is the sum of its children's widths, and the height of a vertical panel is the sum of its children's heights.
We adjust the signature of _PanelLayout
by making it return an int
, and we add an additional parameter bool measure
. The line ElementMove(child, r, false);
is replaced with if (!measure) ElementMove(child, r, false);
. Finally, at the end of the function we put return position;
. Then the panel message handler becomes:
int _PanelMessage(Element *element, Message message, int di, void *dp) {
bool horizontal = element->flags & PANEL_HORIZONTAL;
if (message == MSG_LAYOUT) {
_PanelLayout(panel, element->bounds, false);
ElementRepaint(element, NULL);
} else if (message == MSG_GET_WIDTH) {
return horizontal ? _PanelLayout(panel, RectangleMake(0, 0, 0, di), true) : TODO;
} else if (message == MSG_GET_HEIGHT) {
return horizontal ? TODO : _PanelLayout(panel, RectangleMake(0, di, 0, 0), true);
} else if (message == MSG_PAINT) {
// As before.
}
return 0;
}
What should we do in the other case? That is, what should we return for the height of a row panel, or the width of a column panel? The most sensible thing to return in this case is the maximum size of a child on that specific axis. We write the function:
int _PanelMeasure(Panel *panel) {
bool horizontal = panel->e.flags & PANEL_HORIZONTAL;
// Store the maximum size here.
int size = 0;
for (uintptr_t i = 0; i < panel->e.childCount; i++) {
// Ask the child for its height if this is a row, or its width if this is a column.
int childSize = ElementMessage(
panel->e.children[i],
horizontal ? MSG_GET_HEIGHT : MSG_GET_WIDTH,
0, 0);
if (childSize > size) {
// Update the maximum size.
size = childSize;
}
}
return size;
}
We can now complete the message handler like so.
int _PanelMessage(Element *element, Message message, int di, void *dp) {
bool horizontal = element->flags & PANEL_HORIZONTAL;
if (message == MSG_LAYOUT) {
_PanelLayout(panel, element->bounds, false);
ElementRepaint(element, NULL);
} else if (message == MSG_GET_WIDTH) {
return horizontal ? _PanelLayout(panel, RectangleMake(0, 0, 0, di), true) : _PanelMeasure(panel);
} else if (message == MSG_GET_HEIGHT) {
return horizontal ? _PanelMeasure(panel) : _PanelLayout(panel, RectangleMake(0, di, 0, 0), true);
} else if (message == MSG_PAINT) {
// As before.
}
return 0;
}
Next I want to add 'borders' and 'gaps'. We add these fields to the Panel
structure:
Rectangle border;
int gap;
The border
gives the interior space around the edge of the panel that should be kept clear. For example, consider a column that has been sized so that its width and height match what it returns from MSG_GET_WIDTH
and MSG_GET_HEIGHT
respectively. Then border.t
gives the distance from the top edge of the panel to the top edge of the first child element, border.b
gives the distance from the bottom edge of the panel to the bottom edge of the last child element, border.l
gives the distance from the left edge of the panel to the left edge of the widest child element, and border.r
gives the distance from the right edge of the panel to the right edge of the widest child element.
The gap
is the number of pixels of space placed in between each child element. For instance, given a column with 3 child elements A, B and C, the gap
gives the distance between the bottom edge of A and the top edge of B, and it also gives the distance between the bottom edge of B and the top edge of C.
Okay, let's implement this. First up, _PanelMeasure
. Replace the return
statement with:
int border = horizontal ? (panel->border.t + panel->border.b) /* row */
: (panel->border.l + panel->border.r) /* column */;
return size + border;
Now _PanelLayout
. We change the variable initialisation to:
bool horizontal = panel->e.flags & PANEL_HORIZONTAL;
int position = horizontal ? panel->border.l : panel->border.t; // The initial placement offset from the left/top edge of the panel.
int border2 = horizontal ? panel->border.t : panel->border.l; // The offset on the other axis.
int hSpace = bounds.r - bounds.l - panel->border.r - panel->border.l; // Remove the total border size to calculate the available horizontal space.
int vSpace = bounds.b - bounds.t - panel->border.b - panel->border.t; // Similar for the vertical space.
Then, in the child element loop, we replace the code with this (example given for the column branch):
int width = ElementMessage(child, MSG_GET_WIDTH, 0, 0);
int height = ElementMessage(child, MSG_GET_HEIGHT, width, 0);
Rectangle r = RectangleMake(
border2 + (hSpace - width) / 2 + bounds.l, // NEW! Add border2.
border2 + (hSpace + width) / 2 + bounds.l, // NEW! Add border2.
position + bounds.t,
position + height + bounds.t);
if (!measure) ElementMove(child, r, false);
position += height + panel->gap; // NEW! Add panel->gap.
Finally, we replace the return statement with this:
return position // The current layout position.
- (panel->e.childCount ? panel->gap : 0) // If there was at least 1 element, we added a gap to the end of position that isn't used, so remove it.
+ (horizontal ? panel->border.r : panel->border.b); // Add the space from the border at the other end of the panel on the main axis.
Awesome! That's borders and gaps implemented.
Finally, there's one more layout feature I want to add in: filling. We define our first flags common to all elements. The layout panel will read these to determine how the element wants to be sized.
#define ELEMENT_V_FILL (1 << 16)
#define ELEMENT_H_FILL (1 << 17)
The current layout algorithm doesn't take into the size of the panel itself, except when centering the child elements on the secondary axis. These flags will indicate that the element wants to fill up all the available space on the corresponding axis.
The easy case is when the fill flag specified is for the secondary axis. For example, take a column panel where a child element has the ELEMENT_H_FILL
flag set. Then the panel does not need to send MSG_GET_WIDTH
to the element. Instead, the panel sets the child's width to the panel's own width.
It's a little bit more tricky when the fill flag is specified on the main axis. For example, take a column panel where a child element has the ELEMENT_V_FILL
flag set. To compute the height of the element, the panel first subtracts the combined height of non-filling elements and their gaps from the total available vertical space in the panel. It then counts the number of child elements with the ELEMENT_V_FILL
flag set. Finally, it divides the remaining vertical space between these elements evenly to get their height.
We don't need to change _PanelMeasure
to implement this, just _PanelLayout
. After the declaring the variables at the top of the function and before the child element loop, we add the following:
// This variable will be where we compute the available space after
// removing the space needed by element that don't fill on the main axis.
int available = horizontal ? hSpace : vSpace;
int fill = 0; // The number of child elements with the fill flag set on the main axis.
int perFill = 0; // This will be set to (available / fill), the size per filling element.
int count = 0; // The total number of child elements.
for (uintptr_t i = 0; i < panel->e.childCount; i++) {
count++;
if (horizontal) {
// ... similar to the column case below
} else {
if (panel->e.children[i]->flags & ELEMENT_V_FILL) {
// This child has the vertical fill flag set.
fill++;
} else if (available > 0) {
// This child will use its height from MSG_GET_HEIGHT.
// Remove that from the available space.
available -= ElementMessage(panel->e.children[i], MSG_GET_HEIGHT,
hSpace /* compute the height using the panel's horizontal space */, 0);
}
}
}
// Remove the gaps from the available space.
if (count) {
available -= (count - 1) * panel->gap;
}
// If there is any available remaining space, divide that by the number of elements that are filling.
if (available > 0 && fill) {
perFill = available / fill;
}
Then in the main child element loop we replace the width/height calculation as follows (the column case shown here):
int width = (child->flags & ELEMENT_H_FILL) ? hSpace
: ElementMessage(child, MSG_GET_WIDTH, (child->flags & ELEMENT_V_FILL) ? perFill /* in this case, we know the height already! */ : 0, 0);
int height = (child->flags & ELEMENT_V_FILL) ? perFill
: ElementMessage(child, MSG_GET_HEIGHT, width, 0);
For comparison, the old code was:
int width = ElementMessage(child, MSG_GET_WIDTH, 0, 0);
int height = ElementMessage(child, MSG_GET_HEIGHT, width, 0);
I wouldn't worry too too much if you don't understand the implementation details. But hopefully the resultant behaviour is intuitive. The test usage code for this article contains a lot of different examples. Play around with it! And maybe step through the layout code in a debugger if you like -- although I fear it is a little too complicated to understand with drawing a diagram as you go along with the processor.
Okay... this article was too long. Really, automatic layouting could have been an entire tutorial series in itself. But I enjoyed it nonetheless! Hopefully you enjoyed it too.