Magnet

Magnet is a constraint-based layout: every child is positioned by anchoring its sides to other children, to virtual nodes (barriers, guidelines, chains) or to the parent stage — the same mental model as Android's ConstraintLayout, without nesting.

Magnet

Magnet 2.0 is a full rewrite: the constraint graph is compiled into a small instruction tape that is executed on every measure/arrange with zero allocations. Measure+arrange cost is now on par with Grid, with a fraction of the allocations; see Performance.

Coming from Magnet 1.x? Read the migration guide: the API changed.

Quick start

xmlns:nalu="https://nalu-development.github.com/nalu/layouts"

<nalu:Magnet>
  <nalu:Magnet.Definition>
    <nalu:MagnetDefinition>
      <nalu:MagnetBarrier MagnetId="textsEnd" Direction="Bottom" Margin="8" Nodes="avatar,subtitle" />
    </nalu:MagnetDefinition>
  </nalu:Magnet.Definition>

  <Image  nalu:Magnet.MagnetId="avatar"
          nalu:Magnet.WidthSizing="48" nalu:Magnet.HeightSizing="48"
          nalu:Magnet.LeftTo="parent.Left,16" nalu:Magnet.TopTo="parent.Top,16" />
  <Label  nalu:Magnet.MagnetId="title"
          nalu:Magnet.After="avatar,12,0" nalu:Magnet.RightTo="parent.Right,16"
          nalu:Magnet.AlignTop="avatar" nalu:Magnet.HorizontalBias="0" />
  <Label  nalu:Magnet.MagnetId="subtitle"
          nalu:Magnet.AlignLeft="title" nalu:Magnet.Below="title,2" />
  <Button nalu:Magnet.MagnetId="cta"
          nalu:Magnet.FillWidth="parent,16" nalu:Magnet.Below="textsEnd" />
</nalu:Magnet>

Building the mental model is easiest by example: Magnet by example walks through ten screenshot-driven everyday layouts (login screen, list rows, toolbars, barriers, chains, ratios…).

The same in C# (fluent, targets can be ids, views or nodes):

var magnet = new Magnet();
Magnet.GetConstraints(avatar).Id("avatar").Size(48, 48).AlignLeft("parent", 16).AlignTop("parent", 16);
Magnet.GetConstraints(title).Id("title").After(avatar, 12, goneMargin: 0).Right("parent", margin: 16).AlignTop(avatar);
Magnet.GetConstraints(cta).Id("cta").FillWidth("parent", 16).Below("textsEnd");
magnet.Add(avatar);
magnet.Add(title);
magnet.Add(cta);

Concepts

Nodes and identifiers

Everything Magnet positions is a node with a mandatory, unique MagnetId:

Node Purpose
MagnetView the constraints of a child view (anchors, size, bias)
MagnetBarrier a line at the outermost Direction pole of a set of nodes
MagnetGuideline a line at a percent/absolute position of the stage
MagnetChain lays a group of views out along one axis (spread / spread-inside / packed, weights)

Nodes live in a MagnetDefinition. MagnetViews are usually created inline through the attached properties (Magnet.MagnetId, Magnet.LeftTo, …) or Magnet.GetConstraints(view); virtual nodes are declared in Magnet.Definition. A MagnetView declared in the definition is bound to the child whose Magnet.MagnetId matches (the child then carries only the id — a duplicate inline node is an error). If you don't assign a definition, the layout creates one.

MagnetId is the only identity: nothing falls back to AutomationId. By default the id is copied to AutomationId when the latter is not set (Magnet.PropagateMagnetIdToAutomationId="False" disables it) — handy for UI tests.

A MagnetDefinition is a pure declaration and can be shared across layouts — declaring one as an app-wide StaticResource and assigning it to many Magnets (or every cell of a DataTemplate) is fine and cheaper than inflating a copy per cell: all per-layout state (bound views, engine, transitions) lives in the Magnet. A change to a shared definition (a property, a scene apply) reaches every layout using it.

Anchors

LeftTo / RightTo / TopTo / BottomTo are MagnetAnchors: target.Pole[,margin[,gone:goneMargin]], e.g. parent.Left, avatar.Right,12, avatar.Right,12,gone:0. Horizontal sides can only reference Left/Right poles, vertical sides Top/Bottom. Barriers and guidelines have a single pole per axis (textsEnd.Bottom, mid.Left), chains cannot be targeted (anchor to their first/last member).

