Skip to content

Animation

Mike Strobel edited this page Jun 26, 2026 · 1 revision

Animation

Cursorial.Animation is a small, time-free animation engine: an animation is a pure function from elapsed time to a value. There is no clock, thread, or timer inside the library — the consumer owns the clock and samples the animation wherever it wants. That makes the whole layer deterministic and trivially testable, and it's why the same primitives power a hand-rolled render loop today and the Cursorial.UI frame scheduler underneath.

Reach for this layer directly when you want eased tweening over your own paint loop (a Cursorial.Rendering frame, a Cursorial.Drawing scene). If you're working inside Cursorial.UI, you'll usually go through its storyboards and transitions instead — see Animation & Transitions — but those are built on exactly these types.

The core idea: IAnimation<T>

Every animation implements IAnimation<T>:

public interface IAnimation<out T>
{
    TimeSpan Duration { get; }       // total running length; never negative, may be zero
    T ValueAt(TimeSpan elapsed);     // the value at `elapsed`
}

ValueAt clamps elapsed to [TimeSpan.Zero, Duration], so sampling before the start holds the first frame and sampling past the end holds the last frame — it never extrapolates. T is covariant (IAnimation<Derived> is an IAnimation<Base>).

You drive it by feeding it your own notion of elapsed time:

var clock = System.Diagnostics.Stopwatch.StartNew();

// ...in your render loop, once per frame:
double opacity = fade.ValueAt(clock.Elapsed);
PaintFrameWith(opacity);

No TimeProvider, no background tick — you decide when and how often to sample. If you have a frozen per-frame timestamp (as Cursorial.UI does), pass that; if you want to scrub, hand it any offset.

Animation<T> — the two-point engine

The workhorse is Animation<T>: ease fromto over a duration, shaping progress with an Easing and blending values with an IInterpolator<T>.

using Cursorial.Animation;

IAnimation<double> fade = new Animation<double>(
    from: 0.0,
    to: 1.0,
    duration: TimeSpan.FromMilliseconds(250),
    interpolator: Interpolator.For<double>(),   // the registered double interpolator
    easing: Easings.QuadOut);                    // optional; defaults to Easings.Linear

double v = fade.ValueAt(TimeSpan.FromMilliseconds(125));   // ~0.75 with QuadOut

From, To, Easing, and Duration are all readable on the instance. A zero-length Duration snaps straight to To.

WPF-style conveniences

For the common value types there are named subclasses that bake the interpolator in, so you don't pass one — the same WPF ergonomics over a single generic implementation:

var fade   = new DoubleAnimation(0.0, 1.0, TimeSpan.FromMilliseconds(250), Easings.QuadOut);
var slide  = new Int32Animation(0, 20, TimeSpan.FromMilliseconds(300));            // e.g. a cell offset
var recolor = new ColorAnimation(Color.FromRgb(64, 224, 208),
                                 Color.FromRgb(20, 30, 80),
                                 TimeSpan.FromMilliseconds(400));

ColorAnimation blends in premultiplied sRGB (Color is the Cursorial.Output type).

Easings

Easing is just delegate double Easing(double t) — it maps linear progress t ∈ [0, 1] to an eased value (which may briefly leave [0, 1] for overshooting curves). Because it's a plain delegate, custom curves are first-class; pass any double -> double.

The Easings catalog provides the standard Penner / easings.net curves as cached, allocation-free delegates. Each family has In (accelerate from rest), Out (decelerate to rest), and InOut variants:

  • Linear
  • QuadIn/Out/InOut, CubicIn/Out/InOut, QuartIn/Out/InOut
  • SineIn/Out/InOut, ExpoIn/Out/InOut
  • BackIn/Out/InOut — anticipation / settle overshoot
  • ElasticIn/Out/InOut — spring
  • BounceIn/Out/InOut — decaying bounces

Custom cubic-bezier curves

Easings.CubicBezier(x1, y1, x2, y2) builds a CSS-style timing function over control points (0,0), (x1,y1), (x2,y2), (1,1). The x control points must be in [0, 1] (a monotonic timeline); y may overshoot.

var snappy = Easings.CubicBezier(0.4, 0.0, 0.2, 1.0);   // "standard" material-style curve
var anim   = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300), snappy);

Parsing easings from text

Easings.TryParse accepts either a catalog name (case-insensitive) or a cubic-bezier(x1,y1,x2,y2) functional form — handy when an easing comes from config or markup:

if (Easings.TryParse("ElasticOut", out var e1)) { /* use e1 */ }
if (Easings.TryParse("cubic-bezier(0.4, 0, 0.2, 1)", out var e2)) { /* use e2 */ }

