Progress Bar family — one shared animation core (ProgressBarData + ProgressBarController), several shapes on top.
This page documents that core; siblings: Overview · Linear · Radial · RPG Demo.
This page documents the internal architecture of the Progress Bar system. Use this guide when you need to:
The Progress Bar system uses a separation of concerns architecture:
The controller depends on IProgressBarAnimator (scheduling) and IProgressBarNodeUpdater<TElement> (rendering). IProgressBarVisualUpdater is separate—it styles the rectangular ProgressBar's chrome (background, icon, title) and is not part of the animation pipeline.
All bar elements share one shell: ProgressBarElementBase owns the
Data/Animator/Controller/TransitionDriver lifecycle, pending-play queueing, the
threshold class, the custom-style hook, and the shared UXML attribute surface
(durations, easings, delays, indicator colors, transition slots, threshold).
ProgressBar, RadialProgressBar, and CellBar are thin skins over it: each
contributes only its renderer, its node updater, and its idiom-specific attributes.
The base carries [UxmlElement] so the UXML source generator emits its serialized
data; inherited attributes do not deserialize without it.
Pure data container holding all state and configuration. UI-agnostic—can be used with any rendering system. No visual references or animation scheduling; visuals subscribe to its events.
public class ProgressBarData
{
// Animation settings
public bool InstantPositive { get; set; }
public bool InstantNegative { get; set; }
public long FillDurationMs { get; set; } // wall-clock duration for gains (default 500)
public long DrainDurationMs { get; set; } // wall-clock duration for losses (default 350)
public EasingType FillEasing { get; set; } // CupkekGames.Fadeables (default SineInOut)
public EasingType DrainEasing { get; set; } // default QuadOut
public ProgressBarDurationMode DurationMode { get; set; } // Fixed | PerUnitDistance
public float IndicatorDurationScale { get; set; }
public long FirstDelay { get; set; }
public long SecondDelay { get; set; }
public bool UseTimeScale { get; set; } // Scale animation by Time.timeScale
public float Threshold { get; set; } // 0 = disabled
// Progress segments
public ProgressBarNode[] Segments { get; set; }
public ProgressBarNode Indicator { get; set; }
// Indicator appearance
public bool IndicatorAutoColor { get; set; }
public Color IndicatorColorPositive { get; set; }
public Color IndicatorColorNegative { get; set; }
// Events visuals subscribe to
public Action<int, float> OnSegmentCurrentValueChanged;
public Action<float> OnIndicatorCurrentValueChanged;
public Action OnSegmentsChanged;
public Action<int, bool> OnSegmentAnimationStart;
public Action<bool> OnIndicatorAnimationStart;
public Action<int, bool> OnSegmentAnimationComplete;
public Action<bool> OnIndicatorAnimationComplete;
public Action<float, bool> OnThresholdCrossed;
// Segment management
public void SetSegments(ProgressBarNode[] segments);
public void RebuildSegments(int count);
// Helpers
public long GetDurationMs(bool positive, float distance);
public EasingType GetEasing(bool positive);
public float GetTotalCurrentValue();
public bool IsBelowThreshold { get; }
}Represents a single progress segment with current/target values and color.
public class ProgressBarNode
{
public float CurrentValue { get; set; } // 0-1, setter fires OnCurrentValueChanged
public float TargetValue { get; set; } // 0-1, value animating toward
public Color Color { get; set; }
// Fired when CurrentValue changes
public Action<float> OnCurrentValueChanged;
// Silent setter (no event) — used for initialization
public void SetCurrentValueSilent(float value);
}Generic animation orchestrator that works with any UI element type. Handles:
public class ProgressBarController<TElement> : IDisposable
{
public ProgressBarController(
ProgressBarData data,
IProgressBarAnimator animator,
IProgressBarNodeUpdater<TElement> nodeUpdater,
Func<int, TElement> getSegmentElement,
Func<TElement> getIndicatorElement
);
// Invoked when IndicatorAutoColor needs a style swap (UI-specific)
public Action<bool> OnIndicatorStyleChanged;
public void PlayProgress();
public void PlayIndicator();
public void InstantAllSegments();
public void SetIndicatorColor(bool positive);
public void StopAll();
public void Dispose();
}Instead of holding element references, the controller resolves visual elements through the getSegmentElement / getIndicatorElement callbacks—so the view can rebuild its hierarchy without re-wiring the controller.
Drives animations: implementations step ProgressBarNode.CurrentValue toward TargetValue over time, which fires data events that visuals react to. The default implementation is UIToolkitProgressBarAnimator, which routes all ticking through Luna's single global LunaTicker dispatcher instead of allocating per-element VisualElement.schedule tasks.
public interface IProgressBarAnimator
{
void AnimateSegment(int index, long delay, Action<float> onUpdate, Action onComplete);
void AnimateIndicator(long delay, Action<float> onUpdate, Action onComplete);
void StopSegment(int index);
void StopIndicator();
void StopAll();
}Applies data changes to the actual UI element. Stateless—receives full context with each call. Implement this to create custom rendering (shader-based, transform-based, etc.).
public interface IProgressBarNodeUpdater<TElement>
{
void SetValue(ProgressBarNodeType nodeType, int index, TElement element, float value);
void UpdateValue(ProgressBarNodeType nodeType, int index, TElement element, float value);
void SetColor(ProgressBarNodeType nodeType, int index, TElement element, Color color);
void OnAnimationStart(ProgressBarNodeType nodeType, int index, TElement element, bool positive);
void OnAnimationComplete(ProgressBarNodeType nodeType, int index, TElement element, bool positive);
}
// Non-generic alias for UI Toolkit
public interface IProgressBarNodeUpdater : IProgressBarNodeUpdater<VisualElement> { }ProgressBarNodeType distinguishes Progress segments from the Indicator. The default implementation, DefaultProgressBarNodeUpdater, sets flex-grow on segments (the linear bar's fills are flex children whose positions are derived by layout — seams between segments are structurally impossible) and percent width on the indicator.
Styles the rectangular ProgressBar's non-animated chrome—background, icon, and title. Not used by the animation pipeline; swap it via ProgressBar.SetVisualUpdater().
public interface IProgressBarVisualUpdater
{
void SetBackgroundColor(VisualElement background, Color color, string ussClass);
void SetIconColor(VisualElement icon, Color backgroundColor, Color tintColor, string ussClass);
void SetIconVisibility(VisualElement icon, bool visible);
void SetIconSize(VisualElement icon, int size);
void SetTitleText(Label label, string text);
}public class MyCustomProgressBar : VisualElement
{
private ProgressBarData _data;
public MyCustomProgressBar()
{
_data = new ProgressBarData
{
Segments = new[] { new ProgressBarNode() },
Indicator = new ProgressBarNode()
};
}
}public class MyCustomNodeUpdater : IProgressBarNodeUpdater<VisualElement>
{
public void SetValue(ProgressBarNodeType nodeType, int index, VisualElement element, float value)
{
// Set value instantly (no animation)
}
public void UpdateValue(ProgressBarNodeType nodeType, int index, VisualElement element, float value)
{
// Your custom rendering logic per animation step
// e.g., update shader parameters, move sprites, etc.
}
public void SetColor(ProgressBarNodeType nodeType, int index, VisualElement element, Color color) { }
public void OnAnimationStart(ProgressBarNodeType nodeType, int index, VisualElement element, bool positive) { }
public void OnAnimationComplete(ProgressBarNodeType nodeType, int index, VisualElement element, bool positive) { }
}In the constructor, forward ProgressBarData's value-changed events to your node updater and register the attach/detach callbacks. Then create the animator and controller after the element attaches to a panel (the animator needs a host element for LunaTicker scheduling), and dispose on detach:
public class MyCustomProgressBar : VisualElement
{
private UIToolkitProgressBarAnimator _animator;
private ProgressBarController<VisualElement> _controller;
private MyCustomNodeUpdater _nodeUpdater;
// Your element accessors — return the fill/indicator child elements your
// updater renders into. This minimal example just returns the bar itself.
private VisualElement GetSegmentElement(int index) => this;
private VisualElement GetIndicatorElement() => this;
public MyCustomProgressBar()
{
// ... _data construction from Step 1 ...
// Forward data events to the node updater — this is what makes
// UpdateValue run on every animation tick.
_nodeUpdater = new MyCustomNodeUpdater();
_data.OnSegmentCurrentValueChanged += (index, value) =>
_nodeUpdater.UpdateValue(ProgressBarNodeType.Progress, index, GetSegmentElement(index), value);
_data.OnIndicatorCurrentValueChanged += value =>
_nodeUpdater.UpdateValue(ProgressBarNodeType.Indicator, 0, GetIndicatorElement(), value);
RegisterCallback<AttachToPanelEvent>(OnAttach);
RegisterCallback<DetachFromPanelEvent>(OnDetach);
}
private void OnAttach(AttachToPanelEvent evt)
{
_animator = new UIToolkitProgressBarAnimator(this, _data);
_controller = new ProgressBarController<VisualElement>(
_data,
_animator,
_nodeUpdater,
index => GetSegmentElement(index),
() => GetIndicatorElement()
);
}
private void OnDetach(DetachFromPanelEvent evt)
{
_controller?.Dispose();
_controller = null;
_animator = null;
}
public void PlayProgress() => _controller?.PlayProgress();
public void PlayIndicator() => _controller?.PlayIndicator();
}The controller never calls UpdateValue itself—per-tick rendering flows through ProgressBarData's value-changed events, which your element must forward to the node updater (this is exactly what the built-in ProgressBar does in its constructor). Subscribing once in the constructor is safe because _data is owned by the element and shares its lifetime.
For non-UI Toolkit systems, implement IProgressBarAnimator yourself (e.g., driven by MonoBehaviour.Update) and use any TElement type—the controller and data layer have no UI Toolkit dependency.
When PlayProgress() / PlayIndicator() are called:
Both animations are scheduled immediately—sequencing comes purely from the FirstDelay / SecondDelay values, not from completion chaining. The animation is time-based: each tick computes CurrentValue = start + (target − start) × Easing(elapsed / duration) using the direction's configured duration and EasingType (GetDurationMs / GetEasing), with delta time scaled by Time.timeScale when UseTimeScale is enabled. Wall-clock duration is exact regardless of frame rate. OnSegmentAnimationStart / OnIndicatorAnimationStart fire when an animation is scheduled; instant changes fire start → snap → complete as a zero-duration lifecycle.
flex-grow per segment (indicator uses percent width)GapLineCount, minor/major)ShowTip)MeshGenerationContext for custom arc renderingPainter2D paths on layered elements (track, fill, indicator)CellBarModel wraps ProgressBarData with a unit-facing API (SetResources/Damage/Heal/Add) producing delta recordsCellBarFeelAssetProgressBar/
├── Core/
│ ├── ProgressBarData.cs # Pure data container
│ ├── ProgressBarNode.cs # Single segment data
│ ├── ProgressBarController.cs # Animation orchestration
│ └── CellBarModel.cs # Unit-facing pool model (wraps ProgressBarData)
├── Interfaces/
│ ├── IProgressBarAnimator.cs # Animation scheduling
│ ├── IProgressBarNodeUpdater.cs # Value/visual rendering strategy
│ └── IProgressBarVisualUpdater.cs # Chrome styling (background, icon, title)
└── UIToolkit/
├── Shared/
│ ├── ProgressBarElementBase.cs # Shared shell: lifecycle + common UXML attributes
│ ├── ProgressBarExtensions.cs # Convenience extension methods
│ ├── ProgressBarTransitionDriver.cs # Juice bridge: plays TransitionSequenceAssets on lifecycle events
│ └── UIToolkitProgressBarAnimator.cs # Default animator (LunaTicker-driven, time + easing based)
├── Linear/
│ ├── ProgressBar.cs # Rectangular implementation (partial: element tree + hooks)
│ ├── ProgressBar.Settings.cs # Rectangular implementation (partial: idiom attributes, chrome)
│ ├── ProgressBar.GapLines.cs # Rectangular implementation (partial: ruler gap lines)
│ ├── DefaultProgressBarNodeUpdater.cs # flex-grow based rendering
│ └── DefaultProgressBarVisualUpdater.cs # Default chrome styling
├── Radial/
│ ├── RadialProgressBar.cs # Circular implementation (partial: layers + hooks)
│ ├── RadialProgressBar.Settings.cs # Circular implementation (partial: arc/cell attributes)
│ ├── RadialProgressBar.Rendering.cs # Circular implementation (partial: Painter2D arc painting)
│ └── RadialProgressBarNodeUpdater.cs # Arc repaint rendering
└── CellBar/
├── CellBar.cs # Celled implementation (partial: geometry attributes + hooks)
├── CellBar.Combat.cs # Celled implementation (partial: unit facade + ghost grammar)
├── CellBar.Feel.cs # Celled implementation (partial: feel timelines + asset apply)
├── CellBar.Rendering.cs # Celled implementation (partial: Painter2D cell painting)
├── CellBarMath.cs # Pure cell/span/timeline math (unit-tested)
└── CellBarFeelAsset.cs # Grammar preset ScriptableObjectEvery bar is a partial-class skin over ProgressBarElementBase: the core file holds the renderer and hook overrides, .Settings.cs (or the sibling partials) holds the idiom-specific attributes, and the shared lifecycle plus common attribute surface live in the base.
ProgressBarData for all state managementIDisposable pattern in controllersInstant* to true during initialization, then falsePlayProgress() and PlayIndicator() after changing valuesCurrentValue directly—use TargetValueRebuildSegments() after changing segment countPlayProgress() / PlayIndicator() calls (or change targets) while a PlayMultiPass() sequence is active—multi-pass sequencing is driven by OnSegmentAnimationComplete events, so an outside completion advances its phase machine unexpectedly. Call CancelMultiPass() first.Re-targeting outside a multi-pass is interruption-safe by design: each Play* call stops the running tick subscription, re-syncs the visual to the current data value, and animates toward the new target—you can call PlayProgress() on every value change without waiting for completion. This is the flip side of the scheduling note above: plain Play* calls never chain on completion (only FirstDelay/SecondDelay order them), which is exactly why they are free to interrupt each other—and why PlayMultiPass, which does chain on completion events, must not be mixed with manual calls.
IProgressBarNodeUpdater in practice (shader-driven orb)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