Progress Bar Architecture

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:

  • Create custom progress bar visuals (not rectangular or radial)
  • Implement custom animation behaviors
  • Understand how the animation system works
  • Build progress bars for non-UI Toolkit systems

Overview

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.

Core Classes

ProgressBarData

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.

csharp
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; } }

ProgressBarNode

Represents a single progress segment with current/target values and color.

csharp
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); }

ProgressBarController<TElement>

Generic animation orchestrator that works with any UI element type. Handles:

  • Animation timing and scheduling
  • Coordinating progress vs indicator animations
  • Determining which bar moves first based on positive/negative changes
csharp
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.

Interfaces

IProgressBarAnimator

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.

csharp
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(); }

IProgressBarNodeUpdater<TElement>

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.).

csharp
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.

IProgressBarVisualUpdater

Styles the rectangular ProgressBar's non-animated chrome—background, icon, and title. Not used by the animation pipeline; swap it via ProgressBar.SetVisualUpdater().

csharp
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); }

Creating Custom Progress Bars

Step 1: Create Your Visual Element

csharp
public class MyCustomProgressBar : VisualElement { private ProgressBarData _data; public MyCustomProgressBar() { _data = new ProgressBarData { Segments = new[] { new ProgressBarNode() }, Indicator = new ProgressBarNode() }; } }

Step 2: Implement Node Updater

csharp
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) { } }

Step 3: Wire Up Controller

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:

csharp
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.

Animation Flow

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.

Built-in Implementations

ProgressBar (Rectangular)

  • Uses child VisualElements for segments, laid out as a flex row with an invisible spacer — segment positions are derived by layout, so cracks between segments cannot occur
  • Animates flex-grow per segment (indicator uses percent width)
  • Optional ruler-style gap lines measuring the continuous fill (GapLineCount, minor/major)
  • Optional leading-edge tip element (ShowTip)

RadialProgressBar (Circular)

  • Uses MeshGenerationContext for custom arc rendering
  • Draws arcs as stroked Painter2D paths on layered elements (track, fill, indicator)
  • Supports multi-segment cell mode (Overwatch-style)
  • Each segment rendered as consecutive arc

CellBar (Celled combat bar)

  • Painter-rendered discrete cells with slant, per-resource fill runs, partial caps, ghost span, and edge cap
  • CellBarModel wraps ProgressBarData with a unit-facing API (SetResources/Damage/Heal/Add) producing delta records
  • Built-in damage grammar (hit flash, ghost hold with re-hit extension, heal sweep, pop cascade, severity pulse) driven by a CellBarFeelAsset
  • See Cell Bar

File Structure

ProgressBar/ ├── 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 ScriptableObject

Every 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.

Best Practices

Do

  • ✅ Use ProgressBarData for all state management
  • ✅ Implement IDisposable pattern in controllers
  • ✅ Set Instant* to true during initialization, then false
  • ✅ Call PlayProgress() and PlayIndicator() after changing values

Don't

  • ❌ Modify CurrentValue directly—use TargetValue
  • ❌ Forget to call RebuildSegments() after changing segment count
  • ❌ Issue manual PlayProgress() / 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.

See also

Settings

Theme

Light

Contrast

Material

Dark

Dim

Material Dark

System

Sidebar(Light & Contrast only)

Light
Dark

Font Family

DM Sans

Wix

Inclusive Sans

AR One Sans

Direction

LTR
RTL