It returns false on an unknown name or malformed bezier rather than throwing.

The interpolator registry

An IInterpolator<T> blends two endpoints by an already-eased factor — Interpolate(from, to, progress). The animation applies its easing first, so an interpolator only implements the value-space blend (usually linear) and never thinks about timing. The factor is normally [0, 1] but may leave the range under an overshooting easing, so interpolators extrapolate or clamp deliberately.

Interpolator is a process-global registry mapping T → its interpolator. Reads are lock-free, so Interpolator.For<T>() is safe to call every frame:

IInterpolator<double> d = Interpolator.For<double>();
IInterpolator<Color>?  c = Interpolator.ForOrNull<Color>();   // null instead of throwing
bool has = Interpolator.IsRegistered<MyValue>();
  • For<T>() throws InvalidOperationException with a helpful message if nothing is registered for T.
  • ForOrNull<T>() returns null instead.

Built-in types. The registry is pre-seeded with double, int, and Color. When the Cursorial.Drawing assembly loads, a module initializer auto-registers its value types — PointD, Size, Rect, RelativePoint, Margins, IBrush, Pen, and CompositeParameters — so they're available without any app code. The Interpolators static exposes the three core ones directly (Interpolators.Double, Interpolators.Int32, Interpolators.Color) if you'd rather skip the registry lookup.

Registering your own. Add a type at startup with Interpolator.Register<T>(...); the copy-on-write swap keeps concurrent reads consistent:

Interpolator.Register<MyVector>(new MyVectorInterpolator());

Keyframes

For multi-stop animations, KeyframeAnimation<T> walks a sorted list of Keyframe<T> and eases between adjacent stops. Each keyframe carries the Value reached at its Time and the Easing for the segment leading into it (WPF's per-keyframe model — the first keyframe's easing is unused). A null segment easing is linear.

var pulse = new KeyframeAnimation<double>(
    new[]
    {
        new Keyframe<double>(TimeSpan.Zero,                 0.0),
        new Keyframe<double>(TimeSpan.FromMilliseconds(150), 1.0, Easings.QuadOut),
        new Keyframe<double>(TimeSpan.FromMilliseconds(400), 0.3, Easings.QuadIn),
    },
    Interpolator.For<double>());

double v = pulse.ValueAt(elapsed);

Keyframes are sorted by time on construction (you may pass them in any order); Duration is the last keyframe's time. Before the first frame the value holds at the first stop, after the last it holds at the last.

Combinators

Animations compose through fluent extension methods, so a complex timeline is built from simple parts — each result is still a pure IAnimation<T> you sample with ValueAt.

Combinator Effect
.Delay(span) Hold the first frame for span, then play.
.Then(next) Play next after this finishes — a two-step sequence (same T).
.Repeat(n) Play forward n times.
.PingPong(n) Forward-then-back, n cycles.
.Loop() Forward forever (a perpetual sawtooth).
.AutoReverse() Forward-and-back forever (a perpetual bounce).
// Slide in after a beat, then bounce forever:
IAnimation<int> banner =
    new Int32Animation(0, 12, TimeSpan.FromMilliseconds(300), Easings.CubicOut)
        .Delay(TimeSpan.FromMilliseconds(100))
        .AutoReverse();

Notes on the timeline arithmetic:

  • .Loop() / .AutoReverse() report Duration == TimeSpan.MaxValue — a perpetual animation that never settles; ValueAt wraps elapsed time into the current iteration forever.
  • In a .Then(...) chain, a perpetual animation is only legal in the last position (nothing after it could ever run); the chain's Duration becomes TimeSpan.MaxValue.
  • A finite .Repeat(n) holds its final frame at/after the end (a ping-pong finishes back at the start; a forward repeat finishes at the inner end).

RepeatAnimation<T> and SequenceAnimation<T> are the public types behind the sugar (with a Count/ IsPerpetual/AutoReverse surface) if you need to construct them directly, but the extension methods read better.

Driving these in Cursorial.UI

You rarely call ValueAt by hand inside the UI framework. Cursorial.UI ships a frame-aligned AnimationScheduler that freezes one clock per frame, samples active animations, and writes the result into the property system at animation priority. On top of that sit storyboards (parallel, multi-property timelines you start/stop) and implicit transitions (animate Opacity automatically when a :pointerover style flips a base value), plus reduced-motion support. That orchestration — BeginAnimation, Storyboard/Transition, AnimationHandle — is documented on the Animation & Transitions page. Everything there bottoms out in the pure IAnimation<T> primitives described here.

See also

Clone this wiki locally