2021年12月15日

JavaScript アニメーション

JavaScript アニメーションは CSS ではできないことを扱うことができます。

例えば、ベジェ曲線とは異なるタイミング関数を用いて複雑な経路に沿って移動したり、canvas 上でのアニメーションなどです。

setInterval

HTML/CSS の観点からは、アニメーションはスタイルプロパティの段階的な変更です。例えば、style.left0px から 100px に変更すると、要素が移動します。

そして、もしそれを setInterval の中で増加させるとき、毎秒 50 回の小さな変更を加えることによって、その変化はなめらかに見えます。これは映画館と同じ原理です。: 毎秒 24 以上のフレームがあれば十分に滑らかに見えます。

疑似コードは次のようになります:

let delay = 1000 / 50; // 1 秒で 50 フレーム
let timer = setInterval(function() {
  if (animation complete) clearInterval(timer);
  else increase style.left
}, delay)

より複雑なアニメーションの例:

let start = Date.now(); // 開始時間を覚える

let timer = setInterval(function() {
  // 開始からの経過時間は?
  let timePassed = Date.now() - start;

  if (timePassed >= 2000) {
    clearInterval(timer); // 2秒後にアニメーションが終了
    return;
  }

  // timePassed 時点のアニメーションを描画
  draw(timePassed);

}, 20);

// timePassed は 0 から 2000 まで進む
// なので、left は 0px から 400px になります
function draw(timePassed) {
  train.style.left = timePassed / 5 + 'px';
}

デモです。電車をクリックしてみてください:

結果
index.html
<!DOCTYPE HTML>
<html>

<head>
  <style>
    #train {
      position: relative;
      cursor: pointer;
    }
  </style>
</head>

<body>

  <img id="train" src="https://js.cx/clipart/train.gif">


  <script>
    train.onclick = function() {
      let start = Date.now();

      let timer = setInterval(function() {
        let timePassed = Date.now() - start;

        train.style.left = timePassed / 5 + 'px';

        if (timePassed > 2000) clearInterval(timer);

      }, 20);
    }
  </script>


</body>

</html>

requestAnimationFrame

複数のアニメーションが同時に実行されているとしましょう。

もしそれらを別々に実行し、それぞれが個別に setInterval(..., 20) を持っていると、ブラウザは 20ms 間隔よりもっと頻繁に再描画をする必要があります。

setInterval20ms 毎に一回トリガしますが、独立しているので 20ms の中に複数の独立した実行があることになります。

これらの複数の独立した再描画は、ブラウザの再描画を簡単にし、CPUの負荷を減らしてよりなめらかに見せるためにグループ化すべきです。

言い換えると、次のコード:

setInterval(function() {
  animate1();
  animate2();
  animate3();
}, 20)

…は以下のコードよりも軽量です:

setInterval(animate1, 20);
setInterval(animate2, 20);
setInterval(animate3, 20);

心に留めておくべきことがもう一つあります。CPUが過負荷になっている場合や、その他再描画をあまりしなくて良い場合があります(ブラウザタブが非表示になっているようなとき)。そのため、本当は20ms毎に実行すべきではありません。

しかし、それを JavaScript ではどうやってしるのでしょう? 関数 requestAnimationFrame を提供する標準の アニメーションタイミング があります。この関数は、これらすべての問題及び、その他多くのことに対応しています。

構文:

let requestId = requestAnimationFrame(callback)

これは、ブラウザがアニメーションをしたい最も近い時間に callback 関数を実行するようスケジューリングします。

もし callback の中で要素を変更すると、他の requestAnimationFrame コールバックや CSS アニメーションと一緒にグループ化されます。これにより、配置の再計算と再描画がそれぞれではなく1回でまとめて行われます。

返却値 requestId は呼び出しをキャンセルするのに使うことができます:

// スケジューリングされたコールバックの実行をキャンセルする
cancelAnimationFrame(requestId);

callback は1つの引数を取ります – ページロードの開始からの経過時間のマイクロ秒です。 この時間は performance.now() を呼び出すことでも得ることができます。

通常 callback は CPU が過負荷状態になったり、ノートPCのバッテリーがほとんどなかったり、その他別の理由がある場合を除きすぐに実行されます。

