UI Layout Utils

40 min read

Flexbox- and grid-style layout components for uGUI that size and position your HUDs, menus, and inventory screens automatically, so RectTransform anchors stop being a per-resolution chore.

Summary

The Scylla UI Layout utilities are a flexbox- and grid-style layout engine that sits on top of uGUI (Unity’s built-in UI system). Instead of hand-tuning anchors, pivots, and offsets on every RectTransform until a screen looks right at one resolution, the UI is composed from declarative containers: a stack lays its children out in a row or a column, a grid arranges them in tracks, and each container resizes and repositions its children whenever its own rect changes. The layout is described once and holds its composition across resolutions, aspect ratios, and runtime content changes, so the same HUD (heads-up display) or menu survives the jump from a phone to an ultrawide without a hand-anchored variant per target.

The system is component-based rather than a static facade. A handful of MonoBehaviour containers do the arranging (ScyllaUIHStack, ScyllaUIVStack, ScyllaUIZStack, ScyllaUIAdaptiveStack, ScyllaUIGrid), and a handful of per-child descriptor components tune how individual children behave inside them (ScyllaUIFlexElement for grow and shrink weights, ScyllaUISpacer for gaps, ScyllaUIGridItem for cell placement, ScyllaUIPositioned for free placement inside a depth stack). Value types (ScyllaUILength, ScyllaUIEdgeInsets, ScyllaUITrack) express sizes in either absolute pixels or a percentage of the parent, so a panel can be “40% of the screen width with 12 pixels of padding” without a single magic anchor number. An internal solver does the measuring and placement in three passes; nothing about it is exposed to you beyond the components.

Two things lift this above a thin wrapper over Unity’s HorizontalLayoutGroup. The first is responsiveness baked into the model: a ScyllaUIAdaptiveStack flips between a row and a column when its container crosses an aspect-ratio threshold (a settings menu that is side-by-side on a monitor and stacked on a phone, with no code branch), and a shared ScyllaUILayoutStyle asset carries aspect-ratio breakpoints so one style change restyles every panel that references it. The second is the supporting cast that real UI work always needs anyway: ScyllaUIText auto-sizes TextMeshPro labels to fit their box, ScyllaUITextGroup syncs a set of labels to a common font size so a row of buttons reads evenly, ScyllaUIScreenMargin insets content out of notches and rounded corners using Screen.safeArea, and ScyllaUILayoutAnimator tweens children to their new positions when the layout changes so a reflow eases instead of snapping. The animator builds on the Tween system.

Use the UI layout utilities for:

  • HUDs, pause menus, settings screens, and inventory grids that have to hold their composition across phone, tablet, desktop, ultrawide, and console-TV resolutions without a separate hand-anchored layout per target.
  • Runtime-dynamic UI where the child set changes during play: a party roster that grows as members join, a buff bar that adds icons, a build-list that reflows as options unlock.
  • Responsive layouts that change shape, not just scale, when the aspect ratio changes, via adaptive stacks and aspect-ratio style breakpoints.
  • Text that has to fit a fixed box regardless of language or content length, and rows of labels that should share one font size for visual evenness.
  • Safe-area-aware placement that keeps interactive content clear of notches, rounded corners, and the gesture bar on modern handhelds.

Features

  • Row and column stacks. ScyllaUIHStack and ScyllaUIVStack lay children out along a main axis with configurable alignment, cross-axis alignment, padding, and inter-child spacing. They are the workhorses behind almost every panel.
  • CSS-style sizing modes. Each axis of a stack is Fill (take the parent slot), Hug (shrink to fit the content), or Fixed (leave the RectTransform’s own size alone). The same three modes cover a full-width toolbar, a label that wraps to its text, and a fixed 200-pixel sidebar.
  • Pixel or percentage lengths. ScyllaUILength is either absolute pixels or a percentage of the parent dimension, and ScyllaUIEdgeInsets applies that to per-edge padding. “10% left padding” survives a resolution change; a hardcoded 40f does not.
  • Per-child flex control. ScyllaUIFlexElement gives a child a grow weight (claim a share of leftover space), a shrink weight (give up space when over-allocated), minimum and preferred sizes, and an ignore flag. This is how a chat box expands to fill a sidebar while the buttons under it stay their natural size.
  • Two-dimensional grids. ScyllaUIGrid arranges children into column and row tracks that are fixed-size, fractional (a share of leftover space, like CSS fr), auto (sized to content), or clamped to a min/max range. ScyllaUIGridItem pins a child to a cell and spans it across several. Auto-flow places everything else.
  • Depth stacks and free placement. ScyllaUIZStack layers children at the same origin (a card with a background, an icon, and a badge), and ScyllaUIPositioned anchors a child to one of nine points inside it with a pixel or percentage offset.
  • Adaptive orientation. ScyllaUIAdaptiveStack reads its container’s aspect ratio every solve and flips between horizontal and vertical when it crosses AspectThreshold, so one container is a row on a wide screen and a column on a tall one.
  • Shared styles with breakpoints. ScyllaUILayoutStyle is a ScriptableObject that carries padding, spacing, and alignment plus an ordered list of aspect-ratio breakpoints. Many stacks reference one style; editing the asset restyles all of them, and the breakpoints adjust the values per aspect ratio.
  • Auto-sizing and synced text. ScyllaUIText drives TextMeshPro’s auto-size to fit a label to its box between a min and max point size; ScyllaUITextGroup reads every member’s fitted size and pushes the smallest to all of them so a row of buttons shares one consistent size.
  • Safe-area insets. ScyllaUIScreenMargin insets a RectTransform out of the device safe area on the edges you choose, with an extra designer margin on top, keeping content clear of notches and rounded corners.
  • Animated reflow. ScyllaUILayoutAnimator records each child’s position and size on every solve and tweens from the old values to the new ones, so adding a party member or resizing a panel eases instead of snapping. It builds on the Scylla Tween system.
  • Batched, zero-cost-when-clean solving. ScyllaUILayoutRoot collects dirty stacks and solves them once per frame in LateUpdate, parents before children. A clean frame does no work; a property change marks exactly the affected stack dirty.
  • Editor visualisation. ScyllaUILayoutDebugDraw paints a canvas-space outline around every container and wrapped child in a distinct color per type, and ScyllaUILayoutGizmos draws padding and gap regions in the Scene view, so the structure of a layout is visible while you build it.

