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:
| Names | Go to | Examples |
|---|---|---|
| Layout | the Yoga node | flexDirection, justifyContent, width, margin, padding, … |
| Visual | the view | backgroundColor, 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:
| Parameters | Declared on | |
|---|---|---|
| Item | AlignSelf · Grow · Shrink · Basis · Margin | BnLayoutItem |
| Size | Width · Height · MinWidth · MaxWidth · MinHeight · MaxHeight | BnLayoutItem |
| Position | Position · Top · Right · Bottom · Left | BnLayoutItem |
| Visual | BackgroundColor | BnLayoutItem |
| Container | Justify · Align · Wrap · Gap · Padding · PaddingTop · PaddingRight · PaddingBottom · PaddingLeft | BnLayoutContainer |
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:
| Type | Points | Percent | auto | Used 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:
| Markup | Error |
|---|---|
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
ScrollViewis vertical-only; horizontal is a different widget class, which would have to be chosen at node creation from aflexDirectionthat arrives in a later style patch. - No scroll-offset restore across navigation.
alignContent,rowGap,columnGap,display,flexare accepted by nothing — no typed parameter, no producer.BnPickerdoes not flex its children.SpinnerandUIPickerVieware 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
@2xasset renders at twice its intended physical size on both platforms.