The relative shortcuts say the same thing with a verb; their value is a MagnetTarget: target[,margin[,goneMargin]] ("avatar", "avatar,12", "avatar,12,0", "avatar,12,gone:0"):

Shortcut Equivalent
After="a,12" / Before="a,12" LeftTo="a.Right,12" / RightTo="a.Left,12"
Below="a,12" / Above="a,12" TopTo="a.Bottom,12" / BottomTo="a.Top,12"
AlignLeft / AlignRight / AlignTop / AlignBottom="a" LeftTo="a.Left" / RightTo="a.Right" / TopTo="a.Top" / BottomTo="a.Bottom"
HorizontallyWithin / VerticallyWithin / Within="a" both anchors of the axis (axes) to a — the view is placed by the bias (0.5 = centered)
FillWidth / FillHeight="a,16" both anchors of the axis to a + WidthSizing/HeightSizing="*"

All the constraint attached properties are set-only commands (LeftTo…, WidthSizing/HeightSizing, the biases and the shortcuts): setting one writes into the child's MagnetView node (Magnet.GetConstraints(view), the only place to read constraints — the static getters are hidden and fail to compile). Several of them may write the same side (After and AlignLeft both write LeftTo): the last one set wins. Clearing one ({x:Null}, ClearValue) removes the constraint it wrote only if nobody overwrote it in the meantime. MagnetId is the exception: it is a real, readable attached property.

  • one anchor per axis: the view sticks to it;
  • two anchors: the view is placed inside the span using HorizontalBias / VerticalBias (0..1, default 0.5);
  • no anchor: the view sits at the stage origin.

GoneMargin is used instead of Margin when the target view is collapsed; a collapsed view drops its own margins.

Sizes

Magnet.WidthSizing / Magnet.HeightSizing (attached) and MagnetView.WidthSizing / HeightSizing are MagnetSizings. Three common cases have a string form; everything else uses the {nalu:MagnetSizing} markup extension (Value is the content):

XAML Unit Meaning
(unset) Measured the view's desired size (default)
"48" Fixed fixed dp
"*" Constraint fills the span between the two anchors (weighted share inside a chain)
"50%" ConstraintPercent a fraction of the span between the two anchors
{nalu:MagnetSizing 0.5, Unit=StagePercent} StagePercent a fraction of the stage size, regardless of the anchors (1 = as wide as the layout)
{nalu:MagnetSizing 1.5, Unit=Ratio} Ratio 1.5 × the other axis
{nalu:MagnetSizing 1.5, Unit=Measured} Measured (scaled) 1.5 × the view's desired size
{nalu:MagnetSizing Unit=Constraint, Max=320} any + bounds Min/Max clamp any unit; Max is also the measure constraint of a Measured view (a Label wraps at it)

In C#: MagnetSizing.Fixed(48), MagnetSizing.Constraint, MagnetSizing.Percent(0.5), MagnetSizing.StagePercent(0.5), MagnetSizing.Ratio(1.5), MagnetSizing.Scaled(1.5), .WithBounds(min, max), plus implicit conversions from double (fixed) and from the three string forms.

A Ratio height works with any width; a Ratio width fed by a height that depends on the vertical layout (*, percent — e.g. a thumbnail as tall as its row) triggers one bounded cross-axis feedback pass (ConstraintLayout semantics): the X pass uses the height from the previous execution, and X+Y are re-run once when the Y pass changed it. Layouts without such a node pay nothing for this.

Visibility (GONE)

The single source of truth is the view: IsVisible="False" collapses it — its size becomes 0, anchors to it use the gone margin, chains and barriers skip it. Toggling visibility never recompiles the layout.

A node can additionally declare a visibility action applied onto the view (see Scenes below): ApplyVisibility="Hide"/"Show" stamps IsVisible when the definition attaches, when the view binds, and when the value changes. It is a one-shot write, never read back — the view remains the runtime truth.

Chains

Chains are explicit nodes (unlike Android, which infers them from mutual anchors):

<nalu:MagnetChain MagnetId="row" Orientation="Horizontal" Style="Spread" Nodes="a,b,c" Weights="2,1,1" />
<!-- equivalent: <x:String> items as element content -->
<nalu:MagnetChain MagnetId="row" Orientation="Horizontal" Style="Spread">
  <x:String>a</x:String><x:String>b</x:String><x:String>c</x:String>
</nalu:MagnetChain>