Components

The system is a small set of container components, a smaller set of per-child descriptor components, and the value types they consume. A container arranges its direct children; a descriptor sits on a child and tells the container how to treat it. Everything is a MonoBehaviour you add through the Add Component menu under Scylla/UI/Layout/... or through AddComponent<T>() in code. The internal solver reads these components, measures the children, and writes anchors, pivots, and sizeDelta back to each child’s RectTransform.

A few terms recur throughout. The main axis is the direction a stack distributes children along (horizontal for an HStack, vertical for a VStack); the cross axis is the perpendicular one. A track is one column or one row of a grid. A length is a measurement that is either absolute pixels or a percentage of the parent’s matching dimension. Dirty means a stack has had a property or child change since its last solve and is queued to be re-solved on the next frame.

ComponentKindNotes
ScyllaUIHStack / ScyllaUIVStackContainerLay children in a row or a column. Configurable main-axis distribution, cross-axis alignment, per-edge padding, and inter-child spacing. The default container for toolbars, button rows, vertical menus, and stat panels. Reach for these first; the others are specialisations.
ScyllaUIZStackContainerLayers all children at the same content origin instead of distributing them. The right shape for a composite widget: a card with a background image, a portrait, and a notification badge all occupying the same rect. Pair with ScyllaUIPositioned to anchor individual layers.
ScyllaUIAdaptiveStackContainerA stack whose orientation is computed from its container’s aspect ratio every solve. Above AspectThreshold (width over height) it behaves as a row; below it, a column. One component covers the “side-by-side on desktop, stacked on mobile” settings screen with no manual breakpoint code.
ScyllaUIGridContainerTwo-dimensional track layout. Column and row tracks are fixed, fractional, auto, or min/max. Children auto-flow into the next free cell unless a ScyllaUIGridItem pins them. The shape behind inventory grids, ability bars, and spreadsheet-like stat screens.
ScyllaUIFlexElementDescriptorSits on a child of a stack. Sets grow weight, shrink weight, minimum size, preferred size, and an ignore-layout flag. Absent, a child is laid out at its RectTransform’s current size with no flex.
ScyllaUISpacerDescriptorA layout-only child that reserves space: a fixed gap, or a flexible gap that eats all leftover main-axis space (push the next item to the far edge). No visual; it occupies a slot.
ScyllaUIGridItemDescriptorSits on a child of a grid. Pins the child to an explicit column and row and optionally spans it across several columns and rows. Absent, the child auto-flows.
ScyllaUIPositionedDescriptorSits on a child of a Z-stack. Anchors the child to one of nine points (the TextAnchor values) with a pixel or percentage offset, for deliberate placement of a layer inside the stacked rect.
ScyllaUILayoutRootInfrastructureOne per Canvas. Collects dirty stacks and flushes them once per frame in LateUpdate, parents first, and re-solves everything on resolution, orientation, or safe-area change. Optional but recommended; without one, each stack self-solves in its own LateUpdate.
ScyllaUILayoutStyleAssetA ScriptableObject holding default padding, spacing, alignment, and an ordered list of aspect-ratio breakpoints. Assign it to many stacks to restyle them all from one asset.
ScyllaUIText / ScyllaUITextGroupHelperAuto-size a single TextMeshPro label to its box; sync a set of labels to a shared font size.
ScyllaUIScreenMarginHelperInset a RectTransform out of the device safe area plus a designer margin, per edge.
ScyllaUILayoutAnimatorHelperTween children to their new positions and sizes when a stack re-solves.
ScyllaUILayoutDebugDraw / ScyllaUILayoutGizmosDebugCanvas-space outlines and Scene-view gizmos that visualise the layout structure while authoring.

Sizing is per axis, and the mode decides where the stack’s own size comes from. Fill takes the parent slot (a point-anchored stack writes the parent size to its own sizeDelta; a stretch-anchored stack already matches its parent’s inset rect). Hug shrinks the stack to its content plus padding, summed along the main axis and taken as the maximum along the cross axis. Fixed leaves the RectTransform’s own size untouched and only arranges the children inside it.

Size modeStack’s own size becomesUse when
FillThe parent slot (the full inset rect).A full-width toolbar, a panel that should fill its half of a split, the outermost HUD container. The default.
HugThe content extent plus padding (sum on main, max on cross).A tooltip that wraps to its text, a button that is exactly as wide as its label, a context menu sized to its longest entry.
FixedWhatever the RectTransform already is.A 280-pixel sidebar, a fixed minimap frame, any panel whose size is set elsewhere and should only have its children arranged.

Within a stack, ScyllaUIAlignment controls how the children sit along the main axis when there is leftover space, and ScyllaUICrossAlignment controls the perpendicular fit. The main-axis values mirror CSS flexbox justify-content; the cross-axis values mirror align-items.

Alignment valueAxisEffect
Start / Center / EndMain and crossPack the children at the near edge, the center, or the far edge of the available space.
SpaceBetweenMainEqual gaps between children, none at the ends. The canonical “title on the left, close button on the right” bar.
SpaceAroundMainEqual gaps around each child, so the end gaps are half the inter-child gap.
SpaceEvenlyMainEqual gaps everywhere including the ends, for a perfectly even row of icons.
StretchCross onlyExpand each child to fill the cross axis. A vertical menu where every button is the panel’s full width.

Recipes

These recipes are self-contained: each one solves one game-UI problem without depending on the others, so scan the names and jump to whichever one matches what you are building. If you are new to the system, start with the first recipe – it creates the ScyllaUILayoutRoot and the first stack that most panels begin with.

Wire up the HUD root and your first panel

Problem. Every HUD starts the same way: a container that holds the rest. You want a vertical panel pinned to the screen that arranges whatever you drop into it, and you want it to re-solve itself when its contents or the screen change rather than on a manual call. Getting the layout root and the first stack wired correctly is the foundation for every panel below.

Solution. Add a ScyllaUILayoutRoot to the Canvas – it collects dirty stacks and solves them in LateUpdate, parents before children, so the ordering is always correct. Add a ScyllaUIVStack to whatever RectTransform should host the panel, set the sizing modes and alignment, then parent your child objects under it. The stack registers with the nearest ancestor root automatically.

