Files
trail-into-darkness/Packages/com.jovian.popup-system
2026-04-06 12:28:01 +02:00
..
2026-04-06 10:06:09 +02:00
2026-04-06 10:44:16 +02:00
2026-04-06 10:06:09 +02:00
2026-04-06 10:44:16 +02:00
2026-04-06 10:44:16 +02:00
2026-04-06 10:44:16 +02:00
2026-04-06 10:44:16 +02:00

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. viewPrefab is a reference to your PopupReference prefab.
var popup = new PopupSystem(settings, viewPrefab);

// Register categories you intend to use.
popup.RegisterCategory(PopupCategory.Item);
popup.RegisterCategory(PopupCategory.Character);

// Show a popup anchored to a UI element.
popup.Show(PopupCategory.Item, builder => {
    builder
        .AddHeader("Health Potion")
        .AddSeparator()
        .AddText("Restores a moderate amount of health.")
        .AddStat("Heal Amount", 50)
        .AddStat("Uses", "3 / 3");
}, 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.

Initialization

PopupTrigger must be initialized from code before it will respond to pointer events:

var trigger = button.GetComponent<PopupTrigger>();
trigger.Initialize(popupSystem, builder => {
    builder
        .AddHeader("Attack")
        .AddStat("Damage", 25);
});

You can also override the category at initialization time:

trigger.Initialize(popupSystem, PopupCategory.Skill, builder => {
    builder.AddHeader("Fireball");
});

To change content without re-initializing:

trigger.UpdateContent(builder => {
    builder
        .AddHeader("Attack")
        .AddStat("Damage", newDamageValue);
});

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 constructor or initialization:
var popupSystem = new PopupSystem(popupSettings, popupViewPrefab);
popupSystem.RegisterCategory(PopupCategory.Character, priority: 10);
popupSystem.RegisterCategory(PopupCategory.Item, priority: 5);

// Pass 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:

Step 1: Root GameObject

  1. Create a new GameObject named PopupReference.
  2. Add a Canvas component. Set Render Mode to Screen Space - Overlay. Set Sort Order to match PopupSettings.sortingOrder (default 100).
  3. Add a CanvasGroup component.
  4. Add a PopupReference component (from Jovian.PopupSystem.UI).

Step 2: Background

  1. Create a child GameObject named Background.
  2. Add a RectTransform and an Image component. Set the image color/sprite to your desired popup background.
  3. Add a ContentSizeFitter with Vertical Fit set to Preferred Size.

Step 3: Content container

  1. Create a child of Background named Content.
  2. Add a RectTransform. Stretch it to fill the background with appropriate padding.
  3. Add a VerticalLayoutGroup. Configure padding and spacing to taste.
  4. Add a ContentSizeFitter with Vertical Fit set to Preferred Size.

Step 4: Element prefabs

Create these as child prefabs (or separate prefabs). Each must be a prefab reference, not an in-hierarchy instance:

Prefab Component Notes
Header TMP_Text Bold, larger font size
Text TMP_Text Regular body text
Stat RectTransform with HorizontalLayoutGroup Must have exactly two TMP_Text children: label and value
Image Image For icons or artwork
Separator Image Thin horizontal line

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 header TMP_Text prefab
  • Text Prefab - your body text TMP_Text prefab
  • Stat Prefab - your stat row RectTransform prefab
  • Image Prefab - your image prefab
  • Separator Prefab - your separator Image prefab

Save as a prefab. The maxPopupWidth from PopupSettings is applied at runtime to constrain the popup's horizontal size.

Optimization Notes

The popup system is designed for minimal runtime allocation:

  • PopupCategory is a readonly struct with value semantics -- zero heap allocation on pass or comparison
  • PopupContentBuilder is 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. GetStat caches TMP_Text references in a struct to avoid GetComponentsInChildren on reuse
  • Delay timers and animation are driven by float fields in Tick() -- no coroutines, no WaitForSeconds
  • 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 IPopupAnimator instance, preventing state corruption during concurrent show/hide animations

API Reference

Core Types

Type Namespace Description
IPopupSystem Jovian.PopupSystem Main interface for showing/hiding popups.
PopupSystem Jovian.PopupSystem Concrete implementation. Constructor: (PopupSettings, PopupReference, Func<IPopupAnimator>).
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.