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.
A plain C# object. No base class, no attributes:
public class HeroDetailArgs
{
public string HeroId;
public string DisplayName;
public int Level;
}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.
LunaNavigation.Push("hero.detail", new HeroDetailArgs
{
HeroId = hero.Id,
DisplayName = hero.Name,
Level = hero.Level,
});The view receives the object as PushArgs. Read it with GetArgs<T>() in a Fade.OnFadeInStart handler, not in OnUILoaded:
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.-= before += makes the wiring safe when OnUILoaded re-runs after a UXML live reload.When the caller needs an answer back (a confirm, a picker), push with PushAsync<T> and deliver the result with Pop<T>:
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.
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.
LunaShowcase/Scripts/Screens/Details/HeroDetailScreen.cs receives HeroDetailArgs pushed from the hero grid, plus MailDetailModalArgs and LevelUpModalArgs for the same pattern in modal form.Scripts/UI/PauseMenuViewExample.cs and MainMenuViewExample.cs both await PushAsync<int> confirms with ChoicePopupArgs.Settings
Theme
Light
Contrast
Material
Dark
Dim
Material Dark
System
Sidebar(Light & Contrast only)
Font Family
DM Sans
Wix
Inclusive Sans
AR One Sans
Direction