/* One root per Canvas. It collects dirty stacks and solves them in LateUpdate,
 * parents before children, and re-solves everything when the resolution,
 * orientation, or safe area changes. Add it to the Canvas or any ancestor of
 * the layout. */
var root = canvasGameObject.AddComponent<ScyllaUILayoutRoot>();

/* A vertical stack is the spine of most panels. Add it to a RectTransform that
 * is anchored where the panel should live; the stack arranges its children
 * inside that rect. */
var hud = panelGameObject.AddComponent<ScyllaUIVStack>();
hud.MainAxisSizeMode  = ScyllaUISizeMode.Fill;   /* fill the panel vertically */
hud.CrossAxisSizeMode = ScyllaUISizeMode.Fill;   /* fill it horizontally too */
hud.Alignment         = ScyllaUIAlignment.Start; /* pack children from the top */
hud.CrossAlignment    = ScyllaUICrossAlignment.Stretch; /* full-width children */
hud.Spacing           = ScyllaUILength.Absolute(8f);    /* 8 px between children */

/* Children are ordinary RectTransforms parented under the stack. The stack
 * arranges them on the next solve; their own anchors are driven by the solver. */
var healthBar = Instantiate(healthBarPrefab, hud.transform);
var ammoLabel = Instantiate(ammoLabelPrefab, hud.transform);

/* Property setters mark the stack dirty automatically. After changing one in
 * code, the next frame's flush re-solves it; no explicit solve call is needed.
 * MarkDirty is available for the rare case where an external change (resizing
 * the panel's own rect from another script) needs to force a re-solve. */
hud.MarkDirty();

Size a panel to fill, hug its content, or stay fixed

Problem. You have a top HUD bar that should span the full width of the screen but only be as tall as its tallest child, a context menu that should size itself to whatever entries it contains, and a fixed sidebar that should always be exactly 280 pixels wide. Three distinct cases, and none of them should require you to touch anchors manually.

Solution. The ScyllaUISizeMode enum covers all three cases per axis independently. Set the mode once and the solver handles the rest, including updates when the screen resizes.

/* A top HUD bar: full screen width, height hugs the tallest child (the score
 * readout, say). Fill on main (horizontal), Hug on cross (vertical). */
var topBar = barGameObject.AddComponent<ScyllaUIHStack>();
topBar.MainAxisSizeMode  = ScyllaUISizeMode.Fill;  /* span the width */
topBar.CrossAxisSizeMode = ScyllaUISizeMode.Hug;   /* shrink to content height */

/* A context menu that is exactly as wide as its longest entry and as tall as
 * the sum of its rows: Hug on both axes. The menu sizes itself to its content. */
var contextMenu = menuGameObject.AddComponent<ScyllaUIVStack>();
contextMenu.MainAxisSizeMode  = ScyllaUISizeMode.Hug;
contextMenu.CrossAxisSizeMode = ScyllaUISizeMode.Hug;

/* A fixed 280 px sidebar: the RectTransform is sized to 280 elsewhere and the
 * stack only arranges the menu inside it. Fixed leaves the rect alone. */
var sidebar = sidebarGameObject.AddComponent<ScyllaUIVStack>();
sidebar.MainAxisSizeMode  = ScyllaUISizeMode.Fill;   /* fill the 280 px height */
sidebar.CrossAxisSizeMode = ScyllaUISizeMode.Fixed;  /* keep the 280 px width */

Use percentage padding that survives resolution changes

Problem. You add 40 pixels of side padding to a panel and it looks right on your 1080p monitor. The game ships on a 1440p monitor and a 480p handheld, and the padding is either invisible or eating a third of the screen. Pixel padding is brittle; you want padding that scales with the panel.

Solution. ScyllaUILength is either absolute pixels or a percentage of the parent dimension, and you use it for both padding and inter-child spacing. Percentage lengths resolve against the parent at solve time, so they track the panel as it resizes.

/* Absolute and percentage lengths. The percentage resolves against the parent
 * dimension when the stack solves, so it tracks resolution changes. */
ScyllaUILength gutter      = ScyllaUILength.Absolute(12f);  /* 12 px */
ScyllaUILength sidePadding = ScyllaUILength.Percent(5f);    /* 5% of parent width */

/* Per-edge padding. The four-arg constructor order is top, right, bottom, left
 * (clockwise from the top), matching CSS shorthand intuition. */
hud.Padding = new ScyllaUIEdgeInsets(
    top:    ScyllaUILength.Absolute(16f),
    right:  ScyllaUILength.Percent(5f),
    bottom: ScyllaUILength.Absolute(16f),
    left:   ScyllaUILength.Percent(5f));

/* Convenience builders for the common symmetric cases. */
contextMenu.Padding = ScyllaUIEdgeInsets.All(ScyllaUILength.Absolute(8f));
topBar.Padding      = ScyllaUIEdgeInsets.Symmetric(
    horizontal: ScyllaUILength.Absolute(20f),
    vertical:   ScyllaUILength.Absolute(6f));

/* Spacing is the gap inserted between consecutive children along the main axis,
 * independent of padding. A vertical menu with breathing room between buttons. */
hud.Spacing = ScyllaUILength.Absolute(8f);

/* Lengths resolve on demand against a parent dimension when needed directly. */
float resolvedSidePx = sidePadding.Resolve(parentDimension: 1920f);  /* 96 px */

Let one widget grow while another stays fixed

Problem. You are building a chat panel: the message log should expand to eat all the leftover vertical space in the column, but the input box below it should always stay a fixed height. When the window shrinks, the log should give up space before the input box does. This is the flexbox grow-and-shrink model, and you want it without rewriting layout logic.

Solution. Add ScyllaUIFlexElement to any child of a stack. Set a grow weight to claim leftover space, a shrink weight to yield space under pressure, a minimum size so it never collapses completely, and a preferred size that seeds the measure pass.

/* The message log: grow weight 1 means it claims all the leftover vertical space
 * in the chat column. With no other growing sibling, it takes everything left
 * after the fixed children are placed. */
var logFlex = messageLog.gameObject.AddComponent<ScyllaUIFlexElement>();
logFlex.GrowWeight    = 1f;
logFlex.MinHeight     = ScyllaUILength.Absolute(120f);  /* never collapse below 120 px */
logFlex.ShrinkWeight  = 1f;                             /* give up space first when cramped */

