Skip to content

Factory → Builder → View → Host → Controller

Pattern: Code-generated UITK panels for the Training App
Examples: Login, Settings (with tabs), Module instructions
Note: Code samples are demonstrative — not production-complete


Layer Overview

UIFactory          creates individual elements with correct USS classes
PanelBuilder       assembles elements into layouts, returns cached refs
*PanelView         pure C#, calls builder, wires internal events, exposes them upward  
*PanelHost         MonoBehaviour, panel lifecycle, Unity API calls
AppController      MonoBehaviour, scene orchestration, navigation

Each layer has one job. The View never touches Unity APIs. The Host never touches raw VisualElement creation. The Controller never queries the UI directly.


UIStyles — String Constants

Every USS class name lives here. Never use raw strings anywhere else in the codebase. If a class name changes in USS, there is one place to update it.

public static class UIStyles
{
    // Layout
    public const string PanelRoot        = "panel-root";
    public const string PanelHeader      = "panel-header";
    public const string PanelBody        = "panel-body";
    public const string PanelFooter      = "panel-footer";
    public const string Row              = "row";
    public const string Column           = "column";
    public const string Spacer           = "spacer";

    // Typography
    public const string TitleLarge       = "title-large";
    public const string TitleMedium      = "title-medium";
    public const string BodyText         = "body-text";
    public const string LabelText        = "label-text";
    public const string CaptionText      = "caption-text";

    // Buttons
    public const string BtnPrimary       = "btn-primary";
    public const string BtnSecondary     = "btn-secondary";
    public const string BtnGhost         = "btn-ghost";
    public const string BtnTab           = "btn-tab";
    public const string BtnTabActive     = "btn-tab--active";
    public const string BtnIcon          = "btn-icon";
    public const string BtnDanger        = "btn-danger";

    // Inputs
    public const string InputGroup       = "input-group";
    public const string InputLabel       = "input-label";
    public const string InputField       = "input-field";
    public const string InputError       = "input-field--error";

    // Components
    public const string Card             = "card";
    public const string CardActive       = "card--active";
    public const string CardLocked       = "card--locked";
    public const string GlassPanel       = "glass-panel";
    public const string Divider          = "divider";
    public const string TabBar           = "tab-bar";
    public const string TabContent       = "tab-content";
    public const string ProgressTrack    = "progress-track";
    public const string ProgressFill     = "progress-fill";
    public const string ProgressLabel    = "progress-label";
    public const string StepItem         = "step-item";
    public const string StepItemComplete = "step-item--complete";
    public const string StepItemActive   = "step-item--active";

    // State
    public const string Hidden           = "hidden";
    public const string Disabled         = "disabled";
    public const string FadeIn           = "fade-in";
    public const string Loading          = "loading";
}

UIFactory

Creates individual elements. No layout assembly here — just correctly classed elements ready to be placed by a builder.

Tuple returns are used throughout so callers get both the wrapper group and the interactive element without needing to query for either.

public static class UIFactory
{
    // ── Containers ──────────────────────────────────────────────────────────

    public static VisualElement CreateContainer(params string[] classes)
    {
        var el = new VisualElement();
        foreach (var c in classes) el.AddToClassList(c);
        return el;
    }

    public static VisualElement CreateRow(params string[] extraClasses)
    {
        var el = new VisualElement();
        el.AddToClassList(UIStyles.Row);
        foreach (var c in extraClasses) el.AddToClassList(c);
        return el;
    }

    public static VisualElement CreateColumn(params string[] extraClasses)
    {
        var el = new VisualElement();
        el.AddToClassList(UIStyles.Column);
        foreach (var c in extraClasses) el.AddToClassList(c);
        return el;
    }

    public static VisualElement CreateDivider()
    {
        var el = new VisualElement();
        el.AddToClassList(UIStyles.Divider);
        return el;
    }

    public static VisualElement CreateGlassPanel()
    {
        var el = new VisualElement();
        el.AddToClassList(UIStyles.GlassPanel);
        return el;
    }

    // ── Text ────────────────────────────────────────────────────────────────

    public static Label CreateLabel(string text, string styleClass = UIStyles.BodyText)
    {
        var label = new Label(text);
        label.AddToClassList(styleClass);
        return label;
    }

    public static Label CreateTitle(string text, bool large = true)
        => CreateLabel(text, large ? UIStyles.TitleLarge : UIStyles.TitleMedium);

    // ── Buttons ─────────────────────────────────────────────────────────────

