background

CSS animation is one of the most familiar CSS features, and there are many tools that make it easy to use CSS animations, such as animate. The Web Animations API, which uses JS to create Animations, has also entered the Working Draft stage, but is still fairly compatible.

Prior to this, animation tools such as jQuery’s animate method and tweenmax.js were commonly used by front-end developers.

Although there are a lot of tools available for front-end animation, it’s still a fun exercise to write your own JS animation library.

Moving small box

First we draw a small gray box on the page:

<style type="text/css">
  .container {
    position: absolute;
    left: 100px;
    top: 100px;
    width: 1100px;
    height: 100px;
    border: 1px solid #eee;
  }
  .box {
    position: absolute;
    left: 0;
    top: 0;
    width: 100px;
    height: 100px;
    background: # 666;
  }
</style>
<div class="container">
  <div class="box"></div>  
</div>
Copy the code

Then write some js code:

const $box = document.querySelector('.box');

let left = 0;
const step = function() {
  left += 10;
  if (left < 1000) {
    requestAnimationFrame(step);
  }
  $box.style.left = `${left}px`;
};
requestAnimationFrame(step);
Copy the code

So our little box will move from left to right.

But there are two problems here. First, the code above does not allow the box to move uniformly. Second, the above code does not determine the duration of the animation.

Next modify the code:

const animate = function($el, obj, { duration }) {
  const current = 0;
  const keys = Object.keys(obj);
  const step = function() {
    const now = +new Date;
    const ratio = (now >= end) ? 1 : (now - start) / duration;
    keys.forEach(key => {
      const [from, to] = obj[key];
      $el.style[key] = `${from + (to - from) * ratio}px`;
    })
    if(ratio < 1) { requestAnimationFrame(step); }}; const start = +new Date; const end = start + duration; requestAnimationFrame(step); }; animate($box, { left: [0, 1000] }, { duration: 1500 });
Copy the code

In this case, users can “move the left of the small box from 0 to 1000 in 1.5” in a more conventional way.

Handle different properties

$el.style[key] =${from + (to-from) * ratio}px; This operation is not suitable for all attributes, we need to provide different handling for different attributes:

const handlerMap = {
  left: function($el, [from, to], ratio) {
    $el.style.left = `${from + (to - from) * ratio}px`;
  },
  backgroundColor: function($el, [from, to], ratio) {
    const fr = parseInt(from.substr(0, 2), 16);
    const fg = parseInt(from.substr(2, 2), 16);
    const fb = parseInt(from.substr(4, 2), 16);
    const tr = parseInt(to.substr(0, 2), 16);
    const tg = parseInt(to.substr(2, 2), 16);
    const tb = parseInt(to.substr(4, 2), 16);
    const color = [
      Math.floor((fr + (tr - fr) * ratio)).toString(16),
      Math.floor((fg + (tg - fg) * ratio)).toString(16),
      Math.floor((fb + (tb - fb) * ratio)).toString(16),
    ].join(' ');
    $el.style.backgroundColor = `#${color}`;}}; const animate =function($el, obj, { duration, cb }) {
  ...
  const step = function() {... keys.forEach(key => { const handler = handlerMap[key];if (handler) handler($el, obj[key], ratio); }) cb && cb(ratio); . }; . }; animate($box, { left: [0, 1000], backgroundColor: ['ee3366'.'99ee33'] }, { duration: 1500, cb: (r) => $box.innerText = r.toFixed(2) });
Copy the code

After the modification, the background color of our small box changed as it moved, and it also showed the progress of the current animation.

Easing and Bezier curves

Slow animation

Our little box is moving, but it would be boring to move in a linear fashion. So we need to introduce slow animation.

What is easing? Easing is a series of functions that map the time progress to the animation progress.

Here, the range of time schedule and animation schedule is [0, 1] :

  • When the time progress is 0, it means that the animation has not started, and the animation progress is also 0 at this time
  • When the time progress is 1, it indicates that the animation is completed, and the animation progress should also be 1 at this time

The above two points are self-evident. If we take the time progress as the axis and the animation progress as the vertical axis, we can draw the image corresponding to the easing function.

For example, the linear movement above, the corresponding function is y = x.