/* The input box: no grow weight, so it stays at its preferred height while the
 * log absorbs the slack. Preferred sizes seed the measure pass. */
var inputFlex = chatInput.gameObject.AddComponent<ScyllaUIFlexElement>();
inputFlex.PreferredHeight = ScyllaUILength.Absolute(36f);
inputFlex.ShrinkWeight    = 0f;                          /* refuse to shrink */

/* Two siblings sharing leftover space proportionally: a 2:1 split gives the
 * portrait twice the growth of the stats block. */
portraitFlex.GrowWeight = 2f;
statsFlex.GrowWeight    = 1f;

/* Take a decorative element out of layout without disabling it. The flourish
 * still renders at its own anchors but no longer consumes a slot or shifts
 * its siblings. */
var flourishFlex = cornerFlourish.gameObject.AddComponent<ScyllaUIFlexElement>();
flourishFlex.IgnoreLayout = true;

Push a button to the far edge of a toolbar

Problem. You have a toolbar with tool buttons clustered on the left and a settings cog that should sit hard against the right edge. Alignment modes get you close but you want an explicit empty gap that stretches to fill all the remaining space, pushing the cog to the edge regardless of how many tools are on the left.

Solution. ScyllaUISpacer is a layout-only child (no visual) that reserves space on the main axis. A flexible spacer swallows all leftover space, which pushes everything after it to the far edge. You can also use a fixed spacer to add a precise gap between two button groups.

/* A fixed gap between two button groups. Reserves exactly 24 px on the main axis
 * regardless of available space. */
var fixedGap = gapGameObject.AddComponent<ScyllaUISpacer>();
fixedGap.UseFixedLength = true;
fixedGap.FixedLength    = ScyllaUILength.Absolute(24f);

/* A flexible spacer: eats all leftover main-axis space, so the children after it
 * are pushed to the far edge. The toolbar trick that pins the settings cog to
 * the right while the tools cluster on the left. */
var pushRight = pushGameObject.AddComponent<ScyllaUISpacer>();
pushRight.UseFixedLength = false;                          /* flexible */
pushRight.MinLength      = ScyllaUILength.Absolute(8f);    /* keep at least 8 px */

Center a hotbar and spread a header bar

Problem. You need a header bar with the level title on the left and the pause button on the right, a hotbar row of icons that stays centered as the screen widens, and a vertical menu whose buttons all stretch to the panel’s full width. Each one needs different alignment behavior, and you want to set it without touching RectTransforms directly.

Solution. ScyllaUIAlignment controls main-axis distribution; ScyllaUICrossAlignment controls the perpendicular fit. Together they cover every standard layout shape.

/* Title left, pause button right, gap stretches between them. SpaceBetween puts
 * no gap at the ends and an equal gap between each pair of children. */
var header = headerGameObject.AddComponent<ScyllaUIHStack>();
header.Alignment      = ScyllaUIAlignment.SpaceBetween;
header.CrossAlignment = ScyllaUICrossAlignment.Center;   /* vertically centered */

/* A hotbar centered on the main axis, each icon at its natural size. */
var hotbar = hotbarGameObject.AddComponent<ScyllaUIHStack>();
hotbar.Alignment   = ScyllaUIAlignment.Center;
hotbar.Spacing     = ScyllaUILength.Absolute(6f);

/* A vertical menu where every button stretches to the panel's full width, so a
 * short label and a long label produce buttons of identical width. */
var menu = menuGameObject.AddComponent<ScyllaUIVStack>();
menu.Alignment      = ScyllaUIAlignment.Start;            /* pack from the top */
menu.CrossAlignment = ScyllaUICrossAlignment.Stretch;     /* full-width buttons */

/* Evenly spaced icons including the ends, the shape of a five-slot ability bar
 * that breathes evenly across its panel. */
var abilityBar = abilityGameObject.AddComponent<ScyllaUIHStack>();
abilityBar.Alignment = ScyllaUIAlignment.SpaceEvenly;

Layer a badge over a card without absolute positioning

Problem. Plenty of UI widgets are not a row or a column but a stack of layers occupying the same rect: an item card with a frame image, a portrait, a rarity glow, and a “new” badge in the top-right corner. You want the layers to compose without absolute pixel offsets that break as the card resizes.

Solution. ScyllaUIZStack layers all its children at the same content origin. ScyllaUIPositioned on a specific child then anchors it to one of nine TextAnchor points inside the stack with a pixel or percentage offset – that is how the badge ends up tucked in the corner with a small inset, proportionally, wherever the card grows.

/* A Z-stack layers its children at the same rect. The frame, the icon, and the
 * badge all occupy the card; later siblings render on top. */
var card = cardGameObject.AddComponent<ScyllaUIZStack>();

/* The frame and icon fill the card by default (centered, same rect). The badge
 * gets a Positioned descriptor to tuck it into the top-right corner with a small
 * inset so it does not touch the frame edge. */
var badgePos = newBadge.gameObject.AddComponent<ScyllaUIPositioned>();
badgePos.Anchor  = TextAnchor.UpperRight;
badgePos.OffsetX = ScyllaUILength.Absolute(-6f);  /* 6 px in from the right edge */
badgePos.OffsetY = ScyllaUILength.Absolute(-6f);  /* 6 px down from the top edge */

/* A percentage offset keeps the placement proportional as the card resizes: a
 * watermark a third of the way up from the bottom-left, wherever the card grows. */
var markPos = watermark.gameObject.AddComponent<ScyllaUIPositioned>();
markPos.Anchor  = TextAnchor.LowerLeft;
markPos.OffsetX = ScyllaUILength.Percent(10f);
markPos.OffsetY = ScyllaUILength.Percent(33f);

Make a settings panel go side-by-side on desktop and stack on mobile

Problem. Anyone who has shipped a settings screen has had the conversation about portrait mode. The two-column layout that reads beautifully on a monitor is unusable on a phone held upright, and the usual fix is a second hand-built layout plus a runtime branch to swap between them. You want one component, no code branch.

Solution. ScyllaUIAdaptiveStack reads its container’s aspect ratio on every solve and behaves as a horizontal stack when width-over-height is at or above AspectThreshold, and a vertical stack below it. Set the threshold, set the spacing, and it handles both orientations.

