ReactiveUI
Animation

Animated State

States that move towards a target over time, how fast and along what curve, and how to animate any type.

Animated states

An animated state moves toward its target instead of jumping to it, and everything bound to it glides along without knowing it's animated. Hover this card to see a color and a scale do that:

var color = RememberAnimated(idle, 250.ms, AnimationCurve.EaseOutCubic);
var scale = RememberAnimated(1f, 300.ms, AnimationCurve.EaseOutBack);

new Background {
    ContentTransform = {
        slocalScale = scale.Map(x => Vector3.one * x)
    },

    sColor = color,

    Do = x => x.WithPointerEvents(
        onEnter: _ => {
            color.TargetValue = hovered;
            scale.TargetValue = 1.15f;
        },
        onLeave: _ => {
            color.TargetValue = idle;
            scale.TargetValue = 1f;
        }
    )
}

Move off the card while it's still growing, and it shrinks back from where it got to, not from full size. That's because setting TargetValue starts the animation from the current value, so stopping one halfway always looks smooth. While it moves, the state updates its bindings every frame like any other state, and setting the same target twice does nothing.

A few more members help around the start and end of an animation:

  • SetValueImmediate jumps to a value without animating.
  • IsFinished tells whether the value has reached its target.
  • OnStart runs when an animation starts from rest, and OnFinish when it arrives.
  • Progress is how far the current animation has got after the curve, usually from 0 to 1.
  • Timing and Curve can be changed after the state is created.

An animated state needs to move every frame, so RememberAnimated attaches it to your component as a module. Because of that, it only runs while the component is active and stops when the component is destroyed, with no code of yours. The one thing to watch is that the state belongs to the component that called RememberAnimated, so if that component isn't on screen, the animation never moves.

Timing and curves

Timing decides how the value moves toward its target, and there are two types of it:

A duration, written 400.ms or 1.5f.sec, reaches the target in exactly that time along the curve, and every new target restarts the clock. That suits things that happen once, like a hover, a panel opening or a toast leaving. Driven by something that changes all the time, like a dragged slider, it restarts on every change and never quite catches up, which looks like lag.

A factor, written 8.fact, closes part of the remaining distance every frame, so the value is fast when far from the target and slow when close, and the factor sets the pace at any frame rate. A new target just sends the value somewhere else from wherever it is, so a factor suits values that change all the time, like a scroll position or anything following a slider. Drag the slider below and compare the two:

var timed = RememberAnimated(0f, 400.ms, AnimationCurve.EaseInOutCubic);
var factored = RememberAnimated(0f, 8.fact);

target.AddCallback(x => {
    timed.TargetValue = x;
    factored.TargetValue = x;
});

While you drag, the factor dot stays close behind the slider, and the duration dot keeps starting over and lags. That restart is also where the curve, the optional third argument, comes in, since it decides how a duration spreads its progress over time. Leave it out with a factor, because a curve would start its easing over on every new target. Every dot below gets the same target and duration, so only the curve differs:

(string, AnimationCurve)[] curves = [
    ("Linear", AnimationCurve.Linear),
    ("EaseInQuad", AnimationCurve.EaseInQuad),
    ("EaseOutCubic", AnimationCurve.EaseOutCubic),
    ("EaseInOutSine", AnimationCurve.EaseInOutSine),
    ("EaseOutBack", AnimationCurve.EaseOutBack)
];

foreach (var (name, curve) in curves) {
    var dot = RememberAnimated(0f, 1000.ms, curve);
    tracks.Children.Add(CreateTrack(
        name: name,
        progress: dot,
        labelWidth: 22f
    ));
}

Press Play, and the dots arrive together but get there differently. Linear keeps a steady pace, while EaseOutBack overshoots the end and settles back. These are only a few of the curves on AnimationCurve, which also has the easing families you may know from CSS, like Quad, Cubic, Sine and Back.

For a curve of your own, subclass AnimationCurve and override Evaluate, which turns how much of the time has passed into how far the value has got. This one bounces off the bottom like a dropped ball:

class BounceCurve : AnimationCurve {
    // Takes how much of the time has passed and returns how far the
    // value has got, both from 0 to 1
    public override float Evaluate(float x) {
        const float n = 7.5625f;
        const float d = 2.75f;

        if (x < 1f / d) {
            return n * x * x;
        }
        if (x < 2f / d) {
            x -= 1.5f / d;
            return n * x * x + 0.75f;
        }
        if (x < 2.5f / d) {
            x -= 2.25f / d;
            return n * x * x + 0.9375f;
        }

        x -= 2.625f / d;
        return n * x * x + 0.984375f;
    }
}

var fall = RememberAnimated(0f, 1200.ms, new BounceCurve());

Each drop falls, hits the bottom and bounces a few times before it rests. The curve is the only new part, since the state, the timing and the binding work as before, and SetValueImmediate only puts the ball back at the top first. A curve shaped like a CSS cubic-bezier doesn't need a class at all, because BezierCurve takes its four numbers.

Interpolators

An interpolator decides how an animated state blends between two values. RememberAnimated already has one for float, Vector2, Vector3 and Color, and for any other type you pass your own, so anything you can blend can be animated. Your own needs two methods:

  • Lerp returns the value at a given point between the start and the target. That point can go below 0 or past 1 on curves that overshoot, so use Unity's unclamped lerps unless you want to cut the overshoot off.
  • Equals tells whether two values are the same, so the state can skip a target it already has.

The usual reason to write one is color, since the default blends in RGB and turns a muddy gray between opposite hues. Press Swap to compare it with one that goes around the hue circle instead:

class HsvInterpolator : ValueInterpolator<HsvInterpolator, Color> {
    public override Color Lerp(Color from, Color to, float t) {
        Color.RGBToHSV(from, out var h1, out var s1, out var v1);
        Color.RGBToHSV(to, out var h2, out var s2, out var v2);

        // Wrapped into half a turn either way, so the hue takes the
        // short way round
        var hueDelta = Wrap(h2 - h1 + 0.5f) - 0.5f;

        return Color.HSVToRGB(
            Wrap(h1 + hueDelta * t),
            Mathf.LerpUnclamped(s1, s2, t),
            Mathf.LerpUnclamped(v1, v2, t)
        );
    }

    public override bool Equals(Color x, Color y) => x == y;

    // Keeps a hue between 0 and 1
    private static float Wrap(float x) => x - Mathf.Floor(x);
}

var color = RememberAnimated(Color.red, HsvInterpolator.Instance, 600.ms);

Halfway through, the RGB swatch turns gray, while the HSV one stays bright. The hue difference is wrapped into half a turn either way, so the hue always takes the shorter way around. Deriving from ValueInterpolator gives you a shared Instance, so only Lerp and Equals are left to write. The same approach works for a Quaternion, a YogaVector for an animated size, or a struct of your own.

On this page