🍜 君はなぜCSSスプライトアニメーションを使わないのか?
作成日: 2021/08/29
0
読み飛ばして良いまえがき

Webサイトetc.で、ちょっとしたアニメーションを使おうとした時、おそらくアニメーションGIFやAPNGが真っ先に候補にあがり、これは確かに簡単に扱えて便利です。ただ、1回の再生のみ、開始と終了をちゃんと制御するという条件になると話は少々変わってきます。
アニメーションGIFの場合、ちゃんと再生開始を制御しようとすると、キャッシュが邪魔をする環境があるので、JavaScript etc.でキャッシュよけが必要になります。また、再生終了に関してもあらかじめ何かで取得していた再生時間から、おそらく終わったであろうタイミングで処理をさせるということになります。
また途中で一時停止や、その一時停止したタイミングからの再生etc.も難しいです。

さて一方、CSSスプライトで行うアニメーションは、外部ファイル自体は単なる静止画で、それをCSSを使って上手くパラパラまんがにするという方式なので「再生開始の制御」「再生終了の取得」は簡単にでき、頑張れば「途中一時停止」や「一時停止したタイミングからの再生」「再生速度の制御」なんてこともHTML、CSS、JSの範囲でできてしまいます。🙌🏻

今回は、まずCSSスプライトアニメーションの仕組みと「再生開始の制御」「再生終了の取得」について解説します。


CSSスプライトアニメーションとは

コードと動くサンプルは CodePen にまとめてあります。こちらをベースに話を進めます。
https://codepen.io/kaitou1192/pen/VwbNMjO

まず最初に基本的なCSSスプライトアニメーションのおさらいです。

div {
  position: relative;
  width: 200px;
  height: 200px;
  border: solid #eee 1px;
  overflow: hidden;
}
img {
  position: absolute;
  left: 0;
  top: 0;
}
.active {
  animation: active 3s steps(30) 0s 1 normal forwards running;
}
@keyframes active {
  to {
    top: -6000px;
  }
}

今回、僕が用意した画像は1コマ200px×200pxで、それが縦に31コマ連なった高さ6200pxの画像です。それを div に overflow: hidden; をかけて、紙芝居の枠や映写機のスリットのように扱います。
また .active の animation の steps(30) の箇所で、初期の1コマ+アニメーションの30コマということを指定。+のアニメーションの30コマ部分の高さが6000pxなので @keyframes active の to は top: -6000px; としています。
(テキストで説明されるだけだと難しいよ!という方は、こちらが、よく分かると思います。)

やっと本題

引き続きこちらのCodePenをベースに話を続けます。
https://codepen.io/kaitou1192/pen/VwbNMjO

再生開始の制御

if(img.complete) {
  img.classList.add('active');
} else {
  img.addEventListener('load', () => {
     img.classList.add('active');
  });
}

img.classList.add('active'); の箇所が、アニメーションの class を付け加えているところですね。
<img src="〜"> → <img src="〜" class="active"> にするというのをJSで行っています。

今回は画像が読み込み終わったタイミングで再生開始をしたかったので上記のように書いていますが、2段構えで img.classList.add('active'); が登場しています。(余談ですが2回登場したから function にするか悩みましたが、一旦このままで。)
通常は addEventListener load で、大丈夫なはずです。ただ、このJSが発動するまでに画像の読み込みが完了している可能性もあるということで、手前に img.complete で、判別をかけています。
(この img.complete について詳しく知りたい方は、こちらがわかりやすいと思います。)

ただ、ここは単に今回が画像が読み込み終わったタイミングだっただけなので、クリックであれば addEventListener click をトリガーにする等々は問題ありません。

再生終了の取得

img.addEventListener('animationend', () => {
  setTimeout(() => {
    div.classList.add('fade_out');
  },1000);
});

実は addEventListener animationend という便利なものがあります。これはCSS側のanimationが終わったところで、発動するトリガーです。
今回は animation が終わった後、setTimeout を使って1秒(1000ミリ秒)を待って fade_out という、class を div につけています。

.fade_out {
  opacity: 0;
  transition: opacity 0.3s ease;
}

そして、その fade_out には transition を指定。先ほどまでの animation とは違うスタイルですが、こちらも時間軸のある処理に使われます。(もしかしたらCSSで動きをつけるという場合には、こちらの方がメジャーかもしれません。)

div.addEventListener('transitionend', () => {
  div.remove();
  body.innerHTML = 'finished';
});