    public static Button CreateButton(string text, string styleClass = UIStyles.BtnPrimary)
    {
        var btn = new Button { text = text };
        btn.AddToClassList(styleClass);
        return btn;
    }

    public static Button CreateTabButton(string text)
    {
        var btn = new Button { text = text };
        btn.AddToClassList(UIStyles.BtnTab);
        return btn;
    }

    public static Button CreateIconButton(Sprite icon, string tooltip = "")
    {
        var btn = new Button();
        btn.AddToClassList(UIStyles.BtnIcon);
        btn.tooltip = tooltip;
        if (icon != null)
            btn.style.backgroundImage = new StyleBackground(icon);
        return btn;
    }

    // ── Inputs ──────────────────────────────────────────────────────────────

    public static (VisualElement group, TextField field) CreateTextField(
        string labelText, string placeholder = "")
    {
        var group = new VisualElement();
        group.AddToClassList(UIStyles.InputGroup);

        var label = CreateLabel(labelText, UIStyles.InputLabel);
        var field = new TextField { value = placeholder };
        field.AddToClassList(UIStyles.InputField);

        group.Add(label);
        group.Add(field);

        return (group, field);
    }

    public static (VisualElement group, TextField field) CreatePasswordField(string labelText)
    {
        var (group, field) = CreateTextField(labelText);
        field.isPasswordField = true;
        return (group, field);
    }

    public static (VisualElement group, Slider slider, Label valueLabel) CreateSlider(
        string labelText, float min, float max, float initial)
    {
        var group = new VisualElement();
        group.AddToClassList(UIStyles.InputGroup);

        var headerRow = CreateRow();
        headerRow.Add(CreateLabel(labelText, UIStyles.InputLabel));

        var valueLabel = CreateLabel(initial.ToString("0%"), UIStyles.CaptionText);
        headerRow.Add(valueLabel);

        var slider = new Slider(min, max) { value = initial };
        slider.AddToClassList(UIStyles.InputField);

        group.Add(headerRow);
        group.Add(slider);

        return (group, slider, valueLabel);
    }

    public static (VisualElement group, Toggle toggle) CreateToggle(
        string labelText, bool initial = false)
    {
        var group = new VisualElement();
        group.AddToClassList(UIStyles.InputGroup);

        var toggle = new Toggle(labelText) { value = initial };
        group.Add(toggle);

        return (group, toggle);
    }

    public static (VisualElement group, DropdownField dropdown) CreateDropdown(
        string labelText, List<string> choices, int defaultIndex = 0)
    {
        var group = new VisualElement();
        group.AddToClassList(UIStyles.InputGroup);

        group.Add(CreateLabel(labelText, UIStyles.InputLabel));

        var dropdown = new DropdownField(choices, defaultIndex);
        dropdown.AddToClassList(UIStyles.InputField);
        group.Add(dropdown);

        return (group, dropdown);
    }

    // ── Composite ───────────────────────────────────────────────────────────

    public static (VisualElement track, VisualElement fill, Label label) CreateProgressBar(
        string labelText = "")
    {
        var track = new VisualElement();
        track.AddToClassList(UIStyles.ProgressTrack);

        var fill = new VisualElement();
        fill.AddToClassList(UIStyles.ProgressFill);
        track.Add(fill);

        var label = CreateLabel(labelText, UIStyles.ProgressLabel);

        return (track, fill, label);
    }

    public static (VisualElement card, Label title, Label body) CreateCard(
        string title, string body)
    {
        var card = new VisualElement();
        card.AddToClassList(UIStyles.Card);

        var titleLabel = CreateLabel(title, UIStyles.TitleMedium);
        var bodyLabel  = CreateLabel(body, UIStyles.BodyText);

        card.Add(titleLabel);
        card.Add(bodyLabel);

        return (card, titleLabel, bodyLabel);
    }
}

PanelBuilder

The base builder handles the panel skeleton. Typed subclasses add panel-specific elements and return typed element results — a simple record of every element the View will need at runtime.

The View holds this result and never runs Q<>() after build time. If a builder's internals change, the compiler tells you what broke in the View.

Base Builder

public class PanelBuilder
{
    protected readonly VisualElement _root;
    protected VisualElement _body;
    protected VisualElement _footer;

    public PanelBuilder(VisualElement root)
    {
        _root = root;
        _root.AddToClassList(UIStyles.PanelRoot);
    }

    public PanelBuilder AddHeader(string title)
    {
        var header = new VisualElement();
        header.AddToClassList(UIStyles.PanelHeader);
        header.Add(UIFactory.CreateTitle(title));
        _root.Add(header);
        return this;
    }

