Typed Destinations

One destination, many contents. A hero detail screen should be one prefab, one UXML, one node, receiving fresh typed data on every push: not one screen class per hero. This page is the production pattern for that, end to end.

The contract in one sentence: build a fresh args object, push it atomically, and consume it on every fade-in.

1. Define the args type

A plain C# object. No base class, no attributes:

csharp
public class HeroDetailArgs { public string HeroId; public string DisplayName; public int Level; }

2. Push with fresh args, atomically

Build the object and push it in the same call. Never stash it somewhere for the destination to "pick up later": that path breaks on the first visit whenever timing shifts.

csharp
LunaNavigation.Push("hero.detail", new HeroDetailArgs { HeroId = hero.Id, DisplayName = hero.Name, Level = hero.Level, });

3. Consume on every fade-in

The view receives the object as PushArgs. Read it with GetArgs<T>() in a Fade.OnFadeInStart handler, not in OnUILoaded:

csharp
public class HeroDetailScreen : UIViewComponent { private Label _name; private Label _level; protected override void OnUILoaded(VisualElement root) { _name = root.Q<Label>("hero-name"); _level = root.Q<Label>("hero-level"); Fade.OnFadeInStart -= Refresh; Fade.OnFadeInStart += Refresh; } protected override void OnViewRootChanged(VisualElement root) => OnUILoaded(root); private void Refresh() { var args = GetArgs<HeroDetailArgs>(); if (args == null) { Debug.LogError($"[HeroDetailScreen] pushed without HeroDetailArgs.", this); return; } _name.text = args.DisplayName; _level.text = $"Lv. {args.Level}"; } }

Why this split matters:

  • OnUILoaded fires once, when the panel tree first resolves. If you bind data there, the first push works and every later push shows stale content. This is the classic "data only updates on the second visit" bug, inverted.
  • Fade.OnFadeInStart fires on every fade-in, including the first. Navigation sets PushArgs before the fade starts, so the handler always sees the current visit's object.
  • GetArgs<T>() returns null when args were not set or the type does not match. Fail loudly on null, as above. A silent blank screen costs an hour; a red error costs a second.
  • The -= before += makes the wiring safe when OnUILoaded re-runs after a UXML live reload.

4. The awaitable variant

When the caller needs an answer back (a confirm, a picker), push with PushAsync<T> and deliver the result with Pop<T>:

csharp
var result = await LunaNavigation.PushAsync<int>("confirm.abandon", new ChoicePopupArgs { Title = "Abandon run?", Choices = new[] { "Abandon", "Keep playing" }, }); if (!result.IsDismissed && result.Value == 0) AbandonRun();

Inside the modal, resolve with LunaNavigation.Pop(chosenIndex). Every other close path (Esc, backdrop click, a cascade) resolves the awaiter with IsDismissed = true, so dismissal is a first-class outcome, never a hang.

5. Reset on reopen, when you want it

If the screen should open fresh every time (a compose form, a confirm), tick Reset State On Reopen on the node and override OnStateReset(args). It fires before each reopen push, from the second push on; the first-ever push does not fire it. It complements the fade-in refresh, it does not replace it.

Working examples in the samples

  • Showcase: LunaShowcase/Scripts/Screens/Details/HeroDetailScreen.cs receives HeroDetailArgs pushed from the hero grid, plus MailDetailModalArgs and LevelUpModalArgs for the same pattern in modal form.
  • GameFull: Scripts/UI/PauseMenuViewExample.cs and MainMenuViewExample.cs both await PushAsync<int> confirms with ChoicePopupArgs.

See also

Settings

Theme

Light

Contrast

Material

Dark

Dim

Material Dark

System

Sidebar(Light & Contrast only)

Light
Dark

Font Family

DM Sans

Wix

Inclusive Sans

AR One Sans

Direction

LTR
RTL