単一の CSS アニメーションキーフレームをイベントに応じて順再生・逆再生する
1 つの CSS アニメーションキーフレームを使い回してイベントに応じて順再生したり逆再生したりしたかったのですが、ハマったので原因や対処方法を紹介します。また、CSS アニメーションでの対応だけでなく、Web アニメーション API での対応がいい感じだったのでこちらも紹介します。
やりたいこと
クリックイベントでフェードイン・フェードアウトするアニメーションを CSS で設定したかったのですが思っている通りに動きませんでした。
以下のような HTML と JavaScript を用意します。ボタンをクリックすると対象の要素(ここでは空の div
要素)に表示・非表示用のクラスを付け外しする仕様です。
次に以下のようなスタイルを適用します。ポイントは閉じるときは animation-direction: reverse
を指定し、逆再生することで単一のアニメーションキーフレーム fade
を開くときも閉じるときも使用するようにしていることです。
これで上手くいくと思っていたのですが、初回ボタンをクリックしたときはアニメーションが再生され、フェードインされたのですが、2 回目ボタンをクリックしたときはアニメーションが再生されず、フェードアウトではなくクラス open
が外されたことによって opacity: 0
が適用されて一瞬で消えてしまいました。
さらにその後ボタンをクリックするとフェードインアニメーションも再生されなくなりました。
環境
- ブラウザ
- Microsoft Edge 120.0.2210.77, Firefox 120.0.1
原因
CSS が何かまずいのか、そもそもアニメーションが再生されていないのか原因を切り分けるために以下のようにデバッグライトを仕込んでアニメーションが完了したらコンソールに出力するようにします。
結果、やはり 1 回目は出力されたのですが、2 回目以降フェードインでもフェードアウトでも出力されませんでした。つまりアニメーション自体が再生されいないということがわかりました。
ただ何故再生されないのかわからず、animation-direction: reverse
がダメなのかなとか考え、一旦アニメーションキーフレームをフェードイン・フェードアウトで分けてみました。
すると 2 回目以降でもフェードイン・フェードアウトそれぞれのアニメーションが再生されるようになりました。
この時点では animation-direction: reverse
がよくないんだと思い、念のため以下のようにアニメーションキーフレーム fadeout
の内容を fadein
と同じにして、animation-direction: reverse
を指定してみました。
するとこれでも動くじゃないですか…。
ここで原因は animation-name
に指定しているアニメーションキーフレーム名が同一であることではと考え始め、animation-iteration-count
プロパティ が頭にちらつき始めます。
<number>
アニメーションが繰り返される回数です。既定値は1
です。 - animation-iteration-count - CSS: カスケーディングスタイルシート | MDN
animation-iteration-count
は指定していませんので、上記の通り規定値 1
が適用されます。そして冒頭の単一のアニメーションキーフレームを使用したパターンの場合、初回ボタンクリック時に open
クラスが適用されアニメーションキーフレーム fade
が設定され、アニメーションが 1 度再生されます。
再度ボタンをクリックし、open
クラスが取り除かれ、close
クラスが適用されてもアニメーションキーフレームは fade
のままです。そしてアニメーションキーフレーム fade
は 1 度再生されているので animation-iteration-count: 1
により再生されることはありません。
アニメーションキーフレームをフェードイン・フェードアウトでそれぞれ用意した場合、毎回アニメーションキーフレームが切り替わるのでその度に再生回数がリセットされているのだと考えられます。
答えにたどり着くと当たり前な感じなのですが、なかなか気づきませんでした。
単一のアニメーションキーフレームでイベント毎にアニメーションを再生する
さて前項で原因が判明し、キーフレームを切り替えることでイベント毎にアニメーションを再生することはできたわけですが、上記の例のように単純なキーフレームならまだしも、複雑なキーフレームを単に逆再生したいがためだけに 2 つ用意するのはいけてない気がするので、単一のアニメーションキーフレームをイベント毎に再生する方法を考えます。
アニメーション終了後にアニメーションキーフレームをリセット(OK)
非表示 -> アニメーション順再生 -> 表示 -> アニメーション逆再生という状態を、それぞれクラス無し -> opening
クラス -> open
クラス -> closing
クラスで管理し、非表示、表示のときは animation-name: none
を指定することでアニメーション再生回数をリセットしてみます。
JavaScript は 以下の通りで、非表示 -> アニメーション順再生、表示 -> アニメーション逆再生の状態遷移はボタンのクリックイベントで発火。アニメーション順再生 -> 表示、アニメーション逆再生 -> 非表示の状態遷移は animationend
イベントで発火させます。アニメーション再生中にボタンがクリックされる可能性もあるのでボタンクリック時にこれから表示するのか非表示にするのかの判定は open
クラスだけではなく opening
クラスの有無も見て判断しています。
またアニメーション再生が何らかの理由でキャンセルされた場合も、アニメーション完了時と同じ処理を行うことで状態遷移が狂うのを防いでいます。
animation-iteration-count: infinite
で都度停止させる(NG)
上記と同様の状態管理を行いながら、animation-iteration-count: infinite
にし、非表示、表示状態のときは animation-play-state: paused
にしてアニメーション再生を停止させればいけるんじゃ…と思ってやってみたのですが、アニメーションを複数回再生する場合、アニメーション 1 回当たりの再生終了を検知するには animationiteration
イベントで検知可能なのですが、このイベントが曲者で結果ダメでした。
animationiteration
イベントは、 CSS アニメーションの反復が 1 回分終了し、次の回が始まったときに発生します。 - Element: animationiteration イベント - Web API | MDN
次の回が始まったときに発生するだと…。つまり順再生で opacity
が 0
-> 1
だと 1 回再生されて、opacity: 1
になり、次の再生が始まって opacity: 0
になったときに発生するので、そのタイミングで animation-play-state: paused
を指定したところで、アニメーションが停止されるのは opacity: 0
に戻っているのでフェードインした直後に消えて止まることになります。
実際にやってみましたがやはりその通りになってダメでした。
CSS はこんな感じで、animation-iteration-count: infinite
にして、非表示、表示状態のときに animation-play-state: paused
を指定してアニメーションを一時停止しています。
JavaScript は先ほどとほぼ一緒で、animationend
イベントの代わりに animationiteration
イベントをリッスンしています。
Web アニメーション API(OK)
ここまで CSS アニメーションで何とかしようとがんばっていましたが、もはやこれだけ JavaScript で色々やってたら全部 JavaScript でよくない?と思い、ウェブアニメーション API - Web API | MDN を使ってみることにしました。Web アニメーション API は比較的新しいブラウザ Web API で CSS アニメーションと同じ感覚で JavaScript からアニメーションの定義や実行ができます。また将来的には 2D のみならず 3D アニメーションにも対応していくようです。
先にコードを示しますがめちゃくちゃシンプルになりました。
まず KeyframeEffect
インスタンスを作成します。KeyframeEffect
コンストラクタの引数はいくつかパターンがありますがここでは
を利用しています。
target
はアニメーションさせる DOM 要素なのでここでは div
を渡しています。
target
The DOM element to be animated, or null.
keyframes
A keyframes object ornull
.
- KeyframeEffect: KeyframeEffect() constructor - Web APIs | MDN
keyframes
は keyframes オブジェクトと呼ばれるもので、実態は CSS アニメーションのアニメーションキーフレームと似たようなオブジェクトの配列です。
今回はこのように指定しています。
暗黙的に配列の最初の要素が CSS アニメーションキーフレームの 0%
にあたり、最後の要素が 100%
にあたります。
間のステップを指定する場合は offset
プロパティで指定することが可能です。
Offsets for each keyframe can be specified by providing an
offset
value.
KeyframeEffect
コンストラクタの引数の option
は KeyframeEffect: KeyframeEffect() constructor - Web APIs | MDN を参照いただければと思いますが、delay
, direction
, duration
等、CSS アニメーションで使用する animation-*
プロパティに加え、Web アニメーション API にしかないオプションもあります。
ここでは duration
のみ指定しています。なお、CSS の animation-duration
は CSS データ型の <time> - CSS: カスケーディングスタイルシート | MDN なので 1000ms
, 1s
のようにミリ秒でも秒でも指定できますが、Web アニメーション API の場合はミリ秒一択です。
さて次に作成した KeyframeEffect
インスタンスを Animation()
コンストラクタに渡して、Animation
インスタンスを作成します。
Animation
インスタンスはアニメーションを再生する play()
や逆再生する reverse()
、一時停止する pause()
など CSS アニメーションでもプロパティとして用意されている機能を JavaScript で実行可能なメソッドを提供します。詳細は Animation - Web APIs | MDN をご参照ください。
なお、今回アニメーションの再生には常に reverse()
で再生しています。そもそも keyframes オブジェクト作成時に最初の要素が opacity: 1
になっているので、おや?と思われたかもしれませんが、これには理由があります。
reverse()
メソッドは MDN を見ると以下のように記載されていて単純にアニメーションを逆再生するメソッドに見えました。
The Animation.reverse() method of the Animation Interface reverses the playback direction, meaning the animation ends at its beginning. If called on an unplayed animation, the whole animation is played backwards. If called on a paused animation, the animation will continue in reverse.
- Animation: reverse() method - Web APIs | MDN
従って、フェードイン時は順再生メソッド(と思っていた)の play()
、フェードアウト時は逆再生メソッド(と思っていた)の reverse()
と使い分けていました。
ところが 1 回目の表示 -> 非表示まではよかったのですが 2 回目表示したときに表示時にフェードアウトアニメーションが流れてしまいました。そして 2 回目非表示時にはフェードインアニメーションが流れるという不可解な動きになりました。
使い方が間違っている?それともバグ?と思い、W3C の仕様を確認してみました。
Inverts the playback rate of this animation and plays it using the reverse an animation procedure for this object.
- Web Animations
プレイバックレートを反転させて再生すると書かれています。playbackRate
は Animation
インスタンスのプロパティで再生速度のようなものです。
負の値を含む実数を指定することができ、規定値は 1 です。
例えば playbackRate
を 3
にすると 3 倍の速さでアニメーションが再生されます。そして負の値を指定すると逆再生かつ指定した速さで再生されることになります。詳しくは Animation: playbackRate property - Web APIs | MDN をご参照ください。
playbackRate
はインスタンスプロパティなので、変更すると以降その値が引き継がれます。従って、reverse()
メソッド呼び出し後は playbackRate
が正負反転したままになっているので、現在の playbackRate
に基づいて再生される play()
メソッドは反転された playbackRate
で再生するので期待した動きと反対方向にアニメーションが再生されてしまったようです。
そうするとアニメーション再生毎に playbackRate
を反転してほしいので、普通に毎回 reverse()
メソッドを使用している形になりました。
つまり reverse()
メソッドを実行しているところは以下のように書いても同じ動きをします。
reverse()
メソッドの使い方だけ少しハマりましたが、結果 Web アニメーション API のほうがコントロールしやすくシンプルに実装できるなと思いました。
あとがき
単一のアニメーションキーフレームを任意のタイミングで再生できるようにしてみましたが、CSS アニメーションで実装した場合、結局状態管理の JavaScript が複雑になってしまい、それぞれアニメーションキーフレームを設定するほうが結果シンプルなのではと思いました。一方で、Web アニメーション API は使い勝手よく JavaScript にアニメーションコントロールを寄せられ、シンプルに実装できました。今後アニメーションを使うときは積極的に Web アニメーション API を活用していこうと思います。