11 KiB
Popup System Design
Package: com.jovian.popup-system
Date: 2026-04-06
Purpose
A lightweight, low-allocation popup/tooltip system for Unity UGUI. Supports hover tooltips, anchored popups, and follow-mouse modes with dynamic content built via a fluent API. Per-game-state instances with category-based isolation and priority dismissal.
Architecture
Game Code / PopupTrigger MonoBehaviour
│ popupSystem.Show(category, builder, anchor)
│ popupSystem.Hide(category)
▼
IPopupSystem (per-game-state instance, injected via constructor DI)
│ RegisterCategory(PopupCategory, priority)
│ One PopupView per category (lazy-created on first Show)
│ Priority: higher priority category dismisses lower on show
│ Tick()-driven delay timers and animations (no coroutines)
▼
PopupView (MonoBehaviour — one instance per registered category)
│ Canvas + CanvasGroup for transitions
│ Content parent (VerticalLayoutGroup + ContentSizeFitter)
│ Grow-only element cache (reuse, never destroy)
│ Screen edge clamping
│ Anchor-to-element or follow-mouse positioning
▼
IPopupAnimator (extensible interface for show/hide transitions)
│ Default: FadePopupAnimator (CanvasGroup.alpha lerp)
│ Custom implementations for scale, slide, etc.
▼
PopupContentBuilder (struct, fluent API)
.AddHeader(text) .AddText(text) .AddStat(label, value)
.AddImage(sprite) .AddSeparator()
→ activates pre-existing child elements in PopupView
Core Types
PopupCategory (readonly struct)
Same pattern as LogChannel. Value type, string-keyed, zero-alloc comparisons.
[Serializable]
public readonly struct PopupCategory : IEquatable<PopupCategory> {
readonly string id;
public string Id => id;
public PopupCategory(string id) => this.id = id;
public static readonly PopupCategory Character = new("Character");
public static readonly PopupCategory Item = new("Item");
public static readonly PopupCategory Skill = new("Skill");
public static readonly PopupCategory General = new("General");
}
PopupSettings (ScriptableObject)
Full configuration loaded via Addressables.
General:
popupDelay float 0.4s Time before popup appears after hover
fadeDuration float 0.2s Default animation duration
defaultAnchorSide enum Below Below/Above/Left/Right
screenEdgePadding float 10px Minimum distance from screen edge
maxPopupWidth float 400px Maximum content width
sortingOrder int 100 Canvas sorting order
Follow Mouse:
followMouseOffset Vector2 (15, -15) Offset from cursor in follow mode
Input:
touchHoldDuration float 0.6s Press-and-hold duration for touch
gamepadFocusTrigger bool true Trigger on gamepad focus events
Priority:
categoryPriorities List<CategoryPriority>
- category: PopupCategory
- priority: int Higher value = higher priority
Per-Category Overrides:
categoryDelayOverrides List<CategoryDelay>
- category: PopupCategory
- delay: float
IPopupSystem (interface)
public interface IPopupSystem {
void RegisterCategory(PopupCategory category, int priority = 0);
void Show(PopupCategory category, Action<PopupContentBuilder> buildContent,
RectTransform anchor = null, AnchorSide? anchorSide = null);
void ShowAtPosition(PopupCategory category, Action<PopupContentBuilder> buildContent,
Vector2 screenPosition);
void Hide(PopupCategory category);
void HideAll();
void Tick(float deltaTime);
void Dispose();
}
PopupSystem (implementation)
Dictionary<PopupCategory, PopupViewState>— O(1) lookup by categoryPopupViewStateholds: PopupView instance (null until first show), priority, delay timer, pending show dataTick(deltaTime)drives delay countdown and animation lerp — no coroutines- On
Show():- If a higher-priority popup is already visible, queue or cancel
- Start delay timer; on expiry, activate the view
- PopupView clears content (deactivates cached elements), runs builder callback, positions, animates in
- On
Hide():- Animate out, deactivate
- On
Dispose():- Destroy all PopupView GameObjects
PopupView (MonoBehaviour)
Single prefab, instantiated once per category:
PopupView (Canvas, CanvasGroup, RectMask2D)
├── Content (RectTransform, VerticalLayoutGroup, ContentSizeFitter)
│ ├── [cached] HeaderElement (TMP_Text, deactivated)
│ ├── [cached] TextElement x N (TMP_Text, deactivated)
│ ├── [cached] StatElement x N (TMP_Text label + TMP_Text value, deactivated)
│ ├── [cached] ImageElement x N (Image, deactivated)
│ └── [cached] SeparatorElement x N (Image, deactivated)
└── Background (Image, optional)
Grow-only element cache:
- Pre-creates a small set of each element type (e.g. 3 text, 2 stat, 1 image, 2 separator)
GetOrCreateElement<T>(type)returns next available deactivated element, or Instantiates if none free- New elements persist and are reused on next Show
ClearContent()deactivates all elements and resets the reuse index — no Destroy calls
Positioning:
AnchorToElement(RectTransform target, AnchorSide side)— positions relative to target elementFollowMouse(Vector2 offset)— updates position each Tick to track cursorClampToScreen(float padding)— adjusts position if popup overflows screen bounds, flips anchor side if needed
Animation:
- Calls
IPopupAnimator.Show(canvasGroup, duration, onComplete)andHide(...) - Default
FadePopupAnimatorlerpscanvasGroup.alpha0→1 / 1→0 over duration - Driven by float timer in Tick, not coroutines
PopupContentBuilder (struct)
public struct PopupContentBuilder {
readonly PopupView view;
public PopupContentBuilder AddHeader(string text);
public PopupContentBuilder AddText(string text);
public PopupContentBuilder AddText(string text, string hexColor);
public PopupContentBuilder AddStat(string label, int value);
public PopupContentBuilder AddStat(string label, string value);
public PopupContentBuilder AddImage(Sprite sprite, float height = 64f);
public PopupContentBuilder AddSeparator();
}
Each method activates a cached element from PopupView, sets its data, calls SetAsLastSibling() for ordering. Returns this for chaining. No allocations.
PopupTrigger (MonoBehaviour)
Attach to any UI element. Handles hover detection automatically.
public class PopupTrigger : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler {
[SerializeField] PopupCategory category;
[SerializeField] AnchorSide anchorSide;
[SerializeField] PopupPositionMode positionMode; // AnchorToElement or FollowMouse
IPopupSystem popupSystem;
Action<PopupContentBuilder> contentCallback;
public void Initialize(IPopupSystem popupSystem, Action<PopupContentBuilder> contentCallback);
// IPointerEnterHandler — calls popupSystem.Show(category, contentCallback, rectTransform)
// IPointerExitHandler — calls popupSystem.Hide(category)
}
- Category, anchor side, position mode configurable in Inspector
Initialize()called from code to inject the popup system and content builder callback- No per-frame cost when not hovered
- The
contentCallbackis set once and reused — no allocation per hover
IPopupAnimator (interface)
public interface IPopupAnimator {
void Show(CanvasGroup canvasGroup, float duration, Action onComplete);
void Hide(CanvasGroup canvasGroup, float duration, Action onComplete);
void Tick(float deltaTime);
bool IsAnimating { get; }
}
Default implementation: FadePopupAnimator — lerps alpha. Custom implementations can do scale, slide, bounce, etc.
PopupCategoryJsonConverter
Newtonsoft converter for serializing PopupCategory as string id (same pattern as LogChannelJsonConverter).
Enums
public enum AnchorSide { Below, Above, Left, Right }
public enum PopupPositionMode { AnchorToElement, FollowMouse }
Optimization Summary
| Concern | Approach |
|---|---|
| Hot path (Show/Hide) | Struct category, dict lookup, no alloc |
| Content elements | Grow-only cache, activate/deactivate, never Destroy |
| Timers | Float countdown in Tick(), no coroutines |
| Builder | Struct, operates directly on cached elements |
| Per-frame | Zero alloc when idle; follow-mouse only reads Input position |
| Trigger | IPointerEnter/Exit interfaces, no delegates allocated per hover |
Package Structure
Packages/com.jovian.popup-system/
├── package.json
├── README.md
├── Runtime/
│ ├── Jovian.PopupSystem.asmdef
│ ├── PopupCategory.cs
│ ├── PopupCategoryJsonConverter.cs
│ ├── PopupSettings.cs
│ ├── IPopupSystem.cs
│ ├── PopupSystem.cs
│ ├── IPopupAnimator.cs
│ ├── FadePopupAnimator.cs
│ ├── PopupContentBuilder.cs
│ └── UI/
│ ├── PopupView.cs
│ └── PopupTrigger.cs
├── Editor/
│ ├── Jovian.PopupSystem.Editor.asmdef
│ └── PopupSettingsProvider.cs
└── Samples~/
└── README.md
Dependencies
com.unity.textmeshpro(TMP_Text for content elements)com.unity.inputsystem(for pointer position in follow-mouse mode)com.unity.nuget.newtonsoft-json(for PopupCategory serialization)
Integration Example
// In GameModeGameState or similar:
var popupSettings = Addressables.LoadAssetAsync<PopupSettings>("PopupSettings").WaitForCompletion();
var popupViewPrefab = Addressables.LoadAssetAsync<PopupView>("PopupViewPrefab").WaitForCompletion();
var popupSystem = new PopupSystem(popupSettings, popupViewPrefab);
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);
// Pass popupSystem to views/play modes via constructor DI
// In PartyGuiView — attach PopupTrigger to each slot:
var trigger = slot.GetComponent<PopupTrigger>();
trigger.Initialize(popupSystem, builder => {
builder.AddHeader(member.Name)
.AddSeparator()
.AddStat("Health", member.Stats.GetValue(StatType.Health))
.AddStat("Mana", member.Stats.GetValue(StatType.Mana))
.AddStat("Level", member.Stats.GetValue(StatType.Level))
.AddText($"{member.Race} {member.Class}");
});
// In Tick:
popupSystem.Tick(Time.deltaTime);
// On state exit:
popupSystem.Dispose();
Prefab Setup
PopupView prefab
- Root: Canvas (Screen Space Overlay, sorting order from settings), CanvasGroup (alpha=0)
- Child "Content": RectTransform, VerticalLayoutGroup (Child Force Expand Width: true, Height: false, Spacing: 4, Padding: 8), ContentSizeFitter (Vertical Fit: Preferred Size, Horizontal Fit: Preferred Size up to maxPopupWidth)
- Background Image behind Content
- Optional: RectMask2D on root
Pre-create cached elements as children of Content (all deactivated):
- 2x Header (TMP_Text, bold, larger font)
- 4x Text (TMP_Text, normal)
- 4x Stat (horizontal layout: TMP_Text label + TMP_Text value)
- 2x Image (Image component)
- 3x Separator (Image, 1px height, horizontal stretch)