/* An adaptive stack flips orientation at its aspect threshold. A threshold of
 * 1.0 means "row when wider than tall, column when taller than wide", which is
 * the natural split for a settings panel that should be side-by-side on a
 * monitor and stacked on a phone in portrait. */
var settings = settingsGameObject.AddComponent<ScyllaUIAdaptiveStack>();
settings.AspectThreshold = 1.0f;
settings.Spacing         = ScyllaUILength.Absolute(16f);
settings.CrossAlignment  = ScyllaUICrossAlignment.Stretch;

/* A threshold above 1.0 keeps the stack vertical until the container is clearly
 * landscape: 1.5 holds the column until the width is 1.5x the height, useful for
 * a panel that should only go side-by-side on genuinely wide screens. */
settings.AspectThreshold = 1.5f;

/* The current orientation is readable, for example to swap an icon or label that
 * should differ between the row and column presentations. */
bool isRow = settings.IsHorizontal;

Build an inventory grid with spans and auto-flow

Problem. An inventory screen is the canonical two-dimensional layout: a fixed number of columns, rows that grow as the bag fills, and the occasional double-wide item that spans two cells. You want the grid to handle placement automatically while still letting you pin specific items to specific cells.

Solution. ScyllaUIGrid takes a list of column and row tracks (fixed, fractional, auto, or min/max) and auto-flows children into the next free cell. Add a ScyllaUIGridItem to any child that needs explicit placement or spanning.

/* A 4-column inventory grid. Three fixed 64 px columns plus one fractional column
 * that absorbs the leftover width, and a fixed row height. Gaps separate cells. */
var inventory = inventoryGameObject.AddComponent<ScyllaUIGrid>();
inventory.ColumnTracks.Add(ScyllaUITrack.Fixed(ScyllaUILength.Absolute(64f)));
inventory.ColumnTracks.Add(ScyllaUITrack.Fixed(ScyllaUILength.Absolute(64f)));
inventory.ColumnTracks.Add(ScyllaUITrack.Fixed(ScyllaUILength.Absolute(64f)));
inventory.ColumnTracks.Add(ScyllaUITrack.Fractional(1f));   /* fills the remainder */
inventory.RowTracks.Add(ScyllaUITrack.Fixed(ScyllaUILength.Absolute(64f)));
inventory.ColumnGap = ScyllaUILength.Absolute(4f);
inventory.RowGap    = ScyllaUILength.Absolute(4f);
inventory.MarkDirty();   /* the track lists are mutated directly; mark dirty after */

/* Auto-flow: a slot with no GridItem drops into the next free cell, left to
 * right, creating implicit rows past the explicit ones as the bag fills. */
var potion = Instantiate(slotPrefab, inventory.transform);

/* A double-wide item pinned to a cell and spanned across two columns. */
var greatsword = Instantiate(slotPrefab, inventory.transform);
var swordItem  = greatsword.AddComponent<ScyllaUIGridItem>();
swordItem.Column     = 0;
swordItem.Row        = 1;
swordItem.ColumnSpan = 2;   /* occupies columns 0 and 1 of row 1 */
swordItem.RowSpan    = 1;

/* Other track modes: Auto sizes a column to its widest child (a label column in
 * a stat sheet), MinMax clamps a column between two lengths (a name column that
 * is at least 120 px but never more than 240). */
var statSheet = statGameObject.AddComponent<ScyllaUIGrid>();
statSheet.ColumnTracks.Add(ScyllaUITrack.Auto());           /* hugs the labels */
statSheet.ColumnTracks.Add(ScyllaUITrack.MinMax(
    ScyllaUILength.Absolute(120f),
    ScyllaUILength.Absolute(240f)));
statSheet.MarkDirty();

Apply a shared style with different padding per screen width

Problem. Once your project has a dozen panels, the padding and spacing want to live in one place so a design tweak does not mean editing a dozen components. You also want the values to shift by aspect ratio: tighter padding on a phone, generous spacing on a desktop monitor, from one asset.

Solution. ScyllaUILayoutStyle is a ScriptableObject with default padding, spacing, and alignment plus an ordered list of ScyllaUIAspectBreakpoint entries. Assign it to many stacks and they all read from it; edit the asset and they all change. The breakpoints apply the highest threshold at or below the current aspect ratio.

/* A shared style asset. Many stacks reference it, so editing the asset restyles
 * all of them at once. Build it in code for a runtime theme, or author it as a
 * project asset and assign it in the Inspector. */
var panelStyle = ScriptableObject.CreateInstance<ScyllaUILayoutStyle>();
panelStyle.Padding    = ScyllaUIEdgeInsets.All(ScyllaUILength.Absolute(16f));
panelStyle.Spacing    = ScyllaUILength.Absolute(10f);
panelStyle.Alignment  = ScyllaUIAlignment.Start;

/* A breakpoint overrides chosen values above an aspect ratio. Ordered ascending
 * by MinAspectRatio; the highest threshold at or below the current aspect wins.
 * Here a wide screen (1.78 and up) gets roomier padding and spacing. */
panelStyle.AddBreakpoint(new ScyllaUIAspectBreakpoint
{
    MinAspectRatio   = 1.78f,
    OverridePadding  = true,
    PaddingOverride  = ScyllaUIEdgeInsets.All(ScyllaUILength.Absolute(32f)),
    OverrideSpacing  = true,
    SpacingOverride  = ScyllaUILength.Absolute(16f),
});

/* Assigning the style makes a stack read padding, spacing, and alignment from it
 * (with breakpoints applied for the current aspect) instead of its own fields. */
hud.Style = panelStyle;

/* The style resolves values for a given aspect ratio on demand, for previewing
 * or for driving non-stack UI from the same theme. */
ScyllaUIEdgeInsets padAt16x9 = panelStyle.ResolvePadding(aspectRatio: 1.78f);
ScyllaUILength     gapAt16x9 = panelStyle.ResolveSpacing(aspectRatio: 1.78f);

Fit localized labels into fixed-size buttons

Problem. Localization breaks fixed font sizes. A button that reads “OK” at 24 pt has to hold “Bestatigen” in the same box, and a hand-set size that fits one language clips another. A row of buttons where each one auto-sizes independently ends up with mismatched font sizes that look inconsistent.

Solution. ScyllaUIText wraps a TextMeshPro label and drives its auto-size to fit the box between a minimum and maximum point size. ScyllaUITextGroup then reads every member’s fitted size and pushes the smallest to all of them, so the whole row lands on one even size.

