18 KiB
Jovian Popup System
A lightweight, low-allocation popup and tooltip system for Unity with category-based isolation, a fluent content builder, and extensible animations.
Requirements
- Unity 2022.3 or later
- TextMeshPro 3.0.6+
- Input System 1.18.0+
- Newtonsoft JSON 3.2.1+
Install via the Unity Package Manager by adding the package from its local path or from a git URL.
Quick Start
1. Create a PopupSettings asset
In the Unity Editor, go to Assets > Create > Jovian > Popup System > Popup Settings. Place the asset somewhere accessible (e.g. Assets/Settings/PopupSettings.asset). You can also configure settings via Project Settings > Jovian > Popup System.
2. Build a PopupReference prefab
See the Prefab Setup section below for step-by-step instructions.
3. Create and use PopupSystem
using Jovian.PopupSystem;
using Jovian.PopupSystem.UI;
// Create the system. Pass the scene Canvas as canvasRoot to auto-scan all PopupTrigger components.
var popup = new PopupSystem(settings, viewPrefab, canvasRoot);
// Register categories you intend to use.
popup.RegisterCategory(PopupCategory.Item);
popup.RegisterCategory(PopupCategory.Character);
// Option A: Set content on auto-scanned triggers by name.
var trigger = popup.GetTrigger("ItemSlot");
trigger?.SetContent(builder => {
builder
.AddHeader("Health Potion")
.AddSeparator()
.AddText("Restores a moderate amount of health.")
.AddStat("Heal Amount", 50);
});
// Option B: Show a popup directly from code.
popup.Show(PopupCategory.Item, builder => {
builder.AddHeader("Iron Sword").AddStat("Damage", 12);
}, anchorRect, AnchorSide.Right);
// Tick each frame (typically in Update or a game-state loop).
popup.Tick(Time.deltaTime);
// Clean up when the game state is torn down.
popup.Dispose();
PopupCategory
PopupCategory is a readonly struct that acts as a channel for isolating popups. Each category gets its own view instance and can have independent priority and delay settings.
Built-in categories
PopupCategory.Character // Character tooltips
PopupCategory.Item // Item tooltips
PopupCategory.Skill // Skill tooltips
PopupCategory.General // General-purpose tooltips
Custom categories
Create any number of additional categories:
var lootCategory = new PopupCategory("Loot");
popup.RegisterCategory(lootCategory, priority: 5);
Categories are compared by their string ID using ordinal comparison.
PopupSettings
PopupSettings is a ScriptableObject that holds all configuration. Create one via Assets > Create > Jovian > Popup System > Popup Settings.
| Field | Type | Default | Description |
|---|---|---|---|
popupDelay |
float | 0.4 | Seconds before the popup appears after a show request. |
fadeDuration |
float | 0.2 | Duration of the fade-in and fade-out animation. |
defaultAnchorSide |
AnchorSide | Below | Default side to anchor the popup relative to the target element. |
screenEdgePadding |
float | 10 | Minimum pixel distance from screen edges. |
maxPopupWidth |
float | 400 | Maximum width of the popup in pixels. |
sortingOrder |
int | 100 | Sorting order applied to the popup Canvas. |
followMouseOffset |
Vector2 | (15, -15) | Pixel offset from the cursor in follow-mouse mode. |
touchHoldDuration |
float | 0.6 | Seconds a touch must be held before triggering a popup. |
gamepadFocusTrigger |
bool | true | Whether gamepad focus triggers popups. |
categoryPriorities |
List | empty | Per-category priority overrides. Higher priority popups dismiss lower ones. |
categoryDelayOverrides |
List | empty | Per-category delay overrides. Overrides popupDelay for specific categories. |
PopupContentBuilder
PopupContentBuilder is a struct with a fluent API for composing popup content. You receive it in the build callback passed to Show or ShowAtPosition.
Available methods
builder.AddHeader("Fireball"); // Bold header text
builder.AddText("Deals fire damage to all enemies."); // Body text
builder.AddText("Rare", "FFD700"); // Colored text (hex, with or without #)
builder.AddStat("Damage", 120); // Label + integer value row
builder.AddStat("Range", "15m"); // Label + string value row
builder.AddImage(iconSprite, 64f); // Sprite with optional height
builder.AddSeparator(); // Horizontal divider line
Full example
popup.Show(PopupCategory.Skill, builder => {
builder
.AddHeader("Fireball")
.AddText("Hurls a ball of fire that explodes on impact.")
.AddSeparator()
.AddStat("Damage", 120)
.AddStat("Mana Cost", 35)
.AddStat("Range", "15m")
.AddSeparator()
.AddText("Requires: Level 5", "FF6666");
}, targetRect);
All elements are drawn from a grow-only pool inside PopupReference. No allocations occur once the pool is warmed.
PopupTrigger
PopupTrigger is a MonoBehaviour you attach to UI elements to get hover-based popup behavior automatically. It implements IPointerEnterHandler and IPointerExitHandler.
Inspector fields
| Field | Type | Description |
|---|---|---|
category |
PopupCategory | Which category channel to use. |
anchorSide |
AnchorSide | Which side of this element to anchor the popup. |
positionMode |
PopupPositionMode | AnchorToElement or FollowMouse. |
Approach 1: Auto-scanned triggers (recommended for static UI)
When you pass a canvasRoot to the PopupSystem constructor, it auto-scans all PopupTrigger components and binds them. You only need to set content:
var popup = new PopupSystem(settings, viewPrefab, canvasRoot);
popup.RegisterCategory(PopupCategory.Character);
// Find a trigger by its GameObject name and set content
var trigger = popup.GetTrigger("HeroPortrait");
trigger?.SetContent(builder => {
builder.AddHeader("Kael").AddStat("Health", 55);
});
// Or set content on all triggers of a category
foreach(var t in popup.GetTriggers(PopupCategory.Item)) {
t.SetContent(builder => builder.AddHeader("Item"));
}
Approach 2: Dynamic triggers (for runtime-created UI)
For UI elements instantiated at runtime (inventory slots, party portraits), use InitializeTriggersInChildren after instantiation:
// After instantiating slot prefabs under a container:
popup.InitializeTriggersInChildren(slotsContainer, trigger => {
var slotData = trigger.GetComponentInParent<SlotComponent>();
trigger.SetContent(builder => {
builder.AddHeader(slotData.itemName);
});
});
This scans, binds, and registers all triggers under the parent, then calls the callback on each.
Approach 3: Full manual initialization
For complete control, bypass auto-scan and initialize triggers directly:
var trigger = button.GetComponent<PopupTrigger>();
trigger.Initialize(popupSystem, builder => {
builder.AddHeader("Attack").AddStat("Damage", 25);
});
// Or with a category override:
trigger.Initialize(popupSystem, PopupCategory.Skill, builder => {
builder.AddHeader("Fireball");
});
Updating content
To change content on an already-initialized trigger without re-binding:
trigger.SetContent(builder => {
builder.AddHeader("Attack").AddStat("Damage", newDamageValue);
});
// UpdateContent is an alias for SetContent
trigger.UpdateContent(builder => { ... });
Code-Only Triggers
You do not need PopupTrigger to show popups. Call IPopupSystem methods directly:
Anchor to a RectTransform
popupSystem.Show(PopupCategory.Item, builder => {
builder.AddHeader("Iron Sword");
}, inventorySlotRect, AnchorSide.Right);
Follow the mouse cursor
Pass no anchor argument:
popupSystem.Show(PopupCategory.General, builder => {
builder.AddText("Click to interact");
});
Show at a fixed screen position
popupSystem.ShowAtPosition(PopupCategory.General, builder => {
builder.AddText("Tutorial tip");
}, new Vector2(Screen.width * 0.5f, Screen.height * 0.5f));
Hide
popupSystem.Hide(PopupCategory.Item); // Hide a specific category
popupSystem.HideAll(); // Hide all categories
Priority System
When a popup is shown, any visible popup from a lower-priority category is automatically dismissed. Priority is configured per category via PopupSettings.categoryPriorities or at registration time:
popup.RegisterCategory(PopupCategory.Character, priority: 10);
popup.RegisterCategory(PopupCategory.Item, priority: 5);
popup.RegisterCategory(PopupCategory.General, priority: 1);
If the player hovers a character portrait (priority 10) while an item tooltip (priority 5) is visible, the item tooltip is dismissed. Popups of equal or higher priority are not affected.
Settings-based priorities (from PopupSettings.categoryPriorities) take precedence over the priority argument in RegisterCategory.
Lifecycle Integration
The popup system is designed to be created per game state, not as a global singleton. Each game state owns its own IPopupSystem instance:
// In your game state initialization:
var guiCanvas = guiReferences.GetComponentInParent<Canvas>().transform;
var popupSystem = new PopupSystem(popupSettings, popupViewPrefab, guiCanvas);
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);
// All PopupTrigger components under guiCanvas are auto-scanned and bound.
// Pass popupSystem to views/play modes via constructor DI.
// In your game state's Tick/Update:
popupSystem.Tick(Time.deltaTime);
// When the game state exits:
popupSystem.Dispose(); // destroys all popup view GameObjects
Each category lazily creates its own PopupReference instance on first Show call. On Dispose, all views are destroyed. This ensures no leaked GameObjects when transitioning between game states.
IPopupAnimator
The popup system uses IPopupAnimator for show/hide transitions. The default implementation is FadePopupAnimator, which lerps the CanvasGroup.alpha over the configured fadeDuration.
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; }
}
Custom animator example
public sealed class ScalePopupAnimator : IPopupAnimator {
private CanvasGroup target;
private float duration;
private float elapsed;
private float startScale;
private float endScale;
private Action onComplete;
public bool IsAnimating => target != null;
public void Show(CanvasGroup canvasGroup, float duration, Action onComplete) {
target = canvasGroup;
this.duration = Mathf.Max(duration, 0.001f);
elapsed = 0f;
startScale = 0f;
endScale = 1f;
this.onComplete = onComplete;
canvasGroup.transform.localScale = Vector3.zero;
canvasGroup.alpha = 1f;
}
public void Hide(CanvasGroup canvasGroup, float duration, Action onComplete) {
target = canvasGroup;
this.duration = Mathf.Max(duration, 0.001f);
elapsed = 0f;
startScale = 1f;
endScale = 0f;
this.onComplete = onComplete;
}
public void Tick(float deltaTime) {
if(target == null) {
return;
}
elapsed += deltaTime;
var t = Mathf.Clamp01(elapsed / duration);
var scale = Mathf.Lerp(startScale, endScale, t);
target.transform.localScale = Vector3.one * scale;
if(t >= 1f) {
var callback = onComplete;
target = null;
onComplete = null;
callback?.Invoke();
}
}
}
Pass a factory function to the constructor so each category gets its own animator instance:
var popup = new PopupSystem(settings, viewPrefab, () => new ScalePopupAnimator());
Prefab Setup
Build the PopupReference prefab with the following hierarchy. Reference prefabs are included in the Samples~/Prefabs folder.
PopupReferencePrefab Canvas, CanvasGroup, CanvasScaler, PopupReference
Background Image (popup background), ContentSizeFitter (V=Preferred)
Content VerticalLayoutGroup (Control Child Size Width, Child Force Expand Width)
Step 1: Root GameObject
- Create a new GameObject named
PopupReferencePrefab - Add a
Canvascomponent. Set Render Mode to Screen Space - Overlay - Add a
CanvasScalerwith Scale With Screen Size, reference resolution matching your project (e.g. 1920x1080). This is needed for prefab editing; at runtime the nested Canvas inherits the parent's scaler - Add a
CanvasGroupcomponent - Add a
PopupReferencecomponent (fromJovian.PopupSystem.UI) - Set anchors to a single point (e.g. Min 0,1 Max 0,1), Pivot (0,1) for top-left origin
- Do not add a
ContentSizeFitterto the root
Step 2: Background
- Create a child GameObject named
Background - Add an
Imagecomponent. Set the image sprite/color to your desired popup background - Add a
ContentSizeFitterwith Horizontal Fit = Unconstrained and Vertical Fit = Preferred Size - Stretch the RectTransform to fill the root (anchors 0,0 to 1,1, offsets 0) or use point anchors with the ContentSizeFitter driving the size
Step 3: Content container
- Create a child of
BackgroundnamedContent - Stretch the RectTransform to fill the Background (anchors 0,0 to 1,1, offsets for padding)
- Add a
VerticalLayoutGroup:- Control Child Size: Width checked, Height unchecked
- Child Force Expand: Width checked, Height unchecked
- Spacing: 2 (or to taste)
- Padding: as needed
- Do not add a
ContentSizeFitterto Content. The VerticalLayoutGroup reports its preferred size to the parent Background's ContentSizeFitter
Step 4: Element prefabs
Create these as separate prefabs. Do not add ContentSizeFitter to any element prefab:
| Prefab | Component | Notes |
|---|---|---|
| PopupHeader | TMP_Text |
Bold, larger font size. Add LayoutElement if you need minimum height |
| PopupText | TMP_Text |
Regular body text, rich text enabled |
| PopupStat | RectTransform with HorizontalLayoutGroup |
Must have exactly two TMP_Text children: label (left) and value (right) |
| PopupIcon | Image |
For icons or artwork. Add LayoutElement with preferred height |
| PopupSeparator | Image |
Thin horizontal line. Add LayoutElement with preferred height = 1 or 2 |
Step 5: Wire references
On the PopupReference component, assign:
- Content: the Content RectTransform
- Canvas Group: the root CanvasGroup
- Background: the Background RectTransform
- Header Prefab: your PopupHeader prefab
- Text Prefab: your PopupText prefab
- Stat Prefab: your PopupStat prefab
- Image Prefab: your PopupIcon prefab
- Separator Prefab: your PopupSeparator prefab
Save as a prefab. The maxPopupWidth from PopupSettings is applied at runtime to constrain the popup's horizontal size. At runtime, when parented under a scene Canvas, the popup's own Canvas becomes a nested override Canvas inheriting the parent's scaling.
Optimization Notes
The popup system is designed for minimal runtime allocation:
PopupCategoryis a readonly struct with value semantics -- zero heap allocation on pass or comparisonPopupContentBuilderis a struct that operates directly on cached UI elements -- no intermediate data structures- Content elements use a grow-only cache: elements are activated/deactivated, never instantiated or destroyed after warmup.
GetStatcaches TMP_Text references in a struct to avoidGetComponentsInChildrenon reuse - Delay timers and animation are driven by
floatfields inTick()-- no coroutines, noWaitForSeconds - Screen rect calculation uses a static
Vector3[4]buffer -- no per-frame array allocation - Layout rebuilds only occur when content changes (dirty flag), not on every position update
- Each registered category gets its own
IPopupAnimatorinstance, preventing state corruption during concurrent show/hide animations
Samples
The Samples~ folder contains ready-to-use reference material:
- Prefabs: PopupReferencePrefab and all element prefabs (header, text, stat, icon, separator) with correct component setup
- Settings: Pre-configured PopupSettings asset with sensible defaults
- Scripts: Three example scripts demonstrating different integration patterns:
PopupSystemExample-- auto-scanned triggers withGetTriggerandGetTriggersDynamicTriggersExample-- runtime-instantiated UI withInitializeTriggersInChildrenCodeOnlyPopupExample-- showing popups from code (anchored, fixed position, follow mouse)
Import via Unity Package Manager: select the package, expand Samples, click Import.
API Reference
Core Types
| Type | Namespace | Description |
|---|---|---|
IPopupSystem |
Jovian.PopupSystem |
Main interface for showing/hiding popups. |
PopupSystem |
Jovian.PopupSystem |
Concrete implementation. Constructor: (PopupSettings, PopupReference, Transform canvasParent, Func<IPopupAnimator>). Auto-scans triggers under canvasParent. Methods: ScanTriggers, GetTrigger, GetTriggers, InitializeTriggersInChildren. |
PopupSettings |
Jovian.PopupSystem |
ScriptableObject with all configuration fields. |
PopupCategory |
Jovian.PopupSystem |
Readonly struct identifying a popup channel. |
PopupContentBuilder |
Jovian.PopupSystem |
Fluent struct for building popup content in callbacks. |
UI Types
| Type | Namespace | Description |
|---|---|---|
PopupReference |
Jovian.PopupSystem.UI |
MonoBehaviour managing popup layout, pooling, and positioning. |
PopupTrigger |
Jovian.PopupSystem.UI |
MonoBehaviour for hover-based popup triggers on UI elements. |
Animation Types
| Type | Namespace | Description |
|---|---|---|
IPopupAnimator |
Jovian.PopupSystem |
Interface for custom show/hide animations. |
FadePopupAnimator |
Jovian.PopupSystem |
Default fade animation via CanvasGroup alpha. |
Enums
| Type | Values |
|---|---|
AnchorSide |
Below, Above, Left, Right |
PopupPositionMode |
AnchorToElement, FollowMouse |
License
See the LICENSE file in the package root.