Another example is the quadratic function y = x * x, which also passes through two points [0, 0] and [1, 1]. This function is called easeInQuad in slow motion, which is a kind of function that slows first and then speeds up. The corresponding easeOutQuad is fast first and then slow, and the corresponding equation is 1 – (1-x) * (1-x).

For more common easing types, see easings.net, including staging functions like easeOutBounce.

So we added support for easing:

const Easings = {
  linear(x) {
    return x;
  },
  easeInQuad(x) {
    return x * x;
  },
  easeOutQuad(x) {
    return1 - (1 - x) * (1 - x); }}; const animate =function($el, obj, { duration, cb, easing = Easings.linear }) {
  ...
  const step = function() { const now = +new Date; const timeRatio = (now >= end) ? 1 : (now - start) / duration; const ratio = easing(timeRatio); . }; cb && cb(ratio, timeRatio);if(timeRatio < 1) requestAnimationFrame(step); . }; animate($box, { left: [0, 1000], backgroundColor: ['ee3366'.'99ee33'] }, { easing: Easings.easeInQuad, duration: 1500, cb: (r) => $box.innerText = r.toFixed(2) });
Copy the code

Bessel curve

Bezier curves are defined by control points. Bezier curves consist of more than or equal to 2 control points. The Bessel curve defined by three control points is called quadratic (quadratic) Bessel curve, while the Bessel curve defined by four control points is called cubic (cubic) Bessel curve. These are the two most common Bezier curves.

One of the properties of Bessel curves is that the curve is completely contained in the convex hull consisting of control points. Therefore, when detecting the intersection of two Bessel curves, we can try to detect the intersection of convex hull first.