/* Auto-size a single label to its box. The text shrinks between the min and max
 * point size to fit whatever string and box it is given, so a long localised
 * string clips gracefully instead of overflowing. */
var label = okButton.AddComponent<ScyllaUIText>();
label.EnableAutoSize = true;
label.MinFontSize    = 12f;
label.MaxFontSize    = 32f;
label.TMP.text       = "Confirm";   /* the wrapped TextMeshPro component */
label.Apply();                      /* push the auto-size settings to TMP */

/* The currently fitted size is readable, for example to drive a matching icon
 * scale or to log how small the text had to go. */
float fittedPt = label.CurrentFontSize;

/* Override the auto-fit with an explicit size when one label must not shrink. */
label.OverrideFontSize(20f);

/* A text group syncs a set of labels to one shared size. Each button auto-fits
 * its own label, then the group reads the smallest fitted size and applies it to
 * all members, so a "Yes / No / Maybe" row reads evenly rather than each button
 * sizing independently. */
var buttonRow = buttonRowGameObject.AddComponent<ScyllaUITextGroup>();
buttonRow.AutoDiscover     = true;   /* find ScyllaUIText members in children */
buttonRow.SyncInLateUpdate = true;   /* re-sync every frame as boxes resize */
buttonRow.RefreshMembers();          /* rebuild the member list after a child change */

/* Sync on demand and read back the shared size the group settled on. */
float sharedPt = buttonRow.Sync();

Keep HUD content clear of notches and the gesture bar

Problem. A notch eats the top-left of a phone screen, a rounded corner clips a pause button, and the gesture bar at the bottom of a handheld swallows a tap target. You want your HUD content to stay inside the safe area automatically, and you want it to update when the player rotates the device.

Solution. ScyllaUIScreenMargin reads Screen.safeArea and insets a RectTransform out of it on the edges you choose, plus an extra designer margin. It re-applies whenever the resolution, orientation, or safe area changes.

/* Inset the HUD container out of the safe area on all four edges, with a small
 * designer margin on top so nothing touches the rounded corners. The component
 * drives the RectTransform's anchors and offsets. */
var margin = hudContainer.AddComponent<ScyllaUIScreenMargin>();
margin.SafeAreaTop    = true;
margin.SafeAreaRight  = true;
margin.SafeAreaBottom = true;
margin.SafeAreaLeft   = true;
margin.DesignerMargin = ScyllaUIEdgeInsets.All(ScyllaUILength.Absolute(12f));

/* Inset only the edges that matter: a landscape game with a notch on the left
 * insets the left and leaves the rest flush, reclaiming the safe bottom strip
 * for a wide toolbar. */
margin.SafeAreaTop    = false;
margin.SafeAreaBottom = false;

/* Force a specific safe-area rect for testing a notch layout in the editor
 * without a device. Pass null to return to the live Screen.safeArea. */
margin.OverrideSafeArea = new Rect(0, 0, Screen.width, Screen.height - 132);
margin.Apply();   /* re-apply immediately rather than waiting for the next change */

Ease a party roster reflow instead of snapping

Problem. When a party member joins and the roster reflows, every portrait jumps to its new slot. That snap reads as cheap in a game that otherwise takes care with its feel. You want the portraits to glide to their new positions, and you want the motion on only the panels where it earns its place, not everywhere.

Solution. ScyllaUILayoutAnimator is a per-stack opt-in that hooks the solve, captures each child’s before-state, and tweens to the after-state. It uses the Tween system and exposes the duration, easing curve, and which properties to animate.

/* Add the animator to a stack to ease its children between layouts. It hooks the
 * stack's solve, captures the before-state, and tweens to the after-state. */
var animator = roster.AddComponent<ScyllaUILayoutAnimator>();
animator.Enabled         = true;
animator.Duration        = 0.25f;                /* quarter-second reflow */
animator.Ease            = EaseType.OutCubic;    /* decelerate into place */
animator.AnimatePosition = true;                 /* tween anchored position */
animator.AnimateSize     = true;                 /* tween sizeDelta too */

/* Turning it off makes layout changes snap instantly again, for example during a
 * fast-forward or when the panel is off-screen and the tween would be wasted. */
animator.Enabled = false;

Visualise nested stacks while authoring

Problem. A layout that comes out wrong is hard to read from the Hierarchy alone, because the interesting state is the rects the solver computed, not the serialized anchors. You want to see the structure of nested stacks and grids directly in the Game view while you are building them.

Solution. ScyllaUILayoutDebugDraw paints a thin canvas-space outline around every container and wrapped child in a distinct color per component type. ScyllaUILayoutGizmos draws the padding band and the inter-child gap regions in the Scene view. Both cost nothing when disabled, so you can bind the overlay to a debug key and leave it in the build.

/* Add the overlay to the layout root (or any ancestor) and toggle it on. It
 * walks the descendant hierarchy after each solve and outlines every stack,
 * grid item, spacer, flex element, and text component in its own color. */
var debug = root.gameObject.AddComponent<ScyllaUILayoutDebugDraw>();
debug.Enabled = true;

/* The root exposes the same toggle as a convenience, attaching the component on
 * demand, so a debug key binding can flip the overlay in one line. */
root.DebugDrawEnabled = true;

/* Per-type colors and line thickness come from the config. The default config is
 * a sensible starting point; swap in a custom one to recolor the overlay. */
debug.Config = ScyllaUILayoutDebugDrawConfig.Default;

API Sketch

The surface is a small inheritance tree of container components rooted at ScyllaUIStackBase, a set of per-child descriptor components, the value types they consume, and a handful of helpers. Containers and descriptors are MonoBehaviours added in the Inspector or via AddComponent<T>(); the value types are plain structs; ScyllaUILayoutStyle is a ScriptableObject. The solver itself is internal and never called directly.

/* ----- Containers (all derive from ScyllaUIStackBase) ----- */
public abstract class ScyllaUIStackBase : UIBehaviour, ILayoutElement
{
    public ScyllaUIAlignment      Alignment        { get; set; }
    public ScyllaUICrossAlignment CrossAlignment   { get; set; }
    public ScyllaUIEdgeInsets     Padding          { get; set; }
    public ScyllaUILength         Spacing          { get; set; }
    public ScyllaUILayoutStyle    Style            { get; set; }
    public ScyllaUISizeMode       MainAxisSizeMode  { get; set; }
    public ScyllaUISizeMode       CrossAxisSizeMode { get; set; }
    public RectTransform          RectTransform    { get; }
    public abstract bool          IsHorizontal     { get; }
    public virtual  bool          IsZStack         { get; }
    public virtual  bool          IsGrid           { get; }
    public void MarkDirty();
}

