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. The renderer's C#, the Android shell's Kotlin and the iOS shell's Objective-C++ and Swift all need it, and a name present in one copy and missing from another would be silently dropped — so no copy is written by hand. The names live once, in src/wire-vocabulary.json; a generator emits every language's table from it, and a test in the required CI lane regenerates them and fails if a committed copy differs. Every accepted name is still a name both shells 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 · Padding · PaddingTop · PaddingRight · PaddingBottom · PaddingLeftBnLayoutContainer

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" />. Every component inherits one of the two bases except 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
BnLength✅✅❌MinWidth · MaxWidth · MinHeight · MaxHeight · Top · Right · Bottom · Left · Padding and its per-edge forms · Gap
BnAutoLength✅✅✅Width · 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>

@code {
// Rows stands for your own data source.
private static readonly string[] Rows = ["Row 1", "Row 2", "Row 3"];
}

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.

Reading and moving the scroll position​

OnScroll reports the vertical content offset as BnScrollEventArgs.OffsetY, in dp on Android and pt on iOS. Events are conflated — at most one per rendered frame, latest offset wins — so it tells you where the viewport is, not how many times it moved. To move it, take a @ref and call ScrollToAsync(offset) or ScrollToEndAsync(); for an append-driven view such as a log or a chat transcript, AutoScrollToEnd="true" scrolls to the end on every render instead. Both methods complete when the command is queued for the next frame, not when the view stops moving — watch OnScroll to see where it landed.

<BnScroll @ref="_scroll" Height="200" OnScroll="OnScrolled">
<BnColumn Gap="8">
@foreach (var line in _lines)
{
<BnText Text="@line" />
}
</BnColumn>
</BnScroll>
<BnText Text="@($"Scrolled to {_offset:0} dp/pt")" />
<BnButton Label="Add a line" OnClick="AddLineAsync" />

@code {
private BnScroll? _scroll;
private readonly List<string> _lines = ["line 1"];
private float _offset;

private void OnScrolled(BnScrollEventArgs e) => _offset = e.OffsetY;

private async Task AddLineAsync()
{
_lines.Add($"line {_lines.Count + 1}");
if (_scroll is not null)
await _scroll.ScrollToEndAsync();
}
}

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 scroll-offset restore across navigation.
  • 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.