    // Variant for panels that need a back button in the header
    public PanelBuilder AddHeaderWithBack(string title, out Button backButton)
    {
        var header = new VisualElement();
        header.AddToClassList(UIStyles.PanelHeader);

        var row = UIFactory.CreateRow();
        backButton = UIFactory.CreateButton("←", UIStyles.BtnGhost);
        row.Add(backButton);
        row.Add(UIFactory.CreateTitle(title, large: false));
        header.Add(row);
        _root.Add(header);
        return this;
    }

    // Variant for headers that need a right-side action button
    public PanelBuilder AddHeaderWithAction(string title, out Label titleLabel,
        out Button actionButton, string actionText = "⚙")
    {
        var header = new VisualElement();
        header.AddToClassList(UIStyles.PanelHeader);

        var row = UIFactory.CreateRow();
        titleLabel   = UIFactory.CreateTitle(title, large: false);
        actionButton = UIFactory.CreateButton(actionText, UIStyles.BtnGhost);

        row.Add(titleLabel);
        row.Add(actionButton);
        header.Add(row);
        _root.Add(header);
        return this;
    }

    protected VisualElement EnsureBody()
    {
        if (_body != null) return _body;
        _body = new VisualElement();
        _body.AddToClassList(UIStyles.PanelBody);
        _root.Add(_body);
        return _body;
    }

    protected VisualElement EnsureFooter()
    {
        if (_footer != null) return _footer;
        _footer = new VisualElement();
        _footer.AddToClassList(UIStyles.PanelFooter);
        _root.Add(_footer);
        return _footer;
    }
}

Typed Element Results

Each builder returns one of these. The View holds the result and accesses elements by name — no string queries after build time.

public class LoginElements
{
    public TextField     UsernameField { get; init; }
    public TextField     PasswordField { get; init; }
    public Button        LoginButton   { get; init; }
    public Label         ErrorLabel    { get; init; }
}

public class DashboardElements
{
    public Label         WelcomeLabel   { get; init; }
    public VisualElement ProgressFill   { get; init; }
    public Label         ProgressLabel  { get; init; }
    public VisualElement ModuleList     { get; init; }
    public Button        SettingsButton { get; init; }
}

public class SettingsElements
{
    public Button        VisualTab          { get; init; }
    public Button        AudioTab           { get; init; }
    public VisualElement VisualContent      { get; init; }
    public VisualElement AudioContent       { get; init; }
    public DropdownField ThemeDropdown      { get; init; }
    public Toggle        HighContrastToggle { get; init; }
    public Slider        MasterSlider       { get; init; }
    public Label         MasterValueLabel   { get; init; }
    public Slider        MusicSlider        { get; init; }
    public Label         MusicValueLabel    { get; init; }
    public Slider        SFXSlider          { get; init; }
    public Label         SFXValueLabel      { get; init; }
    public Button        BackButton         { get; init; }
}

public class ModuleElements
{
    public Label         ModuleTitleLabel  { get; init; }
    public Label         InstructionsLabel { get; init; }
    public VisualElement MediaContainer    { get; init; }
    public VisualElement StepList         { get; init; }
    public Button        PreviousButton   { get; init; }
    public Button        NextButton       { get; init; }
    public Label         StepCountLabel   { get; init; }
}

Login Builder

public class LoginBuilder : PanelBuilder
{
    public LoginBuilder(VisualElement root) : base(root) { }

    public LoginElements Build()
    {
        AddHeader("Welcome Back");
        var body = EnsureBody();

        var (userGroup, usernameField) = UIFactory.CreateTextField("Username");
        var (passGroup, passwordField) = UIFactory.CreatePasswordField("Password");

        // Error label starts hidden — shown by the View on validation or auth failure
        var errorLabel = UIFactory.CreateLabel("", UIStyles.CaptionText);
        errorLabel.AddToClassList(UIStyles.Hidden);

        body.Add(userGroup);
        body.Add(passGroup);
        body.Add(errorLabel);

        var footer      = EnsureFooter();
        var loginButton = UIFactory.CreateButton("Sign In");
        footer.Add(loginButton);

        return new LoginElements
        {
            UsernameField = usernameField,
            PasswordField = passwordField,
            LoginButton   = loginButton,
            ErrorLabel    = errorLabel
        };
    }
}

Dashboard Builder

public class DashboardBuilder : PanelBuilder
{
    public DashboardBuilder(VisualElement root) : base(root) { }

