Tier 2 · Responsive UI and orientation
UI across orientations
One UI tree that works in portrait and landscape.
One UI tree that works in portrait and landscape.
For a screen that must work in portrait and landscape. Layout behaviour of a built screen is the UI Toolkit runtime page; the USS capability delta is the uss uxml page. This file is only the orientation axis.
PLAYER-ORIENTATION-IS-NOT-UI-ORIENTATION (HARD)
Setting PlayerSettings.defaultInterfaceOrientation = AutoRotation and enabling all four
allowedAutorotateTo* flags does not make the UI adapt. It only lets the OS hand the app a
rotated surface; a layout with fixed pixel positions rotates into a broken version of itself.
Orientation support is three separate things, and the first two are the ones agents skip:
- USS authored to reflow — flex directions and percentages, never absolute pixel positions for
structure.
position: absoluteis for overlays, not for the skeleton. - A direction switch — one
landscapeclass toggled on the document root whenScreen.width > Screen.height, with USS rules that only change direction and proportion, never content. One UXML tree, two layouts. Duplicating the UXML per orientation is the failure mode: the two copies drift and every later change costs double. - The player settings — which merely permit the rotation.
Minimal switch (the whole mechanism):
bool landscapeNow = Screen.width > Screen.height;
if (!_applied || landscapeNow != _isLandscape)
{
_isLandscape = landscapeNow;
_applied = true;
_root.EnableInClassList("landscape", _isLandscape); // _root = UIDocument.rootVisualElement
}
Put it on the UIDocument's GameObject with the document as a [SerializeField] — not a
GetComponent (the references page). Guard on a change flag: EnableInClassList every frame
is wasted style invalidation.
PANEL-SCALE-MODE-DECIDES-BOTH-ORIENTATIONS
PanelSettings defaults to Constant Pixel Size, which does not scale with the screen at all — the
single most common reason a UITK screen looks right in the editor and wrong on device.
For dual orientation: scaleMode = ScaleWithScreenSize, screenMatchMode = MatchWidthOrHeight, and
match = 0.5. Match 0 keys entirely off width and 1 entirely off height; either makes the other
orientation the one that breaks, because the axis it ignores is the one that changed. 0.5 splits the
difference so neither orientation is privileged. Set match through SerializedObject on m_Match
if the C# property is not exposed in your version.
Note the panel's logical size is not the screen resolution once scaling is on — a 1920×1080 screen
against a 1080×1920 reference resolves to a panel of roughly 1641×923. Never assume
rootVisualElement.resolvedStyle.width == Screen.width.
VERIFY-ORIENTATION-BY-RESOLVEDSTYLE-NOT-SCREENSHOT
A screenshot proves something rendered. It does not prove the layout reacted — a screen that ignored the switch and one that handled it can look similarly plausible at a glance.
Assert the resolved geometry at both aspects:
root.ClassListContains("landscape") // the switch fired
table.resolvedStyle.flexDirection // Column in portrait, Row in landscape
opponents.resolvedStyle.flexDirection // Row in portrait, Column in landscape
Measured on the corpus that produced this rule: portrait 1080×1920 → table Column /
opponents Row; landscape 1920×1080 → table Row / opponents Column, from one UXML. That is the
evidence that makes the claim play-verified rather than "the screenshot looked fine".
Drive the two aspects from the editor by adding FixedResolution entries to the current build
target group's GameViewSizes and setting GameView.selectedSizeIndex (game-view presets are
per-build-target-group — the HTML/CSS mock to UI Toolkit page). Set PlayerSettings.runInBackground = true first, or
an unfocused editor stops rendering play mode and every capture is a stale frame.
A NEW PROJECT HAS NO RUNTIME THEME
PanelSettings.themeStyleSheet is null in a fresh project, and a panel with no theme renders nothing
— no error, no warning, just an empty screen that reads as "my UXML is wrong".
Create one before the first screen: a .tss file whose entire contents are
@import url("unity-theme://default");
Unity imports that into a ThemeStyleSheet you can assign. Then zero the default theme's label and
button chrome once, or every vertical metric lands ~40% tall (UITK-DEFAULT-THEME-CHROME-RESET,
the HTML/CSS mock to UI Toolkit page).
PORTRAIT-ONLY-MATCHES-WIDTH (HARD)
PANEL-SCALE-MODE-DECIDES-BOTH-ORIENTATIONS prescribes match 0.5 for a UI that must survive both
orientations. A single-orientation design authored at a fixed width uses match 0 (width).
Match 0.5 scales by the geometric mean of the two axis ratios, so on any phone taller than the
reference aspect the logical width comes out BELOW the reference: FOURCE's 450x800 table on a
1440x3120 phone (787x1706 after Fixed DPI 328) resolved to a 405x879 logical panel, and every
element authored at 450 wide overflowed the frame - the hand rows ran off both edges, seats and
buttons crowded. match 0 gave 450x975: the width fits exactly and the extra height goes to
whichever band is flex-grow: 1, which is the pile area on a card table. Rule: the moment the
developer locks an orientation, flip the match to the axis the design was authored on, and read the
logical size back (panel.visualTree.resolvedStyle) at the device's post-DPI render size, not
in a free-floating Game view. The invariant behind this AND behind match 1 for a fixed vertical
stack is PANEL-MATCH-AXIS in the HTML/CSS mock to UI Toolkit page: match the axis the design was authored on;
0.5 is only for dual orientation.
PANELSETTINGS-MUTATION-NEEDS-STATIC-OWNER
PanelSettings is ONE asset shared by every panel, and an additive swap loads the incoming scene
BEFORE unloading the outgoing one (ADDITIVE-SWAP-LOAD-THEN-UNLOAD). Per-component capture-in-
OnEnable / restore-in-OnDisable of referenceResolution therefore runs in the order: incoming
applies landscape, outgoing disables and restores portrait over it — landscape silently does
nothing while every USS rule matches. Anything that mutates the shared asset needs process-wide
ownership: a static capture of the original plus a user count; the first panel in captures, only
the LAST one out restores. Verify by reading panelSettings.referenceResolution back after the
swap. (a card game.)