ReactiveUI
Animation

Animation Player

Animating a target with reusable animations: tracks, chaining, repetition and direction.

Animation players

An animated state moves a single value toward a target, while an animation player plays a prepared animation on a whole target, usually a component. That suits entrances, exits and sequences, where you want to fade something in or pop it up rather than steer a value. Play the animation on this box, or drag along the timeline under it to scrub through it:

partial class PlayerDemo : ReactiveComponent {
    private static readonly Animation<IReactiveComponent> PopIn = new(
        Track: AnimationTracks.Fade(0f, 1f)
            + AnimationTracks.Create<IReactiveComponent>(Pop),
        Duration: 1500.ms
    );

    private AnimationPlayer<IReactiveComponent> _player = null!;
    private State<float> _time = null!;
    private State<string> _toggleText = null!;

    protected override GameObject Construct() {
        var box = CreateBox();

        _time = Remember(0f);
        _toggleText = Remember("Play");
        _player = RememberAnimationPlayer<IReactiveComponent>(box);

        // Starts at the end, with the box in place, until you press Play
        _player.Play(PopIn);
        _player.Complete();

        return CreateLayout(
            box: box,
            timeline: CreateTimeline(
                time: _time,
                duration: PopIn.Duration.Seconds,
                onScrub: x => PauseAt(x * PopIn.Duration.Seconds)
            ),
            buttons: [
                new BsTextButton {
                    sText = _toggleText,
                    OnClick = Toggle
                },

                CreateButton(
                    text: "Reset",
                    onClick: () => PauseAt(0f)
                ),

                CreateButton(
                    text: "Complete",
                    onClick: () => _player.Complete()
                )
            ]
        );
    }

    // Holds the animation at a time in seconds. A completed animation is
    // gone, so it's loaded again first
    private void PauseAt(float time) {
        if (_player.Animation == null) {
            _player.Play(PopIn);
        }

        _player.Pause();
        _player.Seek(time);
    }

    // Pauses, resumes, or plays again once the animation has completed
    private void Toggle() {
        if (_player.IsPlaying) {
            _player.Pause();
        } else if (_player.Animation == null) {
            _player.Play(PopIn);
        } else {
            _player.Resume();
        }
    }

    // The timeline and the toggle follow the player
    protected override void OnUpdate() {
        _time.Value = _player.Time / PopIn.Duration.Seconds;

        _toggleText.Value = _player.IsPlaying ? "Pause"
            : _player.Animation != null && _player.Time > 0f ? "Resume"
            : "Play";
    }

    // Pops up with an overshoot and wobbles until it rests
    private static void Pop(IReactiveComponent box, float t) {
        var scale = AnimationCurve.EaseOutBack.Evaluate(t);
        var angle = Mathf.Sin(t * Mathf.PI * 4f) * (1f - t) * 20f;

        box.ContentTransform.localScale =
            Vector3.one * Mathf.LerpUnclamped(0.3f, 1f, scale);
        box.ContentTransform.localEulerAngles = new(0f, 0f, angle);
    }
}

The box fades in, pops up past its size and wobbles until it rests, while the playhead runs along the timeline. The first button pauses and resumes it, Reset holds it at the start, and Complete jumps to the end. Dragging the timeline pauses the player and seeks to that point, so you can scrub back and forth and resume from wherever you let go.

Each animation is an Animation<T>, a record with a track and a duration, plus an optional curve, iteration count and direction. It never changes, so it can live in a static field and be shared by any number of players. The player comes from RememberAnimationPlayer, which takes a target that already exists and attaches the player to your component, so it only runs while the component is active and stops when it's destroyed. The player itself has these members:

  • Play starts an animation from the beginning and takes a callback for when it finishes.
  • Pause holds the animation where it is, and Resume carries on from there.
  • Seek jumps to a time in seconds, even while the animation is paused.
  • Complete jumps to the end and runs the finish callback.
  • Cancel drops the animation where it is, without running the callback.
  • IsPlaying tells whether the animation is moving, and Time how far it has got.
  • Animation is the one that's loaded, and Iteration the run it's on, counting from 0.

Calling Play again inside that finish callback chains two animations one after the other.

Tracks

A track is what an animation does to its target as the progress goes from 0 to 1, and it has a single method, Apply(target, progress). AnimationTracks has ready tracks for fading and scaling, + runs two tracks together, and Create turns any function into a track, as these three buttons show:

partial class TracksDemo : ReactiveComponent {
    // Two tracks at once
    private static readonly Animation<IReactiveComponent> Pop = new(
        Track: AnimationTracks.Scale(0.4f, 1f)
            + AnimationTracks.Fade(0.3f, 1f),
        Duration: 450.ms,
        Curve: AnimationCurve.EaseOutBack
    );

    // Any function of the target and the progress
    private static readonly Animation<IReactiveComponent> Spin = new(
        Track: AnimationTracks.Create<IReactiveComponent>((x, t) => {
            x.ContentTransform.localEulerAngles = new(0f, 0f, t * 360f);
        }),
        Duration: 700.ms,
        Curve: AnimationCurve.EaseInOutCubic
    );

    private static readonly Animation<IReactiveComponent> Wobble = new(
        Track: AnimationTracks.Create<IReactiveComponent>((x, t) => {
            var angle = Mathf.Sin(t * Mathf.PI * 6f) * (1f - t) * 20f;
            x.ContentTransform.localEulerAngles = new(0f, 0f, angle);
        }),
        Duration: 600.ms
    );