    public DashboardElements Build()
    {
        // Header with welcome text and settings button in the corner
        AddHeaderWithAction("Hello", out var welcomeLabel, out var settingsButton);

        var body = EnsureBody();

        // Overall progress section
        var (progressTrack, progressFill, progressLabel) =
            UIFactory.CreateProgressBar("Overall Progress");

        body.Add(progressLabel);
        body.Add(progressTrack);
        body.Add(UIFactory.CreateDivider());

        // Module list — View calls PopulateModules() to fill this at runtime
        body.Add(UIFactory.CreateLabel("Modules", UIStyles.TitleMedium));
        var moduleList = UIFactory.CreateColumn();
        body.Add(moduleList);

        return new DashboardElements
        {
            WelcomeLabel   = welcomeLabel,
            ProgressFill   = progressFill,
            ProgressLabel  = progressLabel,
            ModuleList     = moduleList,
            SettingsButton = settingsButton
        };
    }
}

Settings Builder — Tab Pattern

public class SettingsBuilder : PanelBuilder
{
    private static readonly List<string> ThemeChoices =
        new() { "Default", "Dark", "Light", "High Contrast" };

    public SettingsBuilder(VisualElement root) : base(root) { }

    public SettingsElements Build()
    {
        AddHeaderWithBack("Settings", out var backButton);
        var body = EnsureBody();

        // ── Tab bar ─────────────────────────────────────────────────────────
        var tabBar   = UIFactory.CreateRow(UIStyles.TabBar);
        var visualTab = UIFactory.CreateTabButton("Visual");
        var audioTab  = UIFactory.CreateTabButton("Audio");
        visualTab.AddToClassList(UIStyles.BtnTabActive); // default active tab
        tabBar.Add(visualTab);
        tabBar.Add(audioTab);
        body.Add(tabBar);

        // ── Visual tab content ───────────────────────────────────────────────
        var visualContent = UIFactory.CreateColumn(UIStyles.TabContent);

        var (themeGroup, themeDropdown)     = UIFactory.CreateDropdown("Theme", ThemeChoices);
        var (contrastGroup, contrastToggle) = UIFactory.CreateToggle("High Contrast Mode");

        visualContent.Add(themeGroup);
        visualContent.Add(contrastGroup);
        body.Add(visualContent);

        // ── Audio tab content (hidden by default) ────────────────────────────
        var audioContent = UIFactory.CreateColumn(UIStyles.TabContent);
        audioContent.AddToClassList(UIStyles.Hidden);

        var (masterGroup, masterSlider, masterLabel) =
            UIFactory.CreateSlider("Master Volume", 0f, 1f, 0.8f);
        var (musicGroup, musicSlider, musicLabel) =
            UIFactory.CreateSlider("Music Volume", 0f, 1f, 0.5f);
        var (sfxGroup, sfxSlider, sfxLabel) =
            UIFactory.CreateSlider("SFX Volume", 0f, 1f, 0.7f);

        audioContent.Add(masterGroup);
        audioContent.Add(UIFactory.CreateDivider());
        audioContent.Add(musicGroup);
        audioContent.Add(sfxGroup);
        body.Add(audioContent);

        return new SettingsElements
        {
            VisualTab          = visualTab,
            AudioTab           = audioTab,
            VisualContent      = visualContent,
            AudioContent       = audioContent,
            ThemeDropdown      = themeDropdown,
            HighContrastToggle = contrastToggle,
            MasterSlider       = masterSlider,
            MasterValueLabel   = masterLabel,
            MusicSlider        = musicSlider,
            MusicValueLabel    = musicLabel,
            SFXSlider          = sfxSlider,
            SFXValueLabel      = sfxLabel,
            BackButton         = backButton
        };
    }
}

Module Builder

public class ModuleBuilder : PanelBuilder
{
    public ModuleBuilder(VisualElement root) : base(root) { }

