I show off minanim.js
,
a tiny, 100LoC, yet feature-complete library for building animations declaratively,
and why someone would want to do things this way. Enjoy!
The blue circle's animation is quite complex. It consists of multiple
stages. (1) The circle grows in size. (2) It continues to grow in size
at a faster rate, as it shoots off to the right. (3) It pauses. (4) It
moves to the middle. (5) It pauses again. (6) It shrinks to nothing.
All of this is captured by a single object anim_circle (written using minanim.js)
which declares what the animation is doing:
01:
02: // cx = location | cr = radius
03: let anim_circle = anim_const("cx", 100)
04: .seq(anim_const("cr", 0))
05: // (1) grow in size.
06: .seq(anim_interpolated(ease_cubic, "cr", /*val=*/10, /*time=*/3))
07: // (2) go to right while growing.
08: .seq(anim_interpolated(ease_cubic, "cx", /*val=*/300, /*time=*/1)
09: .par(anim_interpolated(ease_cubic, "cr", 70, 1)))
10: // (3) pause.
11: .seq(anim_delay(/*time=*/3))
12: // (4) come back to the left.
13: .seq(anim_interpolated(ease_cubic, "cx", 100, 1))
14: // (5) pause again.
15: .seq(anim_delay(/*time=*/2))
16: // (6) shrink to nothing.
17: .seq(anim_interpolated(ease_cubic, "cr", 0, 1));
18:
The entire animation is built out of one primitive and three combinators:
anim_const(name, val) to set a constant value val to name name.
anim_interpolated(ease, name, val, time) to change to a named value with name name
to value val in duration time.
anim1.seq(anim2) to run anim2 once anim1 has completed.
anim1.par(anim2) to run anim2 in parallel with anim1.
anim_circle is a function, which can be invoked as val = anim_circle(t).
It returns an object val. val.cx and val.cr have values as the animation dictates.
That's it. It does not modify the DOM. It does not edit thecircle tag.
Given a time t0, it computes cx and cr at time t0. Keep it simple, stupid!
Here is a plot of the values of val.cx and val.cr for different values of t.
This plotting code calls anim_circle at different times to plot the
results. The function anim_circleis these plots,
since it doesn't compute anything else.
Fancy ways of saying that anim_circle doesn't change anything else is to say that it is side-effect-free, or refrentially transparent.
§ Playing with this Webpage: edit anim_circle in the browser!
The code that's been copied onto your clipboard is:
01:
02: // cx = location | cr = radius
03: anim_circle = anim_const("cx", 100)
04: .seq(anim_const("cr", 0))
05: // (1) grow in size.
06: .seq(anim_interpolated(ease_cubic, "cr", /*val=*/10, /*time=*/3))
07: // (2) go to right while growing.
08: .seq(anim_interpolated(ease_cubic, "cx", /*val=*/300, /*time=*/1)
09: .par(anim_interpolated(ease_cubic, "cr", 70, 1)))
10: // (3) shrink to nothing.
11: .seq(anim_interpolated(ease_cubic, "cr", 0, 1)); plot()
12:
You can explore different definitions anim_circles. Feel free to
play around. Try evaluating anim_circle(0), anim_circle(anim_circle.duration),
anim_circle(anim_circle.duration/2.0) in the console to get a feel for what
anim_circle returns.
As hinted above, since our specification of the animation was entirely declarative,
it can't really "do anything else" like manipulate the DOM. This gives us
fantastic debugging and editing capabilities. As it's "just" a mathematical
function:
We can easily swap it (by pasting the code above), poke it (by calling anim_circle(0.5)),
and in general deal with is as a unit of thought. It has no unpleasant
interactions with the rest of the world.
Due to this purity, we also get time-travel-debugging. The slider is hooked up
to anim_circle, and displays the circle as dictated by anim_circle(t_slider).
t_slider is received from the slider.
Drag the slider to move through the animation!
Our framework is composable, because we can build larger objects from smaller
objects in a natural way. As an example, a staggered animation is a nice
way to make the entry of multiple objects feel less monotonous.
The code to achieve this creates a list of animations called as which
has the animations of the ball rising up. Each element as[i] has
the animation of the ball rising up for the same amount of time. This is
visualized here:
Next, each element as[i] is modified by creating a new animation xs[i].
xs[i] runs as[i] after a delay of delta*i.
We then compose all the xs[i] in parallel to create a single animation x.
This animation has the balls rising from the bottom in a staggered fashion.
Next, we similarly create an array of animations called bs which
has animations of the balls disappearing. These are staggered as before.
This is shown here:
Finally, we compose x and y, such that y is staggered relative to x
by some delay. This allows the first few balls to start disappearing
while new balls continue entering.
Notice that the final animation network is quite complex. It's hopeless
to build it "manually". In code, we write special helpers
called anim_stagger that allow us to stagger animation, and then use
it, along with .seq() and .par() to build the full animation:
As hinted above, since our specification of the animation was entirely declarative,
it can't really "do anything else" like manipulate the DOM. This gives us
fantastic debugging and editing capabilities. As it's "just" a mathematical
function:
so we can play with it on the console, edit it interactively, and plot it.
It's behaviour can be studied on a piece of paper, since it's entirely
decoupled from the real world.
So far, we have been using the same easing parameter everywhere:
easing_cubic. This parameter is a way to warp time. We only tell the
library what the final value is supposed to be. It's our library's job
to figure out how to get from the current value to the final value. However,
there are many ways to get from the initial value to the final value. We
could:
Change the value in constant increments. This is what easing_linear does.
Change the value so that it changes slowly in the beginning, and much
faster later. This is what easing_cubic does.
Change the value so that it changes quickly, overshoots, and then
comes back to the final value. This is what ease_out_back does.
There are many easing functions. Indeed, infinitely many, since we can write
any function we want. A quick example of the three mentioned above, with
a slide to notice the difference:
Drag the slider to move through the animation!
Both d3.js and anime.js are libraries that intertwine
computing with animation. On the other hand, our implementation describes
only how values change. It's up to us to render this using
SVG/canvas/what-have-you.
Building a layer like anime.js on top of this is not hard. On the other hand,
using anime.js purely is impossible.
The entire "library", which is written very defensively and sprinkled with
asserts fits in exactly 100 lines of code
. It can be golfed further
at the expense of either asserts, clarity, or by adding some higher-order
functions that factor out some common work. I was loath to do any of these.
So here's the full source code, explained as we go on.
We write assert_precondition(t, out, tstart) to check that t
and tstart are numbers such that t >= tstart, and that out is an object.
If tstart is uninitialized, we initialize tstart to 0. If
out is uninitialized, we initialize out to {}.
anim_delay(duration) creates a function f. On being invoked, it returns
whatever value of out has been given to it. That is, it doesn't
modify anything. It has three fields, duration, par, and seq.
duration. duration is how long the animation runs for. par, seq
are methods for chaining, that allows us to compose this delay animation
in parallel and in sequence with other animations.
const(field, v) creates a function f. On being invoked, it sets
out[field] = v. It takes zero time to run such an animation, hence it's
duration is 0. Useful for instaneously setting the value at the
start of an animation.
We implement two easing functions, which takes a parameter
tlin such that 0 <= tlin <= 1, and two parameters vstart and vend.
The functions allow us to animate a change from vstart to vend smoothly.
We are to imagine tlin as a time. When tlin=0, we are at vstart.
When tlin=1, we will be at tend.
In between, we want values between vstart and vend. To animate values,
we often want the change from vstart to vend to happen a certain way.
For example, we often want the change to start slowly, and then for the
change to happen faster towards the end. A good reference for this is
easings.net
. Our animation library can use
any easing function we see fit.
anim_interpolated(duration) creates a function f. On being invoked,
it figures out if its animation is running or has already ended.
We have a preconditiont >= tstart, which is checked by
assert_precondition, and is maintained by the library.
So, we only need to care whether the animation is currently running
or has ended. If the animation is currently running, we find the
current value using fease. If the animation has ended, we set
the value to the end value.
anim_sequence(anim1, anim2) sets up anim2 to begin
running once anim1 has completed. When it is invoked, t >= tstart. So
it can run anim1 immediately. If it learns that
anim1 has completed, it then invokes anim2. The total time taken for
this animation is its duration. This is the sum of durations of anim1
and anim2.
anim_parallel(anim1, anim2) sets up anim1 and
anim2 to run in parallel. When it is invoked, t >= tstart. So it can
launch anim1, anim2 both immediately. The duration of this animation
is the maximum time taken by anim1, anim2.
anim_parallel_list(xs) is a helpful to write the animations
in xs in parallel. It chains together the elements of the list
with par calls.
1:
2: // xs: list[animation]
3: function anim_parallel_list(xs) {
4: var x = xs[0]; for(var i = 1; i < xs.length; ++i) { x = x.par(xs[i]); }
5: return x;
6: }
7:
anim_stagger(xs, delta) is a combinator to stagger the animations in
the list of animations xs. It
delays the animation at xs[i] for a duration delta*i.
01:
02: // xs: list[animation]. delta: duration
03: function anim_stagger(xs, delta) {
04: console.assert(typeof(delta) == "number");
05: var ys = [];
06: for(var i = 0; i < xs.length; ++i) {
07: ys.push(anim_delay(delta*i).seq(xs[i]));
08: }
09: var y = ys[0];
10: for(var i = 1; i < ys.length; ++i) {
11: y = y.par(ys[i]);
12: }
13: return y;
14: }
15:
We saw how to write a tiny, declarative, composable animation library that
does one thing: compose functions that manipulate values over time,
and does it well.
If you like this content, check out the repo at
bollu/mathemagic