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:
Playstarts an animation from the beginning and takes a callback for when it finishes.Pauseholds the animation where it is, andResumecarries on from there.Seekjumps to a time in seconds, even while the animation is paused.Completejumps to the end and runs the finish callback.Canceldrops the animation where it is, without running the callback.IsPlayingtells whether the animation is moving, andTimehow far it has got.Animationis the one that's loaded, andIterationthe 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:
Normalplays every run forward.Reverseplays every run backward.Alternateflips every other run, so a repeat continues from where the last one ended.AlternateReversedoes 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.