    public ModuleElements Build()
    {
        var body = EnsureBody();

        // Title and instructions at the top
        var moduleTitleLabel  = UIFactory.CreateTitle("", large: false);
        var instructionsLabel = UIFactory.CreateLabel("", UIStyles.BodyText);

        body.Add(moduleTitleLabel);
        body.Add(instructionsLabel);
        body.Add(UIFactory.CreateDivider());

        // Media placeholder — hidden until the Host sets content
        var mediaContainer = UIFactory.CreateContainer(UIStyles.Card);
        mediaContainer.AddToClassList(UIStyles.Hidden);
        body.Add(mediaContainer);

        // Step checklist — rebuilt by View on each step change
        var stepList = UIFactory.CreateColumn();
        body.Add(stepList);

        // Footer: previous / step counter / next
        var footer    = EnsureFooter();
        var footerRow = UIFactory.CreateRow();

        var prevButton     = UIFactory.CreateButton("← Previous", UIStyles.BtnSecondary);
        var stepCountLabel = UIFactory.CreateLabel("Step 1 of 1", UIStyles.CaptionText);
        var nextButton     = UIFactory.CreateButton("Next →", UIStyles.BtnPrimary);

        footerRow.Add(prevButton);
        footerRow.Add(stepCountLabel);
        footerRow.Add(nextButton);
        footer.Add(footerRow);

        return new ModuleElements
        {
            ModuleTitleLabel  = moduleTitleLabel,
            InstructionsLabel = instructionsLabel,
            MediaContainer    = mediaContainer,
            StepList          = stepList,
            PreviousButton    = prevButton,
            NextButton        = nextButton,
            StepCountLabel    = stepCountLabel
        };
    }
}

Views

Pure C#. Calls the builder, holds the element result, wires internal events, exposes them upward. No Unity API calls anywhere in this layer — if something requires AudioListener, QualitySettings, or a service call, it belongs in the Host.

Login View

public class LoginPanelView
{
    public event Action<string, string> OnLoginSubmitted;

    private readonly LoginElements _els;

    public LoginPanelView(VisualElement root)
    {
        _els = new LoginBuilder(root).Build();
        Wire();
    }

    private void Wire()
    {
        _els.LoginButton.clicked += OnLoginClicked;
    }

    private void OnLoginClicked()
    {
        ClearError();

        var username = _els.UsernameField.value.Trim();
        var password = _els.PasswordField.value;

        // Basic client-side validation before raising the event
        if (string.IsNullOrEmpty(username))
        {
            ShowError("Please enter your username.");
            return;
        }

        if (string.IsNullOrEmpty(password))
        {
            ShowError("Please enter your password.");
            return;
        }

        SetInteractable(false);
        OnLoginSubmitted?.Invoke(username, password);
    }

    // Called by Host on auth failure
    public void ShowError(string message)
    {
        _els.ErrorLabel.text = message;
        _els.ErrorLabel.RemoveFromClassList(UIStyles.Hidden);
        _els.UsernameField.AddToClassList(UIStyles.InputError);
    }

    public void ClearError()
    {
        _els.ErrorLabel.AddToClassList(UIStyles.Hidden);
        _els.UsernameField.RemoveFromClassList(UIStyles.InputError);
    }

    // Disabled while auth request is in flight, re-enabled on failure
    public void SetInteractable(bool on)
    {
        _els.LoginButton.SetEnabled(on);
        _els.UsernameField.SetEnabled(on);
        _els.PasswordField.SetEnabled(on);
        _els.LoginButton.text = on ? "Sign In" : "Signing in...";
    }

    // VR poke entry point — mirrors the click path exactly
    public void HandlePoke(string pokeId)
    {
        if (pokeId == PokeIds.Login) OnLoginClicked();
    }
}

Settings View — Tab Switching

Tab state lives entirely in the View. The Host and Controller have no knowledge of which tab is active.

public class SettingsPanelView
{
    public event Action         OnBackClicked;
    public event Action<string> OnThemeChanged;
    public event Action<bool>   OnHighContrastChanged;
    public event Action<float>  OnMasterVolumeChanged;
    public event Action<float>  OnMusicVolumeChanged;
    public event Action<float>  OnSFXVolumeChanged;

    private readonly SettingsElements _els;

    public SettingsPanelView(VisualElement root)
    {
        _els = new SettingsBuilder(root).Build();
        Wire();
    }

    private void Wire()
    {
        _els.BackButton.clicked += () => OnBackClicked?.Invoke();

        _els.VisualTab.clicked += () => SwitchTab(audio: false);
        _els.AudioTab.clicked  += () => SwitchTab(audio: true);

        _els.ThemeDropdown.RegisterValueChangedCallback(
            e => OnThemeChanged?.Invoke(e.newValue));

        _els.HighContrastToggle.RegisterValueChangedCallback(
            e => OnHighContrastChanged?.Invoke(e.newValue));

        RegisterVolumeSlider(_els.MasterSlider, _els.MasterValueLabel,
            v => OnMasterVolumeChanged?.Invoke(v));

        RegisterVolumeSlider(_els.MusicSlider, _els.MusicValueLabel,
            v => OnMusicVolumeChanged?.Invoke(v));

        RegisterVolumeSlider(_els.SFXSlider, _els.SFXValueLabel,
            v => OnSFXVolumeChanged?.Invoke(v));
    }

