composition¶
you have learned how state lives on a GooPanel, how Build() returns a tree, and how helper functions extract reusable blobs. this article ties those ideas together into the load-bearing pattern goo was designed around: a single stateful root and a set of stateless presenters.
the core idea¶
every field that drives the UI lives on the root GooPanel. child components are stateless. a presenter is just a static method, a plain class, or a sealed class that takes data in and returns a blob subtree. it holds no fields of its own that affect layout, no Rebuild() reference, and no reference to the panel. you pass data and handlers down into it. it builds a subtree from what it receives and returns.
this is not a rule you have to follow. it is the shape that works best when the UI grows.
why all state lives on the root¶
when state is scattered across many objects, two kinds of trouble appear. first: a handler in object A needs to trigger a rebuild that reflects state in object B. you end up threading Rebuild() references everywhere. second: a rebuild in A produces a subtree that references state in B, and if B has changed between builds, the tree is inconsistent.
neither problem appears when state is on one root. the handler mutates a field on the panel, calls Rebuild(), and Build() reads every field fresh from one place. there is no threading and no stale reference. the whole tree is a pure function of the panel's fields at build time.
the presenter shape¶
a presenter takes a struct of data and returns a blob. the struct name is domain-specific, not a generic "props" convention. here is the pattern as the hotbar demo uses it:
the root panel holds the selection index and the spring arrays:
sealed class HotbarView
{
int _selected;
readonly SpringFloat[] _pop = new SpringFloat[N];
public Container Build()
{
var bar = new Container { Key = "hotbar", FlexDirection = FlexDirection.Row, Gap = SlotGap };
for ( int i = 0; i < N; i++ )
bar.Children.Add( Slot( i ) );
return bar;
}
Container Slot( int i )
{
bool selected = i == _selected;
float pop = (selected ? SelectedScale : 1f) + _pop[i].Current * PopScale;
var slot = new Container
{
Key = $"slot-{i}",
Width = SlotSize,
Height = SlotSize,
BackgroundColor = selected ? SelectedBg : SlotBg,
BorderRadius = 6f,
JustifyContent = Justify.Center,
AlignItems = Align.Center,
Transform = Goo.PanelTransform.Scale( pop ),
PointerEvents = PointerEvents.None,
};
slot.Children.Add( new Goo.SvgPanel
{
Key = "icon", Path = Items[i].Icon, Color = IconTint,
Width = IconSize, Height = IconSize,
} );
return slot;
}
}
Slot takes an index, reads from the view's own fields, and returns a subtree. it is not static here because it reads instance fields, but it takes no callbacks and holds no state of its own. the point is the same: data flows in, blob flows out.
the demo this comes from is Code/Demos/ComposableHud/HotbarView.cs.
the chip presenter: a fully static example¶
EntryChip in the keystroke visualizer is the cleanest example of the fully static form. it takes a key, a label, timing floats, and an animation config struct, and returns a Container. there is no instance state at all:
public static class EntryChip
{
public static Container Build( int key, string label, float age, float idleAge, ChipAnim a )
{
var p = a.Phase.Project( age, idleAge );
float yOff = (a.SlideFromBelow ? 1f : -1f) * a.ChipHeight * a.SlideRiseFraction * (1f - p.Slide);
float opacity = p.Opacity * a.BaseOpacity;
return new Container
{
Key = key.ToString(),
Height = a.ChipHeight,
PaddingLeft = 14,
PaddingRight = 14,
PaddingTop = 6,
PaddingBottom = 6,
BackgroundColor = Ink2,
BorderRadius = 6f,
Opacity = opacity,
Transform = Goo.PanelTransform.Translate( 0, yOff ),
JustifyContent = Justify.Center,
AlignItems = Align.Center,
Children = { new Text( label ) { FontSize = a.FontSize, FontColor = FgPrimary } },
};
}
}
ChipAnim is a readonly record struct. it bundles the animation config the caller passes down. the presenter does not know how the caller stores those values or when they change. it only computes a blob from what it receives.
the calling side constructs the config once, then calls EntryChip.Build for each entry in the queue:
var anim = new ChipAnim( new AgePhase( SlideInDuration, HoldTime, FadeOutDuration ),
ChipHeight, SlideRiseFraction, FontSize, ChipOpacity, r.StacksUp );
column.Children.AddRange( _queue, ( i, e ) =>
EntryChip.Build( i, e.Label, _now - e.SpawnTime, _now - e.LastInputTime, anim ) );
this is from Code/Demos/KeystrokeVisualizerUI.cs.
the config struct¶
ChipAnim is a readonly record struct. that is the idiom for presenter inputs that carry several related values. put display-driving data in it. put handlers in it too, if the presenter fires events back up:
public readonly record struct ChipAnim(
AgePhase Phase,
float ChipHeight,
float SlideRiseFraction,
float FontSize,
float BaseOpacity,
bool SlideFromBelow );
the name is domain-specific, not generic. name it after what it describes. ChipAnim describes the animation config for a chip. SquadRow would describe the data for one squad-bar row. pick a name that makes the call site read like prose.
passing handlers down¶
a presenter that fires user actions passes the callback in its config struct. the root constructs the lambda that closes over the panel's Rebuild():
public readonly record struct RowData( string Name, int Hp, int MaxHp, Action OnKick );
static Container Row( RowData d ) => new Container
{
FlexDirection = FlexDirection.Row,
AlignItems = Align.Center,
Gap = 8f,
Children =
{
new Text( d.Name ),
new Text( $"{d.Hp}/{d.MaxHp}" ),
new Container
{
Padding = 8,
OnClick = _ => d.OnKick(),
Children = { new Text( "kick" ) },
},
},
};
the lambda that wires OnKick to a field mutation plus Rebuild() lives on the root panel, not inside the presenter. the presenter never calls Rebuild() directly. note the struct is passed by value: an in parameter would not compile here, because the OnClick lambda captures d.
when to extract a presenter vs inline¶
a flat inline helper is fine for one or two call sites. extract a presenter (its own static class or sealed class) when:
- the same subtree shape appears in three or more places, or
- the config struct has more than two or three values and naming it clarifies the call site, or
- the subtree has enough internal logic (animation sampling, conditional children) that it clutters
Build().
the chip and slot examples both cleared the third bar. inline helpers are the right call for one-off, low-complexity subtrees:
Container Button( string label, Color tint, Action onClick ) => new Container
{
Key = label,
Padding = Space3,
BackgroundColor = BgCard,
HoverBackgroundColor = BgCardHi,
BorderRadius = Radius1,
OnClick = _ => onClick(),
Children = { new Text( label ) { FontSize = FontBodySm, FontColor = tint } },
};
here Button is a private instance method on the panel, not a static class, because the subtree is small enough that it does not need the separation. both forms are legitimate. the distinction is scale, not ceremony.
the view object pattern¶
for a subtree that also owns animation state or ticks input, the presenter grows into a view object: a sealed class with fields, a Tick() method, and a Build() method. the root panel holds an instance and drives it:
public sealed class KeystrokeView
{
public float HoldTime = 1.5f;
public float SlideInDuration = 0.1f;
// ... other config fields ...
public bool Tick( Scene? scene, float dt )
{
// poll input, advance timers, return true when a rebuild is needed
...
}
public Container Build()
{
// returns the chip column
...
}
}
the root panel holds an instance of the view object, pushes config in each OnUpdate, and calls Tick(). if Tick returns true, the root calls Rebuild(). the root's Build() calls the view's Build() and plants the result into the tree.
you can see this pattern in Code/Demos/KeystrokeVisualizer/KeystrokeView.cs.
the view object holds its own fields, but those fields never include a reference to the panel or a Rebuild() delegate. it signals "i changed" by returning true from Tick, and the root decides whether to rebuild.
what this handles well¶
the composability pattern ran seven HUD elements through this shape. zero changes to the reconciler or fiber were needed. each element was a view object, and the host panel composed them with no edits to the original views.
the pattern works because HUD-class UI is a display projection of game state. animation springs flatten to arrays on one view. nested stateful sub-elements flatten to parallel arrays with per-index indexing. there is no tree-shaped state: it is all flat.
the pattern reaches a limit when sub-elements need UI-local state that does not exist anywhere in the model, at several levels of a tree whose shape changes at runtime. expand-collapse nodes, in-flight drag buffers, per-item focus rings in a deep nested inventory: those cases can require hoisting so much state to the root that Build() becomes a hand-maintained shadow tree. that limit is real but separate from the composition pattern this article teaches. when you hit it, cells are the tool: self-owning stateful units nested inside the tree.
recap¶
- all state lives on the root
GooPanel. no state on presenters. - a presenter takes a config struct (named after the domain, not "Props") and returns a blob.
- handlers are passed down as
Actionfields in the config struct. the lambda that closes overRebuild()is always on the root. - inline helpers are fine at small scale. extract a static class or view object when the subtree has its own logic or appears in many places.
- a view object adds a
Tick()method and returnstruewhen the root needs to rebuild. the root holds the instance and drives the clock.
see also¶
- build method -
Build(),Rebuild(), and the extract-helper pattern this article builds on - arranging children - flex layout and the
Childrencollection, used in every presenter - events - wiring
OnClickand other handlers, including how to pass them through a config struct - dynamic children -
AddRangeand keyed child lists, used when building columns from queues - cells - self-owning stateful units, for the cases that outgrow root-held state