public sealed class ScyllaUIHStack : ScyllaUIStackBase { }
public sealed class ScyllaUIVStack : ScyllaUIStackBase { }
public sealed class ScyllaUIZStack : ScyllaUIStackBase { }

public sealed class ScyllaUIAdaptiveStack : ScyllaUIStackBase
{
    public float AspectThreshold { get; set; }
}

public sealed class ScyllaUIGrid : ScyllaUIStackBase
{
    public List<ScyllaUITrack> ColumnTracks { get; }
    public List<ScyllaUITrack> RowTracks    { get; }
    public ScyllaUILength      ColumnGap    { get; set; }
    public ScyllaUILength      RowGap       { get; set; }

    public static void    ResolveTrackSizes(List<ScyllaUITrack> tracks, int trackCount,
                              float totalAvailable, float gap, float[] autoSizes, out float[] sizes);
    public static float[] ComputeTrackOffsets(float[] sizes, float gap);
}

/* ----- Per-child descriptors ----- */
public sealed class ScyllaUIFlexElement : MonoBehaviour
{
    public float          GrowWeight     { get; set; }
    public float          ShrinkWeight   { get; set; }
    public ScyllaUILength MinWidth       { get; set; }
    public ScyllaUILength MinHeight      { get; set; }
    public ScyllaUILength PreferredWidth { get; set; }
    public ScyllaUILength PreferredHeight{ get; set; }
    public ScyllaUIFitMode FitMode       { get; set; }
    public bool           IgnoreLayout   { get; set; }
}

public sealed class ScyllaUISpacer : MonoBehaviour
{
    public bool           UseFixedLength { get; set; }
    public ScyllaUILength FixedLength    { get; set; }
    public ScyllaUILength MinLength      { get; set; }
}

public sealed class ScyllaUIGridItem : MonoBehaviour
{
    public int Column { get; set; }
    public int Row    { get; set; }
    public int ColumnSpan { get; set; }
    public int RowSpan    { get; set; }
}

public sealed class ScyllaUIPositioned : MonoBehaviour
{
    public TextAnchor     Anchor  { get; set; }
    public ScyllaUILength OffsetX { get; set; }
    public ScyllaUILength OffsetY { get; set; }
}

/* ----- Value types ----- */
public struct ScyllaUILength
{
    public static ScyllaUILength Absolute(float pixels);
    public static ScyllaUILength Percent(float percent);
    public float Resolve(float parentDimension);
    public bool  IsPercent { get; }
}

public struct ScyllaUIEdgeInsets
{
    public ScyllaUIEdgeInsets(ScyllaUILength top, ScyllaUILength right, ScyllaUILength bottom, ScyllaUILength left);
    public static ScyllaUIEdgeInsets All(ScyllaUILength value);
    public static ScyllaUIEdgeInsets Symmetric(ScyllaUILength horizontal, ScyllaUILength vertical);
    public float ResolveHorizontal(float parentWidth);
    public float ResolveVertical(float parentHeight);
}

public struct ScyllaUITrack
{
    public static ScyllaUITrack Fixed(ScyllaUILength size);
    public static ScyllaUITrack Fractional(float weight);
    public static ScyllaUITrack Auto();
    public static ScyllaUITrack MinMax(ScyllaUILength min, ScyllaUILength max);
}

/* ----- Style asset ----- */
public sealed class ScyllaUILayoutStyle : ScriptableObject
{
    public ScyllaUIEdgeInsets     Padding        { get; set; }
    public ScyllaUILength         Spacing        { get; set; }
    public ScyllaUIAlignment      Alignment      { get; set; }
    public ScyllaUICrossAlignment CrossAlignment { get; set; }
    public IReadOnlyList<ScyllaUIAspectBreakpoint> Breakpoints { get; }
    public void AddBreakpoint(ScyllaUIAspectBreakpoint breakpoint);
    public void ClearBreakpoints();
    public ScyllaUIEdgeInsets ResolvePadding(float aspectRatio);
    public ScyllaUILength     ResolveSpacing(float aspectRatio);
    public ScyllaUIAlignment  ResolveAlignment(float aspectRatio);
}

/* ----- Infrastructure and helpers ----- */
public sealed class ScyllaUILayoutRoot : MonoBehaviour
{
    public bool SolveInLateUpdate { get; set; }
    public bool WatchResolution   { get; set; }
    public bool WatchOrientation  { get; set; }
    public bool WatchSafeArea     { get; set; }
    public bool DebugDrawEnabled  { get; set; }
    public void SolveAllNow();
}

public sealed class ScyllaUIText : MonoBehaviour
{
    public bool     EnableAutoSize  { get; set; }
    public float    MinFontSize     { get; set; }
    public float    MaxFontSize     { get; set; }
    public TMP_Text TMP             { get; }
    public float    CurrentFontSize { get; }
    public void Apply();
    public void OverrideFontSize(float fontSize);
}

public sealed class ScyllaUITextGroup : MonoBehaviour
{
    public List<ScyllaUIText> Members          { get; }
    public bool               AutoDiscover     { get; set; }
    public bool               SyncInLateUpdate { get; set; }
    public void  RefreshMembers();
    public float Sync();
}

public sealed class ScyllaUIScreenMargin : MonoBehaviour
{
    public bool SafeAreaTop    { get; set; }
    public bool SafeAreaRight  { get; set; }
    public bool SafeAreaBottom { get; set; }
    public bool SafeAreaLeft   { get; set; }
    public ScyllaUIEdgeInsets DesignerMargin { get; set; }
    public Rect? OverrideSafeArea { get; set; }
    public void Apply();
}