    // Shared helper avoids repeating the same three-line pattern per slider
    private void RegisterVolumeSlider(Slider slider, Label label, Action<float> onChange)
    {
        slider.RegisterValueChangedCallback(e =>
        {
            label.text = e.newValue.ToString("0%");
            onChange?.Invoke(e.newValue);
        });
    }

    private void SwitchTab(bool audio)
    {
        _els.VisualTab.EnableInClassList(UIStyles.BtnTabActive, !audio);
        _els.AudioTab.EnableInClassList(UIStyles.BtnTabActive,   audio);
        _els.VisualContent.EnableInClassList(UIStyles.Hidden,    audio);
        _els.AudioContent.EnableInClassList(UIStyles.Hidden,    !audio);
    }

    // Called by Host.Show() to sync sliders with saved values before panel opens
    public void SetValues(float master, float music, float sfx,
        string theme, bool highContrast)
    {
        // SetValueWithoutNotify prevents firing change events during initialisation
        _els.MasterSlider.SetValueWithoutNotify(master);
        _els.MasterValueLabel.text = master.ToString("0%");

        _els.MusicSlider.SetValueWithoutNotify(music);
        _els.MusicValueLabel.text = music.ToString("0%");

        _els.SFXSlider.SetValueWithoutNotify(sfx);
        _els.SFXValueLabel.text = sfx.ToString("0%");

        _els.ThemeDropdown.SetValueWithoutNotify(theme);
        _els.HighContrastToggle.SetValueWithoutNotify(highContrast);
    }

    public void HandlePoke(string pokeId)
    {
        switch (pokeId)
        {
            case PokeIds.Back:      OnBackClicked?.Invoke(); break;
            case PokeIds.TabVisual: SwitchTab(false);        break;
            case PokeIds.TabAudio:  SwitchTab(true);         break;
        }
    }
}

Module View — Dynamic Content Per Step

public class ModulePanelView
{
    public event Action OnPreviousClicked;
    public event Action OnNextClicked;

    private readonly ModuleElements _els;

    public ModulePanelView(VisualElement root)
    {
        _els = new ModuleBuilder(root).Build();
        Wire();
    }

    private void Wire()
    {
        _els.PreviousButton.clicked += () => OnPreviousClicked?.Invoke();
        _els.NextButton.clicked     += () => OnNextClicked?.Invoke();
    }

    // Called by Host each time the step index changes
    public void Populate(ModuleStepData step, int current, int total)
    {
        _els.ModuleTitleLabel.text  = step.ModuleTitle;
        _els.InstructionsLabel.text = step.Instructions;
        _els.StepCountLabel.text    = $"Step {current} of {total}";

        _els.PreviousButton.SetEnabled(current > 1);

        // Last step: change button label to signal completion
        _els.NextButton.text = current == total ? "Complete ✓" : "Next →";

        // Show media only if this step has an image assigned
        if (step.Media != null)
        {
            _els.MediaContainer.style.backgroundImage =
                new StyleBackground(step.Media);
            _els.MediaContainer.RemoveFromClassList(UIStyles.Hidden);
        }
        else
        {
            _els.MediaContainer.AddToClassList(UIStyles.Hidden);
        }

        RebuildStepList(step.Steps, current);
    }

    private void RebuildStepList(IList<string> steps, int activeIndex)
    {
        _els.StepList.Clear();

        for (int i = 0; i < steps.Count; i++)
        {
            var item  = UIFactory.CreateContainer(UIStyles.StepItem);
            var label = UIFactory.CreateLabel(steps[i], UIStyles.BodyText);
            item.Add(label);

            if (i < activeIndex - 1)
                item.AddToClassList(UIStyles.StepItemComplete);
            else if (i == activeIndex - 1)
                item.AddToClassList(UIStyles.StepItemActive);

            _els.StepList.Add(item);
        }
    }

    public void HandlePoke(string pokeId)
    {
        switch (pokeId)
        {
            case PokeIds.Next:     OnNextClicked?.Invoke();     break;
            case PokeIds.Previous: OnPreviousClicked?.Invoke(); break;
        }
    }
}

VR Interactable Handshake

public interface IPokeTarget
{
    void OnPoked(string buttonId);
}