The chain start is the first member's LeftTo (default parent.Left), the end is the last member's RightTo (default parent.Right). Inner members must not carry anchors on the chain axis, except anchors to the adjacent member (which only contribute their margin, e.g. b.LeftTo="a.Right,8") — so gaps can differ per pair, and each carries its own gone margin (b.LeftTo="a.Right,8,gone:2"). For the common uniform case, declare the gap ONCE on the chain instead: Gap="8" places it between consecutive visible members (separator semantics: a collapsed member takes its gap away, no gone margins involved — like Android's Flow flow_horizontalGap); it is animatable, and a per-pair adjacent anchor overrides it for that pair.

GapMode="Separators" extends the separator semantics to the per-pair anchors and the chain ends — the StackLayout padding+spacing mental model, per pair: the first member's start margin and the last member's end margin belong to the chain (they survive the head/tail collapsing, so the first visible member sits at the chain's leading margin whichever member it is), and a member's margin towards the previous one applies only when a visible member precedes it (gone margins are not involved). With the default GapMode="Anchors" the margins follow the ConstraintLayout rules above. "10 A 20 B 30 C" with A and B collapsed yields 30 C in Anchors mode and 10 C in Separators mode. Collapsed members follow the GONE rules: they drop their own margins (including the chain's start/end margin when the head/tail collapses), the anchor pointing at a collapsed member uses its gone margin, and spread gaps only count visible members. Packed uses the first member's bias (HorizontalBias="0.3" on the head puts the packed group at 30% of the free space). Members sized * share the remaining space according to Weights (positional, aligned with Nodes, default 1; collapsed members are excluded, their share goes back to the visible ones). Measured members are measured with the room left by the other members (in chain order): a packed [name, star] chain lets the name grow until it must ellipsize while the star stays glued to its right — the pattern that needs a FlexLayout elsewhere.

You do not need a chain for "one view fixed, the other centered in the rest": two anchors and a bias do that (b.LeftTo="a.Right" b.RightTo="parent.Right"). A chain is for a group distributed as a whole.

Hug vs fill

Measure returns the content extent (every view fits between the stage edges), clamped by the constraint; the assigned size is used at arrange time. A *-sized child contributes only its margins (and min) to the hug — put the Magnet in a filling slot when you want fill semantics.

Barriers and guidelines

<nalu:MagnetBarrier MagnetId="textsEnd" Direction="Right" Margin="8" Nodes="title,subtitle" />   <!-- or <x:String> items as content -->
<nalu:MagnetGuideline MagnetId="mid" Orientation="Vertical" Percent="0.5" Position="0" />

A guideline is placed at stageSize × Percent + Position (both animatable). Percent-based guidelines are taken into account exactly when hugging.

Fluent API (C#)

Magnet.GetConstraints(view) returns the view's MagnetView node (created on first access, registered when the view is added to the layout); every setter returns the node. Besides the primitives (Left/Right/Top/Bottom(target, pole, margin, goneMargin), Size, Bias, Id) the node offers the same relative shortcuts available in XAML:

Magnet.GetConstraints(avatar).Id("avatar").Size(48, 48).AlignLeft("parent", 16).AlignTop("parent", 16);
Magnet.GetConstraints(title).Id("title")
      .After(avatar, 12, goneMargin: 0)      // LeftTo = avatar.Right (target = view, node or id)
      .Right("parent", margin: 16)
      .AlignTop(avatar);
Magnet.GetConstraints(subtitle).Id("subtitle").AlignLeft(title).Below(title, 2);
Magnet.GetConstraints(cta).Id("cta").FillWidth("parent", 16).Below("textsEnd");   // both anchors + WidthSizing "*"
Magnet.GetConstraints(badge).Id("badge").Within(avatar);

magnet.Definition = new MagnetDefinition().Add(
    new MagnetBarrier { MagnetId = "textsEnd", Direction = MagnetPole.Bottom, Margin = 8 }.With(avatar, subtitle),
    new MagnetChain { MagnetId = "row", Style = MagnetChainStyle.Packed }.With("name", "star"),
    new MagnetGuideline { MagnetId = "mid", Percent = 0.5 });
Shortcut Equivalent
After(t) / Before(t) Left(t, Right) / Right(t, Left)
Below(t) / Above(t) Top(t, Bottom) / Bottom(t, Top)
AlignLeft/Right/Top/Bottom(t) same-side anchor
HorizontallyWithin/VerticallyWithin/Within(t) both anchors of the axis (axes) — centered by the bias
FillWidth/FillHeight(t) both anchors + WidthSizing/HeightSizing = "*"

Every target accepts an id string, a MagnetTarget ("avatar,12,gone:0"), a view carrying Magnet.MagnetId (or an inline node with Id(...)) or a node; MagnetChain.With(...) / MagnetBarrier.With(...) accept views too. Shortcuts and primitives write the same node property: the last one wins. MagnetSizing.Fixed/Constraint/Percent/ StagePercent/Ratio/Scaled and implicit conversions from double (fixed) and from the three string forms. The attached setters (Magnet.SetAfter(view, "avatar,12")) write into the same node.

Changing constraints at runtime

Every node property is bindable. Changes are classified: values (margins, biases, sizes, percents, weights) patch the compiled tape; structure (targets, poles, units, nodes added/removed) recompiles it. Both coalesce into a single InvalidateMeasure.

Compile errors (unknown target, axis mismatch, cycles, zero chain weights, …) surface as InvalidOperationException from the first measure after the offending change; every message names the MagnetIds and properties involved.

Transitions

await magnet.TransitionToAsync(() =>
{
    Magnet.GetConstraints(avatar).Left("parent", margin: 80).Top("parent", margin: 80);
    details.IsVisible = true;
}, length: 300, easing: Easing.CubicInOut);

TransitionToAsync(Action mutate) applies the mutation and animates from the current state to the new one:

  • value-only changes interpolate the constraint inputs, so intermediate frames obey the constraints exactly (animating a guideline Percent or a chain weight moves every dependent view correctly);
  • structural changes and visibility toggles interpolate frames; appearing views fade in (a view hidden with a manual IsVisible=false disappears immediately — the platform hides it before the animation can run; use ApplyVisibility="Hide" on the node to get the animated fade-out);
  • a new transition retargets from the current interpolated state (the previous task completes with false);
  • when the layout's own size changes the ancestors reflow every tick (inherently more expensive).

TransitionToAsync(MagnetDefinition end) swaps the whole definition, matching nodes by MagnetId.

Scenes (two definitions, animated)

A "scene" is a definition that declares both the geometry and the visibility of the views it manages, using ApplyVisibility on its nodes:

<nalu:MagnetDefinition x:Key="SceneA">
  <nalu:MagnetView MagnetId="badge" ApplyVisibility="Show" ... />
</nalu:MagnetDefinition>
<nalu:MagnetDefinition x:Key="SceneB">
  <nalu:MagnetView MagnetId="badge" ApplyVisibility="Hide" ... />
</nalu:MagnetDefinition>
await magnet.TransitionToAsync(sceneB); // the badge fades out while its siblings animate to the collapsed layout

Applying a scene inside a transition defers the visibility writes: a Hide on a visible view freezes it in place and fades it out (Opacity → 0), its siblings animate to the layout solved as if it were already collapsed, and IsVisible = false lands at the end (also when the transition is interrupted); a Show applies up front and fades in. Outside a transition (a plain Definition swap, or a late-bound child) the action applies immediately.

Swapping the whole definition is not required — toggling the node property inside the mutate gets the same animated treatment:

await magnet.TransitionToAsync(() => badgeNode.ApplyVisibility = MagnetVisibilityAction.Hide);

ApplyVisibility applies on change: re-assigning the value it already holds is a no-op (so after manually setting the view's IsVisible back, re-assert a scene action by passing through None first).

Rules to keep scenes predictable:

  • Scenes are total, there is no auto-revert: swapping back to a definition with no opinion (None, the default) leaves the view as the previous scene left it. Each scene declares ApplyVisibility for every view it manages.
  • Ownership: applying writes IsVisible with standard MAUI semantics, which permanently detaches any binding on that property. A view whose visibility is scene-managed must not also bind IsVisible — in MVVM, trigger the transition from the view model instead of binding the visibility.
  • Scene visibility works with definition-declared nodes (views bound by MagnetId); views carrying inline constraints cannot be scene-hidden (an id declared both inline and in the definition is an error).
  • ApplyVisibility does not affect the compiled layout: definitions differing only in visibility actions share the same compiled tape.

Performance

Benchmarks (Tests/Nalu.Maui.Benchmarks, Apple M-series). Two scenarios, both with the same leaf views for Grid and Magnet; 1000 × measure+arrange per row, inflation = 100 instances including compile and first layout.

Row: 5 views in one row, the middle one filling the remaining space (Grid Auto,Auto,*,Auto,Auto vs a horizontal MagnetChain with a * member).

Method Grid Magnet 2
Child invalidated every pass (e.g. text change) 0.72 ms / 1.45 MB 0.94 ms / 0.33 MB
Nothing changed (MAUI re-measures often) 0.68 ms / 1.43 MB 0.87 ms / 0.31 MB
Changing bounds (rotation) 0.96 ms / 1.70 MB 1.19 ms / 0.58 MB
Value patch (animated margin) + relayout – 1.42 ms / 0.58 MB
Inflation 4.5 ms / 7.8 MB 4.9 ms / 8.7 MB

Card (the sample-app credit card: image | name + star / detail | money): the Grid version needs three nested layouts (Grid + VerticalStackLayout + FlexLayout), the Magnet version is flat (a packed name+star chain).

Method Grid (3 layouts) Magnet 2 (flat)
Child invalidated every pass 0.53 ms / 1.07 MB 0.62 ms / 0.33 MB
Nothing changed 0.48 ms / 1.05 MB 0.57 ms / 0.31 MB
Inflation 3.2 ms / 4.8 MB 2.6 ms / 4.7 MB

These benchmarks measure only the managed layout algorithm (no handlers, no platform views). They ignore what nested layouts cost in a real app: every extra layout is an extra native view (UIView / ViewGroup / Panel) to create and map, one more native→managed round trip (JNI/Objective-C) per measure and arrange pass, one more level for native traversals (window insets, hit-testing, accessibility), one more layer to render and more native memory. A flat Magnet replaces that whole nesting with a single native view: the ~0.2 µs of algorithmic overhead per relayout is noise next to a single native measure call, and the saved hierarchy is what actually shows up in list scrolling.

On device (TestApp "Magnet Perf" page)

The TestApp ships a manual benchmark page (Magnet Perf) that inflates N cards of both flavours and hooks SizeChanged on every element of every card: "settled" is the last size change of the whole subtree, i.e. the real end of the native layout pass. Debug builds (JIT, no AOT), 200 cards, warm second run — absolute numbers are inflated by the Debug configuration, the ratio is what matters:

200 cards, warm run iPhone simulator Android emulator (arm64)
Inflate: create + Add (handlers + native views) → settled Grid 1600 ms · Magnet 1352 ms (−16%) Grid 1999 ms · Magnet 1219 ms (−39%; layout phase after Add: 321 vs 166 ms)
Text change on every card → settled Grid 253 ms · Magnet 185 ms (−27%) Grid 390 ms · Magnet 102 ms (−74%)
SizeChanged events per inflate Grid 1800 · Magnet 1267 Grid 1800 · Magnet 1267

The numbers are collected by the opt-in MagnetPerfStatsTests DevFlow test (set MAGNET_PERF_OUT to a file path), which drives the page: a discarded cold inflate to warm the JIT, then the recorded warm inflate and text change per flavour.

On device the flat Magnet wins on every scenario, by the cost of the two native views per card that the nested Grid needs. Magnet VirtualScroll Perf (2000 cards in a VirtualScroll, definition declared inline in the template) shows the recycled-cell case: a handful of inflations, one compilation shared by every cell.

Takeaways (managed only): a full relayout costs 1.2–1.3× a Grid with 3–4.7× fewer allocations (the generic tape interpreter does more work than three trivial specialized layouts, see the on-device numbers for the other side of the coin); an arrange that follows a measure with matching bounds re-uses the child measures — and when only the stage size differs from the measured one, the solver replays the measure's affine solution at the arrange size instead of re-executing the tape (delta arrange); an arrange without a measure in between (recycled cells) re-measures; the remaining allocations come from MAUI's own Measure/Arrange plumbing (the engine allocates only when compiling). Compiled tapes are pure and shared through a process-wide LRU cache keyed by the structure of the definition, so template-instantiated cells compile once; the flat Magnet card even inflates faster than its three nested layouts, and sharing one MagnetDefinition across cells (see above) trims per-cell inflation further. The cache holds 64 distinct structures by default (a safety net, not a tuning knob: an evicted structure is transparently recompiled in a few tens of microseconds) and is configurable via Magnet.CompilationCacheCapacity.

When to use Magnet

Good use cases: complex layouts that would need nested Grid/StackLayouts, elements positioned relative to several others, responsive layouts adapting to content size, layout-driven animations.

Consider alternatives for trivial layouts a Grid or StackLayout expresses directly.