    protected override GameObject Construct() {
        var box = CreateBox();

        // Runs while this component is active
        var player = RememberAnimationPlayer<IReactiveComponent>(box);

        return CreateLayout(
            box: box,
            buttons: [
                CreateButton(
                    text: "Pop",
                    onClick: () => player.Play(Pop)
                ),

                CreateButton(
                    text: "Spin",
                    onClick: () => player.Play(Spin)
                ),

                CreateButton(
                    text: "Wobble",
                    onClick: () => player.Play(Wobble)
                )
            ]
        );
    }
}

Pop scales and fades at once, and it overshoots a little because its curve pushes the progress past 1 before the track gets it. Spin and Wobble are made with Create, so they can change anything on the target, here its rotation. The fade goes through a CanvasGroup, so fading a component fades everything inside it as well.

A track made for one type can also be pointed at a part of another with For. Since the fade works on any component, it can fade only a card's icon while the card stays the player's target:

// The card is the player's target, and the icon is what fades
var fadeIcon = AnimationTracks.Fade(0f, 1f).For((Card x) => x.Icon);

The selector runs every time the track is applied, which is every frame while the animation plays, so it should only pick a part the target already has. Looking up a child or creating something there would repeat that work each frame.

Repeating and direction

Iterations sets how many times an animation plays. It takes a whole number, a fraction like 2.2f that stops partway through the last run, or AnimationIteration.Infinite to repeat until stopped. Since Animation is a record, with makes a copy with a different count and leaves the shared one alone, so every button below plays the same beat:

partial class RepeatingDemo : ReactiveComponent {
    private static readonly Animation<IReactiveComponent> Beat = new(
        Track: AnimationTracks.Scale(1f, 1.4f),
        Duration: 500.ms,
        Curve: AnimationCurve.EaseOutCubic
    );

    private AnimationPlayer<IReactiveComponent> _player = null!;
    private State<string> _status = null!;

    protected override GameObject Construct() {
        var box = CreateBox();

        _player = RememberAnimationPlayer<IReactiveComponent>(box);
        _status = Remember("not playing");

        return CreateLayout(
            box: box,
            status: _status,
            iterations: [
                CreateButton(
                    text: "Once",
                    onClick: () => Play(1)
                ),

                CreateButton(
                    text: "3 times",
                    onClick: () => Play(3)
                ),

                CreateButton(
                    text: "2.2 times",
                    onClick: () => Play(2.2f)
                ),

                CreateButton(
                    text: "Forever",
                    onClick: () => Play(AnimationIteration.Infinite)
                )
            ],
            controls: [
                CreateButton(
                    text: "Cancel",
                    onClick: () => _player.Cancel()
                ),

                CreateButton(
                    text: "Complete",
                    onClick: () => _player.Complete()
                )
            ]
        );
    }

    // A variant of the shared animation, which stays as it was
    private void Play(AnimationIteration iterations) {
        _player.Play(Beat with { Iterations = iterations });
    }

    protected override void OnUpdate() {
        _status.Value = _player.IsPlaying
            ? $"iteration {_player.Iteration}"
            : "not playing";
    }
}

The label counts the runs from 0 while the box beats, and 2.2 times stops partway through the third beat. Pressing Complete during Forever completes only the beat in progress, since an endless animation has no end to jump to.

Direction works as in CSS and decides which way each run plays:

  • Normal plays every run forward.
  • Reverse plays every run backward.
  • Alternate flips every other run, so a repeat continues from where the last one ended.
  • AlternateReverse does the same but starts backward.

Here all four play the same animation side by side:

partial class DirectionDemo : ReactiveComponent {
    // The player's target is a plain state, which the dot's position follows
    private static readonly IAnimationTrack<State<float>> Move =
        AnimationTracks.Create<State<float>>((x, t) => x.Value = t);

    private readonly List<(
        AnimationPlayer<State<float>> Player,
        AnimationDirection Direction
    )> _rows = new();

    protected override GameObject Construct() {
        return CreateLayout(
            buttons: [
                CreateButton(
                    text: "Play 3 times",
                    onClick: () => Play(3)
                ),

                CreateButton(
                    text: "Loop",
                    onClick: () => Play(AnimationIteration.Infinite)
                ),

                CreateButton(
                    text: "Cancel",
                    onClick: () => _rows.ForEach(x => x.Player.Cancel())
                )
            ],
            rows: [
                CreateRow(AnimationDirection.Normal),
                CreateRow(AnimationDirection.Reverse),
                CreateRow(AnimationDirection.Alternate),
                CreateRow(AnimationDirection.AlternateReverse)
            ]
        );
    }

    private Layout CreateRow(AnimationDirection direction) {
        var position = Remember(0f);
        _rows.Add((RememberAnimationPlayer(position), direction));

        return CreateRowLayout(
            name: direction.ToString(),
            position: position
        );
    }

    private void Play(AnimationIteration iterations) {
        foreach (var (player, direction) in _rows) {
            player.Play(new(
                Track: Move,
                Duration: 700.ms,
                Curve: AnimationCurve.EaseInOutSine,
                Iterations: iterations,
                Direction: direction
            ));
        }
    }
}

With Loop, the two alternating dots swing back and forth, while the other two jump back to their start after every run. The curve goes over the directed progress, so a reversed ease-in plays like an ease-out, the same as on the web.

On this page