public static class PokeIds
{
    public const string Login     = "login";
    public const string Back      = "back";
    public const string Next      = "next";
    public const string Previous  = "previous";
    public const string TabVisual = "tab-visual";
    public const string TabAudio  = "tab-audio";
}

The uGUI button sitting above the UITK panel calls host.OnPoked(PokeIds.Next) in its onClick. The Host passes it to view.HandlePoke(), which runs the same code path as a pointer click. Poke and pointer events are in sync with no duplication.


Hosts

MonoBehaviour. Owns the panel lifecycle and handles everything that requires Unity APIs — audio, quality settings, scene loading, save calls. Views never do this.

Base Host

public abstract class BasePanelHost : MonoBehaviour
{
    [SerializeField] protected UIDocument uiDocument;
    [SerializeField] protected StyleSheet styleSheet;

    protected bool IsGenerated { get; private set; }
    protected VisualElement Root => uiDocument.rootVisualElement;

    public virtual void Generate()
    {
        if (IsGenerated) Dispose();
        if (styleSheet != null)
            Root.styleSheets.Add(styleSheet);
        IsGenerated = true;
    }

    public virtual void Show()
    {
        Root.RemoveFromClassList(UIStyles.Hidden);
        Root.AddToClassList(UIStyles.FadeIn);
    }

    public virtual void Hide()
    {
        Root.AddToClassList(UIStyles.Hidden);
        Root.RemoveFromClassList(UIStyles.FadeIn);
    }

    public virtual void Dispose()
    {
        Root.Clear();
        Root.styleSheets.Clear();
        IsGenerated = false;
    }
}

Login Host

public class LoginPanelHost : BasePanelHost, IPokeTarget
{
    public event Action<string, string> OnLoginSubmitted;

    private LoginPanelView _view;

    public override void Generate()
    {
        base.Generate();
        _view = new LoginPanelView(Root);
        _view.OnLoginSubmitted += (user, pass) => OnLoginSubmitted?.Invoke(user, pass);
        Show();
    }

    // Called by Controller if the auth service returns a failure
    public void NotifyLoginFailed(string reason)
    {
        _view?.ShowError(reason);
        _view?.SetInteractable(true);
    }

    public void OnPoked(string buttonId) => _view?.HandlePoke(buttonId);

    public override void Dispose()
    {
        _view = null;
        base.Dispose();
    }
}

Settings Host

public class SettingsPanelHost : BasePanelHost, IPokeTarget
{
    [SerializeField] private AppSettings _appSettings;

    public event Action OnBackRequested;

    private SettingsPanelView _view;

    public override void Generate()
    {
        base.Generate();
        _view = new SettingsPanelView(Root);

        _view.OnBackClicked         += () => OnBackRequested?.Invoke();
        _view.OnThemeChanged        += name  => ThemeManager.Instance.Apply(name);
        _view.OnHighContrastChanged += on    => ThemeManager.Instance.SetHighContrast(on);
        _view.OnMasterVolumeChanged += value =>
        {
            _appSettings.MasterVolume = value;
            AudioManager.SetMaster(value);
        };
        _view.OnMusicVolumeChanged += value =>
        {
            _appSettings.MusicVolume = value;
            AudioManager.SetMusic(value);
        };
        _view.OnSFXVolumeChanged += value =>
        {
            _appSettings.SFXVolume = value;
            AudioManager.SetSFX(value);
        };

        Hide(); // Settings starts hidden — shown by Controller on demand
    }

    public override void Show()
    {
        // Sync sliders with current saved values every time the panel opens
        _view?.SetValues(
            _appSettings.MasterVolume,
            _appSettings.MusicVolume,
            _appSettings.SFXVolume,
            _appSettings.Theme,
            _appSettings.HighContrast
        );
        base.Show();
    }

    public void OnPoked(string buttonId) => _view?.HandlePoke(buttonId);

    public override void Dispose()
    {
        _view = null;
        base.Dispose();
    }
}

Module Host

public class ModulePanelHost : BasePanelHost, IPokeTarget
{
    public event Action OnModuleCompleted;

    private ModulePanelView      _view;
    private List<ModuleStepData> _steps;
    private int                  _currentStep;

    public override void Generate()
    {
        base.Generate();
        _view = new ModulePanelView(Root);
        _view.OnNextClicked     += HandleNext;
        _view.OnPreviousClicked += HandlePrevious;
        Hide();
    }

    public void LoadModule(List<ModuleStepData> steps)
    {
        _steps       = steps;
        _currentStep = 1;
        RefreshView();
        Show();
    }