Di Castelio algorithm (Javascript. The info/the bezier – curv…

The Diccastelio algorithm describes how Bessel curves are generated from control points.

The first is the Bezier curve with two control points A and B. Connect a and B, and set the variable T representing the drawing progress from 0 to 1 (100%). For example, when t=0.3, the corresponding point is the point 30% of line segment AB from A.

As t goes from zero to one, the corresponding point drawn is obviously the line segment AB.

And then we have quadratic Bezier curves. Connect ab and BC respectively, take AD/ab = be/BC = t on AB and BC respectively, connect de, and then take dp/de = t. The curve formed by the points drawn in accordance with such rules is the quadratic Bessel curve determined by control points A, B, and C.

Cubic Bezier curves and so on. For point ABcd, make point efg such that ae/ab =… = t, connect ef and fg, make points H and I so that eh/ef = Fi/fg = t, and finally connect Hi, and take point P so that HP/hi = t, where point P is the corresponding point of t.

The formula

Bessel curves can be expressed by the following formula:

  • A:P = (1-t) * P1 + t * P2
  • Second:P = (1−t)^2 * P1 + 2(1−t) * t * P2 + t^2 * P3
  • Three times:P = (1 - t) ^ 3 * (P1 + 3 (1 - t) P2 + 3 ^ 2 * t * * t ^ 2 * (1 - t) P3 + t ^ 3 * P4

P here is a vector. Corresponding to Cubic Bezier curves, the Cubic Bezier commonly used in CSS animations:

  • X = (1 - t) ^ 3 * x1 + 3 (1 - t) ^ 2 x2 + 3 * t * * t ^ 2 * (1 - t) x3 + t ^ 3 * x4
  • Y = (1 - t) ^ 3 * y1 + 3 (1) - t ^ 2 * t * y2 + 3 (1 - t) * t ^ 2 * y3 + t ^ 3 * y4

According to our usage scenario, x1 = y1 = 0 and x4 = y4 = 1.

If our goal is to draw three Bezier curves in two dimensions, the story ends there.

However, for slow animation, we need to establish the relationship between X (time progress) and Y (animation progress), not the relationship between each of them and T.

A possible but performance-consuming approach is to iteratively approximate the corresponding t for a given x and then solve for y.

The specific calculation method can refer to here. Let’s just use the code here:

const Easings = {
  ...
  cubicBezier(x1, y1, x2, y2) {
    const easing = BezierEasing(x1, y1, x2, y2);
    return function(x) {
      returneasing(x); }; }}; animate($box, { left: [0, 1000], backgroundColor: ['ee3366'.'99ee33'], {easing: easings.cubicbezier (0.5, 0, 0.5, 1), duration: 3000, cb: (r) =>$box.innerText = r.toFixed(2) });
Copy the code

Playback controls

Finally, add a bit of control logic to our animation library.

The first step is to rewrite our gadget with a class:

class Animation {
  constructor($el, obj, { duration, cb, easing = Easings.linear }) {
    this.$el = $el;
    this.obj = obj;
    this.keys = Object.keys(obj);
    this.duration = duration;
    this.cb = cb;
    this.easing = easing;
    this.aniId = 0;
  }

  play() {
    const { duration } = this;
    this.start = +new Date - this.passedTime;
    this.end = this.start + duration;
    cancelAnimationFrame(this.aniId);
    this.aniId = requestAnimationFrame(() => this.step());
  }

  step() {
    const { duration, start, end } = this;
    const now = +new Date;
    const passedTime = (now - start);
    const timeRatio = (now >= end) ? 1 : passedTime / duration;
    this.render(timeRatio); 
    if (timeRatio < 1) {
      this.aniId = requestAnimationFrame(() => this.step());
    }
  }

  render(timeRatio) {
    const { $el, obj, keys, cb, easing } = this;
    const ratio = easing(timeRatio);
    keys.forEach(key => {
      const handler = handlerMap[key];
      if (handler) handler($el, obj[key], ratio);
    });
    cb && cb(ratio, timeRatio);
  }
}

const animate = function($el, obj, opts) {
  return new Animation($el, obj, opts); }; const ani = animate(...) ; ani.play();Copy the code

Pause, continue and replay

Now add a few methods:

  • Pause: Pauses the playback
  • Resume: To resume the game from a pause (to play from the beginning)
  • Stop: Stops playing and returns to the start state of the animation

To do this, we add two properties to the animation instance: isPlaying and passedTime. The former is used to indicate the current state of play, while the latter is used to resume from the correct position:

class Animation {
  constructor($el, obj, { duration, cb, easing = Easings.linear }) {
    ...
    this.isPlaying = false;
    this.passedTime = 0;
  }

  play(reset = true) {...if (reset) this.passedTime = 0;
    this.isPlaying = true; this.start = +new Date - this.passedTime; . }step() {... const passedTime = Math.min((now - start), duration); const timeRatio = passedTime / duration; . this.passedTime = passedTime;if (this.isPlaying && timeRatio < 1) {
      ...
    } else {
      this.isPlaying = false; }}pause() {
    if(! this.isPlaying)return false;
    this.isPlaying = false;
    return true;
  }

  resume() {
    if (this.isPlaying) return false;
    this.play(false);
  }

  stop() { this.pause(); cancelAnimationFrame(this.aniId); this.render(0); }}Copy the code

The play method adds the reset argument, which defaults to true. Resume calls this.play(false) to resume from the current position.

Run backward

The isReversed attribute is added for the reverse function. Also modify the Render method so that if it is currently in reverse, the current time progress ratio is 1-timeratio. Also, in the reverse method, reverse passedTime to duration-passedTime:

class Animation {
  constructor($el, obj, { duration, cb, easing = Easings.linear }) {
    ...
    this.isReversed = false;
  }

  play(reset = true) {...if (reset) {
      this.passedTime = 0;
      this.isReversed = false; }... }... render(timeRatio) { const {$el, obj, keys, cb, easing, isReversed } = this;
    if(isReversed) timeRatio = 1 - timeRatio; . }pause() {
    if(! this.isPlaying)return false;
    if (this.isReversed) this.reverseData();
    cancelAnimationFrame(this.aniId);
    this.isPlaying = false;
    return true;
  }

  resume() {
    if (this.isPlaying) return false;
    if (this.isReversed) this.reverseData();
    this.play(false); }...reverseData() {
    this.passedTime = this.duration - this.passedTime;
    this.isReversed = true;
  }

  reverse() {
    if (this.isReversed) return false;
    this.reverseData();
    cancelAnimationFrame(this.aniId);
    this.play(false);
  }
Copy the code

So this works backwards.

summary

As a small exercise, I introduced some of the relevant content of writing a slow library, of course, if you want to develop into a complete tool, there are a lot of things to perfect, such as comprehensive property handling, animation and broadcast function, play event subscription and so on.

The resources

  • easings.net/
  • Javascript. The info/the bezier – curv…
  • Greweb. Me / 2012/02 / bez…
  • Github.com/gre/bezier-…