Skip to main content

Layout and Yoga

Yoga owns all placement. You write typed flex parameters in C#; both shells hand them to the same C++ flexbox engine and place every child at the frame it computes. No shell ever lays out a child itself.

This is the mechanism behind the parity contract: two shells that both delegate to one engine can be held to identical numbers. Two shells that each did their own layout could not.

Two trees, one of them invisible

Each shell keeps a tree of real platform widgets and a Yoga node tree beside it. When a style patch arrives, its name decides which tree it lands in:

NamesGo toExamples
Layoutthe Yoga nodeflexDirection, justifyContent, width, margin, padding, …
Visualthe viewbackgroundColor, color, fontSize, …

The partition is an allow-list, not a heuristic — and because it is hand-written in the renderer's C#, the Android shell's Kotlin, and the iOS shell's Objective-C++, a name present in one and missing from another would be silently dropped. So a drift test in the required CI lane parses all three and asserts set-equality. Every accepted name is a name three parsers must implement, which is why the accepted set is small and grows deliberately.

Containers are layout-suppressed frame containers: they exist to hold children, not to arrange them. Leaves are measured natively through Yoga's measure callback — a long label wraps, its real measured height comes back from the platform's own text engine, and that height drives its row.

The flex surface

It is declared once, on two abstract bases, and nearly every component inherits it:

ParametersDeclared on
ItemAlignSelf · Grow · Shrink · Basis · MarginBnLayoutItem
SizeWidth · Height · MinWidth · MaxWidth · MinHeight · MaxHeightBnLayoutItem
PositionPosition · Top · Right · Bottom · LeftBnLayoutItem
VisualBackgroundColorBnLayoutItem
ContainerJustify · Align · Wrap · Gap · PaddingBnLayoutContainer

BnLayoutContainer derives from BnLayoutItem, so a container is also an item — a BnColumn can be given a Margin by its own parent.

This means the size and position parameters are not a BnView privilege. <BnText Width="100" Margin="8" /> works, and so does <BnButton MaxWidth="200" />. Thirteen components inherit one of the two bases; the exceptions are BnList<TItem> and BnModal, and both are on an explicit allowlist with a written reason.

BnRow and BnColumn are thin presets over BnView: they forward every parameter except Direction, because a BnRow is a row. Reach for BnView when the direction is dynamic.

There is deliberately no BnStack — it would be a synonym for BnColumn, and two names for one thing is a library smell on day one.

<BnColumn Gap="16" Padding="16">

@* Grow absorbs the free space: the middle box computes the same on both platforms *@
<BnRow Width="300" Height="100">
<BnView Width="50" BackgroundColor="#E57373" />
<BnView Grow="1" BackgroundColor="#64B5F6" />
<BnView Width="50" BackgroundColor="#81C784" />
</BnRow>

<BnRow Justify="FlexJustify.SpaceBetween" Align="FlexAlign.Center">
<BnText Text="Left" />
<BnText Text="Right" />
</BnRow>

@* A long label wraps, and its NATIVELY MEASURED height drives its row *@
<BnRow Width="150">
<BnText Text="A label long enough to wrap onto several lines." />
</BnRow>

</BnColumn>

Lengths are typed

Every length is a BnLength or a BnAutoLength, not a string. Width="12px" does not compile.

That matters because the grammar has no units. A length is a bare number of density-independent points, a percentage, or — where the property allows it — auto. 12px is CSS muscle memory, and before these types it compiled, shipped, and was logged-and-ignored by both shells at runtime.

<BnView Width="200" @* points a plain number, unchanged *@
Height="12.5" @* decimals are fine too *@
MinWidth="@BnLength.Percent(50)" @* percentages *@
Margin="@BnAutoLength.Auto" /> @* auto, where it is legal *@

Plain numbers need no ceremony — they convert implicitly, and they are the overwhelming majority of real markup. Only percentages and auto need the explicit spelling.

The two types differ by exactly one case:

TypePointsPercentautoUsed by
BnLengthMinWidth · MaxWidth · MinHeight · MaxHeight · Top · Right · Bottom · Left · Padding · Gap
BnAutoLengthWidth · Height · Basis · Margin

The split is not cosmetic: Margin accepts auto and Padding does not, because that is what the shells enforce. auto on a margin absorbs free space and re-centres the node.

The errors do not mention lengths

When a component parameter is not a string, Razor compiles the attribute's literal text as a C# expression. So 12px never reaches a length parser at all — it is 12 followed by px, and you get a syntax error:

MarkupError
Width="12px"CS1003 — "syntax error, ',' expected"
Width="50%"CS1525 — a binary % missing its right operand
Width="auto" · Width="abc"CS0103 — undefined identifier

Cryptic, but early: these are compile errors, not runtime log lines nobody reads.

One trap worth knowing

The conversions from float are implicit operators, which exist only at compile time. If a layout value passes through object — a ParameterView, a Dictionary<string, object?>, a hand-written AddComponentParameter — the compiler never gets to apply them, and Blazor casts rather than converts. That compiles and throws at first render:

["Width"] = 200f // ✗ throws — boxed float, no runtime conversion
["Width"] = (BnAutoLength)200f // ✓ box the constructed type

Ordinary markup is unaffected. See Migrating to typed layout lengths for the full upgrade path.

BnScroll — the rule that catches everybody

BnScroll is a flex item, not a flex container. It has no Direction, Justify, Align, Wrap, Gap or Padding, and that is by construction rather than oversight.

Those parameters would style the viewport, whose only child is a shell-synthesised content node. Justify="Center" over 800dp of content in a 200dp viewport would offset it to y = −300 and make the top of the page permanently unreachable. To shape the content, compose inside the scroll — React Native's contentContainerStyle, without a second style surface:

@* BnScroll is a VIEWPORT: give it a definite height, compose the content inside *@
<BnScroll Height="200">
<BnColumn Gap="8">
@foreach (var row in Rows)
{
<BnRow Height="80"><BnText Text="@row" /></BnRow>
}
</BnColumn>
</BnScroll>

The shells enforce the same rule at the wire, so the raw-element hatch is closed by the same sentence.

Give it a definite height

Use Height, or Grow="1" Basis="0" in a bounded parent.

Grow="1" alone is not enough, and the failure is silent. It leaves flexBasis: auto, so the basis becomes the content's height, the free space goes negative, and flexGrow distributes only positive free space — the viewport hugs its content and never scrolls. That is exactly why CSS's flex: 1 shorthand sets basis to 0. The shells emit a diagnostic when a viewport is indefinite.

Honest boundaries

These are real limits, not omissions from this page:

  • No horizontal scroll. Android's ScrollView is vertical-only; horizontal is a different widget class, which would have to be chosen at node creation from a flexDirection that arrives in a later style patch.
  • No onScroll / scrollTo, and no scroll-offset restore across navigation. onScroll fires at 60 Hz and would be the first high-frequency producer on a wire designed for taps.
  • alignContent, rowGap, columnGap, display, flex are accepted by nothing — no typed parameter, no producer.
  • BnPicker does not flex its children. Spinner and UIPickerView are framework containers that run their own layout inside themselves. The picker node itself is placed correctly by its parent.
  • One file pixel = one dp/pt for images, so a @2x asset renders at twice its intended physical size on both platforms.