    private void HandleNext()
    {
        if (_currentStep >= _steps.Count)
        {
            OnModuleCompleted?.Invoke();
            return;
        }
        _currentStep++;
        RefreshView();
    }

    private void HandlePrevious()
    {
        if (_currentStep <= 1) return;
        _currentStep--;
        RefreshView();
    }

    private void RefreshView()
        => _view?.Populate(_steps[_currentStep - 1], _currentStep, _steps.Count);

    public void OnPoked(string buttonId) => _view?.HandlePoke(buttonId);

    public override void Dispose()
    {
        _view  = null;
        _steps = null;
        base.Dispose();
    }
}

AppController

Single MonoBehaviour for the screen set. Owns navigation only — no UI element references, no builder calls, no direct View interaction.

public class AppController : MonoBehaviour
{
    [Header("Hosts")]
    [SerializeField] private LoginPanelHost     _loginHost;
    [SerializeField] private DashboardPanelHost _dashboardHost;
    [SerializeField] private SettingsPanelHost  _settingsHost;
    [SerializeField] private ModulePanelHost    _moduleHost;

    // ── Lifecycle ────────────────────────────────────────────────────────────

    private void OnEnable()
    {
        GenerateAll();
        SubscribeAll();
        ShowOnly(_loginHost);
    }

    private void OnDisable()
    {
        UnsubscribeAll();
    }

    private void GenerateAll()
    {
        _loginHost.Generate();
        _dashboardHost.Generate();
        _settingsHost.Generate();
        _moduleHost.Generate();
    }

    // ── Subscriptions ────────────────────────────────────────────────────────

    private void SubscribeAll()
    {
        _loginHost.OnLoginSubmitted        += HandleLogin;
        _dashboardHost.OnSettingsRequested += () => ShowOnly(_settingsHost);
        _dashboardHost.OnModuleSelected    += HandleModuleSelected;
        _settingsHost.OnBackRequested      += () => ShowOnly(_dashboardHost);
        _moduleHost.OnModuleCompleted      += HandleModuleCompleted;
    }

    private void UnsubscribeAll()
    {
        _loginHost.OnLoginSubmitted        -= HandleLogin;
        _dashboardHost.OnSettingsRequested -= () => ShowOnly(_settingsHost);
        _dashboardHost.OnModuleSelected    -= HandleModuleSelected;
        _settingsHost.OnBackRequested      -= () => ShowOnly(_dashboardHost);
        _moduleHost.OnModuleCompleted      -= HandleModuleCompleted;
    }

    // ── Navigation ───────────────────────────────────────────────────────────

    private void ShowOnly(BasePanelHost target)
    {
        _loginHost.Hide();
        _dashboardHost.Hide();
        _settingsHost.Hide();
        _moduleHost.Hide();
        target.Show();
    }

    // ── Handlers ─────────────────────────────────────────────────────────────

    private void HandleLogin(string username, string password)
    {
        // Replace with real auth service call
        // On success:
        _dashboardHost.SetUserData(username, progress: 0.4f);
        _dashboardHost.SetModules(ModuleService.GetAll());
        ShowOnly(_dashboardHost);

        // On failure:
        // _loginHost.NotifyLoginFailed("Invalid credentials.");
    }

    private void HandleModuleSelected(string moduleId)
    {
        var steps = ModuleService.GetSteps(moduleId);
        _moduleHost.LoadModule(steps);
        ShowOnly(_moduleHost);
    }

    private void HandleModuleCompleted()
    {
        // Update progress data, unlock next module, return to dashboard
        ShowOnly(_dashboardHost);
    }
}

File Structure

Assets/Scripts/UI/
├── Common/
│   ├── UIStyles.cs
│   └── IPokeTarget.cs
├── Factory/
│   └── UIFactory.cs
├── Builder/
│   ├── PanelBuilder.cs
│   ├── LoginBuilder.cs
│   ├── DashboardBuilder.cs
│   ├── SettingsBuilder.cs
│   └── ModuleBuilder.cs
├── Views/
│   ├── LoginPanelView.cs
│   ├── DashboardPanelView.cs
│   ├── SettingsPanelView.cs
│   └── ModulePanelView.cs
├── Hosts/
│   ├── BasePanelHost.cs
│   ├── LoginPanelHost.cs
│   ├── DashboardPanelHost.cs
│   ├── SettingsPanelHost.cs
│   └── ModulePanelHost.cs
├── Controllers/
│   └── AppController.cs
└── Models/
    ├── ModuleData.cs
    └── ModuleStepData.cs