下のコードは requestAnimationFrame での最初の10回の実行時間を表示します。通常は 10-20ms です。

<script>
  let prev = performance.now();
  let times = 0;

  requestAnimationFrame(function measure(time) {
    document.body.insertAdjacentHTML("beforeEnd", Math.floor(time - prev) + " ");
    prev = time;

    if (times++ < 10) requestAnimationFrame(measure);
  })
</script>

Structured animation

これで、requestAnimationFrame に基づいた、様々な状況に対応することのできるアニメーション関数を作成することができます。

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction は 0 から 1 になります
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // 現在のアニメーションの状態を計算します
    let progress = timing(timeFraction)

    draw(progress); // 描画します

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

関数 animate はアニメーションを記述するための3つのパラメータを受け付けます。:

duration

アニメーションのトータルの時間。例: 1000

timing(timeFraction)

経過時間(開始時: 0, 終了時: 1)を基に、アニメーションの完了(ベジェ曲線の y のような) を返す、CSS プロパティ transition-timing-function のようなタイミング関数です。

例えば、線形関数はアニメーションが同じスピードで均一に進むことを意味します。:

function linear(timeFraction) {
  return timeFraction;
}

グラフはこのようになります:

これは transition-timing-function: linear のようなものです。下にあるようなより興味深いケースがあります。

draw(progress)

アニメーションの完了状態を取り、描画を行う関数です。値 progress=0 はアニメーションの開始状態を示し、progress=1 は終了状態を示します。

これは実際にアニメーションを描画する関数です。

要素が移動します:

function draw(progress) {
  train.style.left = progress + 'px';
}

…または、他のことを行うことで、どんな方法でも何でもアニメーションさせることができます。

この関数を使って、要素の width0 から 100% までアニメーションさせてみましょう。

デモ内の要素をクリックしてください:

結果
animate.js
index.html
function animate({duration, draw, timing}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    let progress = timing(timeFraction)

    draw(progress);

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <style>
    progress {
      width: 5%;
    }
  </style>
  <script src="animate.js"></script>
</head>

<body>


  <progress id="elem"></progress>

  <script>
    elem.onclick = function() {
      animate({
        duration: 1000,
        timing: function(timeFraction) {
          return timeFraction;
        },
        draw: function(progress) {
          elem.style.width = progress * 100 + '%';
        }
      });
    };
  </script>


</body>

</html>

コードは次の通りです:

animate({
  duration: 1000,
  timing(timeFraction) {
    return timeFraction;
  },
  draw(progress) {
    elem.style.width = progress * 100 + '%';
  }
});

CSS アニメーションとは異なり、任意のタイミング関数や描画関数を作ることができます。タイミング関数はベジェ曲線には制限されません。そして draw はプロパティを超えて、花火のアニメーションといった新しい要素を作成することもできます。

タイミング関数

上記で最もシンプルな線形のタイミング関数を見ました。

他のものも見てみましょう。様々なタイミング関数でのアニメーションを試して、どのように動くのかを確認してみます。

Power of n(n のべき乗)

アニメーションをスピードアップさせたい場合には、n のべき乗で progress を使います。

例えば、放物曲線:

function quad(timeFraction) {
  return Math.pow(timeFraction, 2)
}

グラフ:

動作を見る(クリックして有効化):

…または、3次曲線のような n がより大きい場合。n を増やすことでより速度が上がります。

これは、べき乗 5 での progress のグラフです。:

動作を見る:

円弧

関数:

function circ(timeFraction) {
  return 1 - Math.sin(Math.acos(timeFraction));
}

グラフ:

戻る: 弓

この関数は “弓の射撃” を行います。最初に “弦を引き”、次に “撃ちます”。

前の関数とは異なり、追加のパラメータ x, “弾性係数” に依存します。“弦を引く” 距離はこれにより定義されます。

コード:

function back(x, timeFraction) {
  return Math.pow(timeFraction, 2) * ((x + 1) * timeFraction - x)
}

x = 1.5 の場合のグラフ:

アニメーションの場合、特定の x の値で使用します。これは x = 1.5 の例です:

バウンド

ボールを落としたと想像してください。それは落ちて後何度か跳ね返ってから停止します。

bounce 関数はそれと同じことをしますが、始まる順序は逆です。なお、このために必要な特別な係数はほとんどありません。:

function bounce(timeFraction) {
  for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
    if (timeFraction >= (7 - 4 * a) / 11) {
      return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
    }
  }
}

