-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
The workhorse is Animation<T>: ease from → to 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 QuadOutFrom, To, Easing, and Duration are all readable on the instance. A zero-length Duration snaps
straight to To.
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).
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
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);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.
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>()throwsInvalidOperationExceptionwith a helpful message if nothing is registered forT. -
ForOrNull<T>()returnsnullinstead.
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());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.
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()reportDuration == TimeSpan.MaxValue— a perpetual animation that never settles;ValueAtwraps 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'sDurationbecomesTimeSpan.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.
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.
-
Animation & Transitions — the
Cursorial.UIscheduler, storyboards, and transitions -
Drawing: Scenes, Brushes & Charts — the layer that registers
PointD/Rect/IBrush/Peninterpolators -
Core Output — the
ColorandStylevalue types animations blend - Architecture — how the layers stack
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Declarative