public sealed class ScyllaUILayoutAnimator : MonoBehaviour
{
    public bool     Enabled         { get; set; }
    public float    Duration        { get; set; }
    public EaseType Ease            { get; set; }
    public bool     AnimatePosition { get; set; }
    public bool     AnimateSize     { get; set; }
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Settings reference

The components below carry the settings that shape a layout. Defaults are what a freshly added component reports.

Component / propertyDefaultValuesEffect
ScyllaUIStackBase.MainAxisSizeModeFillFill, Hug, FixedHow the stack sizes itself along its distribution axis. Fill takes the parent slot, Hug shrinks to content, Fixed keeps the RectTransform’s size.
ScyllaUIStackBase.CrossAxisSizeModeFillFill, Hug, FixedSame three modes for the perpendicular axis.
ScyllaUIStackBase.AlignmentStartStart, Center, End, SpaceBetween, SpaceAround, SpaceEvenlyMain-axis distribution of children in the leftover space. The space modes only differ when there is leftover space to distribute.
ScyllaUIStackBase.CrossAlignmentStretchStart, Center, End, StretchCross-axis fit. Stretch expands each child to the cross extent; the rest pin the child at the near edge, center, or far edge.
ScyllaUIStackBase.PaddingzeroScyllaUIEdgeInsetsInner padding per edge, subtracted from the content area before children are placed.
ScyllaUIStackBase.SpacingzeroScyllaUILengthGap inserted between consecutive children on the main axis.
ScyllaUIStackBase.StylenoneScyllaUILayoutStyleWhen assigned, the stack reads padding, spacing, and alignment from the style (with breakpoints applied) instead of its own fields.
ScyllaUIAdaptiveStack.AspectThreshold1.0float > 0Width-over-height ratio at or above which the stack is a row; below it, a column.
ScyllaUIGrid.ColumnGap / RowGapzeroScyllaUILengthGap between columns and between rows.
ScyllaUIFlexElement.GrowWeight0float >= 0Share of leftover main-axis space this child claims. Zero means it does not grow.
ScyllaUIFlexElement.ShrinkWeight0float >= 0Share of the deficit this child gives up when the stack is over-allocated. Zero means it refuses to shrink.
ScyllaUIFlexElement.IgnoreLayoutfalseboolWhen true, the child is excluded from the solve entirely and keeps its own anchors.
ScyllaUISpacer.UseFixedLengthfalseboolTrue reserves FixedLength; false makes the spacer flexible and absorb leftover space.
ScyllaUIGridItem.ColumnSpan / RowSpan1int >= 1Number of tracks the pinned child spans.
ScyllaUILayoutRoot.SolveInLateUpdatetrueboolWhen true, the root flushes dirty stacks each frame in LateUpdate.
ScyllaUILayoutRoot.WatchResolution / WatchOrientation / WatchSafeAreatrueboolRe-solve all registered stacks when the matching screen property changes.
ScyllaUIText.MinFontSize / MaxFontSizeTMP defaultsfloatThe point-size bounds the auto-sizer fits the label between.
ScyllaUITextGroup.SyncInLateUpdatetrueboolWhen true, the group re-syncs its members’ font size every frame; false syncs only on an explicit Sync() call.
ScyllaUIScreenMargin.SafeAreaTop / Right / Bottom / LefttrueboolWhich edges are inset out of the device safe area.
ScyllaUILayoutAnimator.Duration0.2float >= 0Tween length in seconds for a reflow.
ScyllaUILayoutAnimator.EaseOutCubicEaseTypeEasing curve applied to the position and size tweens.

Best Practices

  • One layout root per Canvas. The root batches the per-frame solve and orders it parents-before-children, which avoids the double-solve and one-frame flicker that a child-first order produces. A stack without a root falls back to solving itself, which works but loses the ordering guarantee; add a root to any Canvas that hosts more than one stack.
  • Reach for the stacks first, the grid second, the Z-stack third. Most UI is rows and columns. A grid is for genuine two-dimensional content (inventories, ability matrices); a Z-stack is for layered composite widgets. Using a grid where a stack would do adds track bookkeeping for no benefit.
  • Express sizes as percentages or lengths, not hardcoded anchors. A ScyllaUILength.Percent(5f) side padding tracks every resolution; a typed-in 40f anchor does not. The whole point of the system is to stop re-anchoring per target, so lean on the value types rather than reaching back into raw RectTransform offsets.
  • Give Hug stacks point anchors on the hugging axis. Hug writes the content size to sizeDelta, which only takes effect when the anchors leave that dimension to the rect. A Hug stack on stretch anchors silently keeps its stretched size. Point anchors (anchorMin == anchorMax) on the hugging axis are the fix.
  • Keep percentages off Hug axes. A percentage length on a Hug axis is a circular dependency and resolves to zero with a one-time warning. Percentages belong on Fill or Fixed axes, where the dimension is known before the children are measured.
  • Set a flex child’s minimum size. A growing child with no MinWidth or MinHeight can be squeezed to nothing when the container shrinks. A minimum keeps a chat log readable and a portrait visible at the smallest supported resolution.
  • Use a shared style for anything that repeats. A ScyllaUILayoutStyle asset assigned to every panel turns a project-wide padding change into a one-asset edit, and its breakpoints give responsive spacing for free. Build the style in code for a runtime theme or author it as an asset for a fixed one.
  • Mark the grid dirty after mutating its track lists. ColumnTracks and RowTracks are mutable lists; adding or removing a track does not auto-mark the grid dirty the way a property setter does. Call MarkDirty() after editing them so the change is picked up on the next flush.
  • Animate selectively. A ScyllaUILayoutAnimator is worth it on panels where reflow motion reads as polish (a roster, a re-sorting inventory) and wasted on high-churn or off-screen panels. It is a per-stack opt-in, so add it only where the motion matters.
  • Drive auto-sized text groups in play mode. The group’s per-frame re-fit relies on a live TextMeshPro mesh update and runs only in play mode. For a static row of labels whose boxes never resize, sync once after building rather than every frame, by setting SyncInLateUpdate to false and calling Sync().
  • Inset only the safe-area edges that matter. A landscape game with a left-side notch should inset the left edge and leave the others flush, reclaiming the safe bottom strip for a wide toolbar. Toggling all four on by reflex throws away usable screen real estate on devices whose safe area is asymmetric.
  • Turn on the debug overlay while authoring. ScyllaUILayoutDebugDraw makes nested stack and grid structure visible in the Game view, which turns “why is this child the wrong size” from a guess into a glance. It costs nothing when disabled, so bind it to a debug key.

Pitfalls