動作を見る:

弾性のあるアニメーション

“初期範囲” 用の追加パラメータ x を受け取るもう一つの “弾む” 関数です。

function elastic(x, timeFraction) {
  return Math.pow(2, 10 * (timeFraction - 1)) * Math.cos(20 * Math.PI * x / 3 * timeFraction)
}

x=1.5 のグラフです:

x=1.5 の場合の動作:

Reversal: ease*

ここまでで様々なタイミング関数があります。これらは “easeIn” と呼ばれます。

アニメーションを逆の順序で表示する必要があることがあります。これは、“easeOut” 変換で行います。

easeOut

“easeOut” モードでは、timing 関数はラッパー timingEaseOut の中に配置されます。

timingEaseOut(timeFraction) = 1 - timing(1 - timeFraction)

つまり、“通常の” タイミング関数を取り、“そのラッパーを返す” “変換” 関数 makeEaseOut を使用します。:

// タイミング関数を引数とし、変換したものを返す
function makeEaseOut(timing) {
  return function(timeFraction) {
    return 1 - timing(1 - timeFraction);
  }
}

例えば、上述の bounce 関数に対して適用してみます:

let bounceEaseOut = makeEaseOut(bounce);

すると、最初ではなくアニメーションの最後にバウンドするようになります。より自然にみえます。:

結果
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseOut(timing) {
      return function(timeFraction) {
        return 1 - timing(1 - timeFraction);
      }
    }

    function bounce(timeFraction) {
      for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }

    let bounceEaseOut = makeEaseOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

ここでは、変換関数がどのように元の関数の挙動を変化させたのかが確認できます:

跳ね返るような、アニメーションが最初にある場合には、それは最後似表示されます。

上のグラフでは 通常のバウンド は赤色、easeOut のバウンド は青色です。

  • 通常のバウンド: 物体は下の方で跳ね、最後に急激に跳ね上がります。
  • easeOut : 最初に上に大きく跳ねてからバウンドします

easeInOut

アニメーションの最初と最後両方でこの効果を見せることもできます。このトランジションは “easeInOut” と呼ばれます。

タイミング関数が与えられると、次のようにアニメーションの状態を算出します:

if (timeFraction <= 0.5) { // アニメーションの前半
  return timing(2 * timeFraction) / 2;
} else { // アニメーションの後半
  return (2 - timing(2 * (1 - timeFraction))) / 2;
}

このラッパーコードです:

function makeEaseInOut(timing) {
  return function(timeFraction) {
    if (timeFraction < .5)
      return timing(2 * timeFraction) / 2;
    else
      return (2 - timing(2 * (1 - timeFraction))) / 2;
  }
}

bounceEaseInOut = makeEaseInOut(bounce);

bounceEaseInOut の動作を見る:

結果
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseInOut(timing) {
      return function(timeFraction) {
        if (timeFraction < .5)
          return timing(2 * timeFraction) / 2;
        else
          return (2 - timing(2 * (1 - timeFraction))) / 2;
      }
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }

    let bounceEaseInOut = makeEaseInOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseInOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

“easeInOut” 変換は2つのグラフを1つにします: アニメーションの前半用の easeIn と、後半用の easeOut (easeIn の反転)です。

円弧 circ タイミング関数を例にして、その easeIn, easeOuteaseInOut のグラフを比べると、その効果ががはっきりと分かります。:

  • 通常の circ (easeIn).
  • easeOut.
  • easeInOut.

ご覧の通り、アニメーションの前半のグラフは縮小された easeIn であり、後半は縮小された easeOut のグラフです。結果、アニメーションはそれぞれの効果ではじまり、そして終わります。

より興味深い “draw”

要素を移動させる代わりに、他のことをすることもできます。必要なことは適切な draw を記述することです。

これは “バウンド” するテキスト入力のアニメーション例です:

結果
style.css
index.html
textarea {
  display: block;
  border: 1px solid #BBB;
  color: #444;
  font-size: 110%;
}