で、こちらはその transition に対応する終了のトリガーです。
先ほどのものは animationend でしたが、こちらは transitionend です。CSSの animation でも transition でも終了は取れるということですね。
(詳しく知りたい方は、animationend についてはこちら、transitionend についてはこちらを参考にしてください。)

レスポンシブ対応

さて、ここまで順調に来ましたが、実はCSSスプライトアニメーションにも苦手なものがあります。

それは表示サイズ変更です。アニメーションGIFやAPNGは、HTMLやCSS上では、普通の画像を扱っているのと同じなので、同じ用に表示サイズの変更ができますし、レスポンシブ対応の時に困るということも特段ありません。
しかしCSSスプライトは先ほど書いたように、紙芝居の枠みたいなものを作って、その中の静止画を上手く1コマごとに動かすことによって、パラパラまんがを実現していたわけです。なので、紙芝居の枠のサイズや、コマの動かし方が上手くいかなくなると、途端に破綻してしまいます。
なので、ネット上ではまことしやかに「レスポンシブ対応は無理」みたいに言われていて、実際、先のサンプルを社内(どこ)で展開したときも「あの…… Kaitouさん、レスポンシブ対応が……。」と言われましたが、実はできます。

サンプルは先ほどとは別のCodePenにまとめてあります。
https://codepen.io/kaitou1192/pen/PojwNWE

肝は下記の場所です。

<div class="outer">
  <div class="inner"><img src="https://drive.google.com/uc?export=view&id=1JUBwT_YVu84bdxKWr5VX-BF5VL5j3y4z"></div>
</div>
const adjustAnimation = () => {
  const windowWidth = window.innerWidth;
  if(windowWidth/200 < 1) {
    const scaleData = windowWidth/200;
    outer.setAttribute('style','width: ' + (200 * scaleData) + 'px;height: ' + (200 * scaleData) + 'px;');
    inner.setAttribute('style','transform: scale(' + scaleData + ');');
  } else {
    outer.removeAttribute('style');
    inner.removeAttribute('style');
  }
}

document.addEventListener('DOMContentLoaded', adjustAnimation);
window.addEventListener('resize', adjustAnimation);

今回は div が2つ登場するので .outer と .inner という class をそれぞれつけてあります。
何をしているかと言うと JS で、transform scale を使って .inner の表示サイズを制御しています。
ただこれには問題があって transform scale は、単に画面表示のみの拡大や縮小で、まわりの要素との関係性を気にしてくれません。
通常の画像ファイルで幅を変えれば、その画像の表示サイズが変わると同時に、その画像が保持している領域も変化するため、良い感じに表示が保持されると思います。これは幅だけではなく高さでも同様ですね。
transform scale の場合は、保持している領域自体は変化しないので、拡大したらまわりの要素に被ったり、潜り込んだりしますし、縮小したら変な余白ができてしまいます。その領域の制御をするために .outer を作って、そちらも同時にJSでサイズの制御をすることによって、transform scale を使用しても、要素が被ったり、変な余白ができないように調整しています。



せっかくなのでコードの他の部分のワンポイント解説
const img = document.querySelector('img');

document.querySelector は、htmlで最初に登場する <img> を指します。このときの img は、別に #aaa とか .bbb とか input[type='text'] とかでも構いません。jQueryみたいに扱えて便利。もうJSのためだけに id を付与して、getElementById で受けるということはしなくて大丈夫です。
ちなみに最初のもの以外も扱いたいときは querySelectorAll というのもあります。
(詳しく知りたい方は、querySelector についてはこちら、querySelectorAll についてはこちらを参考にしてください。)

document.addEventListener('DOMContentLoaded', adjustAnimation);

document.addEventListener load は、よく世にあるJSのサンプルコードに出てくると思いますが、この load の場合だと画像etc.の外部ファイルも含めて読み込まれたタイミングで発生します。
一方、DOMContentLoaded の方は、外部ファイルは気にしていなくて、DOMの読み込みが完了した時点で発生するという違いがあります。こちらもjQueryの $(function(){ }); のように、扱えて便利。
(DOMContentLoaded について、詳しく知りたい方はこちらを参考にしてください。)



こういうメモをTwitterにも書いているので良かったらフォローしてください。✨
https://twitter.com/Kaitou1192/status/1431602283771359234
https://twitter.com/Kaitou1192/status/1426866915071303683

本業はコードを書かせてもらえないフロントエンドエンジニアです。 こんなサービス作っています。 https://lp.re-shine.jp