Two Anchors and a Touch Rect: Relative UI Layout

Nothing exposes a hardcoded layout like a second screen size. A UI positioned in pixels works perfectly on the machine it was authored on and is wrong everywhere else, and the wrongness isn't uniform: some elements drift off the edge, some overlap, and some look fine until the device rotates.

The engine I wrote for mobile solved this with three ideas that fit together. Two anchors per element, geometry resolved at query time rather than stored, and a strict separation between the region an element draws into and the region it can be touched in.

This is the third post pulling one system out of my custom engines. Of everything in that codebase this is the design I'd port unchanged.

Two Anchors, Not One

Every component carries two anchor values:

cmAnchor m_eAnchorParent;
cmAnchor m_eAnchorSelf;

Each is one of nine points: the four corners, the four edge midpoints, and the center.

The parent anchor answers where on the parent this element attaches. The self anchor answers which point on this element does the attaching. A position offset is then applied from that meeting point.

One anchor isn't enough, and the reason is the part people skip. Say an element should sit in the bottom-right corner of its parent. With a single anchor naming the parent's bottom-right corner, the element's own top-left lands there, so the element hangs off the outside of the corner. Getting it inside means offsetting by the element's own size, which means the layout now depends on a size that may change when the text inside it changes.

With two anchors, the element declares that its bottom-right attaches to the parent's bottom-right, and it sits correctly regardless of how big it is or becomes. The size drops out of the calculation entirely.

This is the same model as Unity's anchor-and-pivot and as the anchor attributes in Apple's layout system, arrived at independently. The two-anchor model is what the problem produces when someone follows it far enough.

Resolve at Query Time

The second decision is that a component doesn't store a rectangle. It computes one when asked, against the aspect it's being asked about:

virtual const cmRect GetDrawRect(const Vector3& vAspect = Vector3(1.0f, 1.0f)) const;

Aspect ratio is a parameter, not a global read during initialization.

That looks like a small signature detail and it's the thing that makes the system actually work. A stored rectangle is a cached value with an invalidation problem: rotate the device, change the resolution, resize the window, and every cached rectangle in the tree is silently wrong. Something has to walk the tree and recompute, and whatever triggers that walk will eventually be forgotten for some code path.

Computing on demand has no invalidation problem because there's nothing cached to invalidate. The cost is recomputing layout for elements that didn't change, which for a UI of a few dozen components is not measurable, and which can be added back later as a cache with a dirty flag if it ever is.

The general shape: prefer deriving a value from its inputs over storing it, until storing it is demonstrably necessary. A derived value can't go stale.

Touch Rect and Draw Rect Are Different

The third idea is the one I'd argue hardest for, and it's two methods that could have been one:

virtual const cmRect GetTouchRect(const Vector3& vAspect) const;
virtual const cmRect GetDrawRect(const Vector3& vAspect) const;

Where a control is drawn and where it responds to input are separate questions with separate right answers.

On a touch screen, a finger contact is roughly a centimeter across and the person can't see under it. A button drawn at a visually correct size is frequently too small to hit reliably, and the fix isn't to draw it larger, because the design wants it that size. The fix is a hit region larger than the art.

Once the two are separable, other things become expressible. A close button in a corner can extend its touch region into the corner past its visible bounds. A thin slider can have a tall grab region. A decorative element can have no touch region at all rather than silently swallowing input.

Collapsing these into one rectangle, which is the obvious first implementation, forces every one of those cases to be solved by lying about the visual bounds. I'd take this pair to any UI system I built.

Events Go Up, Hit Tests Go Down

The tree is walked in two directions for two purposes.

Hit testing goes down. GetChildAtPoint descends to find the deepest component containing a point, so the most specific control gets the input.

Events go up. The base implementation of each input handler forwards to the parent:

virtual void OnMouseDown(const Vector3& vPos) {
    if (m_pParent) { m_pParent->OnSoftTouch(vPos); }
}

A component that doesn't handle a touch passes it to its parent as a soft event. That's how a panel learns that something inside it was touched without every child needing to know the panel exists, and it's why a scrolling container can start a drag that began on a button.

The naming carries the distinction. OnMouseDown is the direct event, OnSoftTouch is the propagated one, so a component can tell whether it was the target or an ancestor of the target. That's a real distinction that many UI systems blur, and blurring it is how a container ends up stealing input from its children.

What It Doesn't Do

Two absences.

There's no layout container. Nothing stacks children in a row, distributes them evenly, or sizes itself to its contents. Every element is positioned individually against its parent, which is fine for a game UI of fixed screens and becomes tedious for anything list-shaped or driven by variable content. A stack container that positions children sequentially would have been a small addition and would have removed a lot of repetition.

And there's no separate measure and arrange pass. Real layout systems ask each element how big it wants to be, then tell it how big it actually is, because those differ when content doesn't fit. Here an element's size is set by whoever created it. Text that grows past its box overflows rather than triggering a relayout.

Both absences come from the same source. This is a layout system for screens designed by a person who knows what's on them, not for content of unknown size. That was true of the game it was written for, and it would stop being true immediately for anything data-driven.

What Transfers

Two anchors rather than one, so that position doesn't depend on size. Resolve geometry from its inputs at query time rather than caching it, so it can't go stale. Keep the region that draws separate from the region that responds, because they answer different questions and only one of them is about how it looks.

None of that is specific to games, and all three survive the move to any other UI toolkit worth using.