button {
  margin-top: 10px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <textarea id="textExample" rows="5" cols="60">He took his vorpal sword in hand:
Long time the manxome foe he sought—
So rested he by the Tumtum tree,
And stood awhile in thought.
  </textarea>

  <button onclick="animateText(textExample)">Run the animated typing!</button>

  <script>
    function animateText(textArea) {
      let text = textArea.value;
      let to = text.length,
        from = 0;

      animate({
        duration: 5000,
        timing: bounce,
        draw: function(progress) {
          let result = (to - from) * progress + from;
          textArea.value = text.substr(0, Math.ceil(result))
        }
      });
    }


    function bounce(timeFraction) {
      for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
        if (timeFraction >= (7 - 4 * a) / 11) {
          return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
        }
      }
    }
  </script>


</body>

</html>

サマリ

CSS では上手く扱えなかったり、厳密な制御が必要なアニメーションの場合、JavaScript が役立ちます。JavaScript アニメーションは requestAnimationFrame 経由で実装します。この組み込みのメソッドにより、ブラウザが再描画を準備するときに実行されるコールバック関数をセットアップすることができます。通常、それはすぐですが、正確な時間はブラウザに依存します。

また、これはページがバックグラウンドのときは再描画はまったく行いません。コールバックが実行されないからです。アニメーションは一時停止し、リソースも消費されません。これは素晴らしいことです。

これは、ほとんどのアニメーションのセットアップに使えるヘルパー関数 animate です:

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction は 0 tから 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // 現在のアニメーションの状態を計算
    let progress = timing(timeFraction);

    draw(progress); // 描画

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

オプション:

  • duration – アニメーションの合計時間(ms)。
  • timing – アニメーションの進行状況を計算する関数。0 〜 1 まで値を引数に取り、通常は 0 〜 1 でアニメーションの進行状況を返します。
  • draw – アニメーションを描画する関数です。

もちろん、これを改善して様々なオプションを追加することができますが、JavaScript アニメーションは日常的に使用されるものではありません。これらはなにか興味深いことをする場合や非標準的なことをする際に利用されます。そのため、必要なときに必要な機能を追加するのがよいでしょう。

JavaScript アニメーションは、任意のタイミング関数を扱うことができます。ここでは多くの例を取り上げました。CSS とは異なり、JavaScript アニメーションはベジェ曲線に制限されません。

draw についても同様です。CSS プロパティだけでなく、何でもアニメーションにすることができます。

タスク

重要性: 5

バウンドするボールを作ってください。ボールをクリックすると動きを確認できます。:

タスクのためのサンドボックスを開く

バウンドさせるには、position:relative をもつフィールド内のボールに対して、CSS プロパティ topposition:absolute を使うことで実現できます。

フィールドの下部の座標は field.clientHeight です。しかし、top プロパティはボール上部の座標のため、下端の位置は field.clientHeight - ball.clientHeight になります。

したがって、top0 から field.clientHeight - ball.clientHeight までアニメートします。

あとは、“バウンド” 効果を行うためにタイミング関数 bounceeaseOut モードで使います。

これがアニメーションの最終的なコードです:

let to = field.clientHeight - ball.clientHeight;

animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw(progress) {
    ball.style.top = to * progress + 'px'
  }
});

サンドボックスで解答を開く

重要性: 5

次のように、ボールを右にバウンドさせます。:

アニメーションコードを記述してください。右への距離は 100px です。

前のタスク バウンドするボールのアニメーション の答えをソースとして使ってください。

タスク バウンドするボールのアニメーション では、アニメーションするプロパティは1つだけでした。ここではもう1つ必要です。:elem.style.left.

水平座標は別の法則(“バウンド” ではなく、徐々にボールを右にシフトする)で変化します。

そのため、もう一つの animate を記述します。

時間関数として linear も使えますが、makeEaseOut(quad) などの方がはるかによく見えます。

コード:

let height = field.clientHeight - ball.clientHeight;
let width = 100;

// animate top (bouncing)
animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw: function(progress) {
    ball.style.top = height * progress + 'px'
  }
});

// animate left (moving to the right)
animate({
  duration: 2000,
  timing: makeEaseOut(quad),
  draw: function(progress) {
    ball.style.left = width * progress + "px"
  }
});

サンドボックスで解答を開く

チュートリアルマップ