この記事はようやくSafariでもフルサポートされそうなWeb Animations API
のcomposite
(効果の組成)って機能がすごいよ!!って、ただそれだけを伝えたい記事です。平たくいうと複数のアニメーションを簡単キレイに合成できる機能なのですが、通常のWebのコーディングでもよく出てくる辛さを解決してくれる結構すごいヤツなのです。
▼ こういうアニメーション作るのもだいぶん楽になります
Web Animations APIで星空パーティクル
— ゆき@ティアF47a (@yuneco) October 11, 2020
単にCSSのアニメーションをJSで描けるよってだけではあるんだけど、ライブラリなしでそこそこ簡単にインタラクティブなもの作れるって意味ではうれしい。主要ブラウザ全部で使える。https://t.co/8H8zXfc5NL pic.twitter.com/bfTERJPxIX
▼ 全国から寄せられた喜びの声
SafariのTechnology Preview 142でついにアニメーションのcompositeがサポートされたよ!!このサンプルが!うごくよ!! #WebAnimationsAPI https://t.co/vip74klL3Q
— ゆき@ティアF47a (@yuneco) April 3, 2022
一応このツイートは100いいねくらい貰ったのだけど、この喜びが果たしてWebエンジニア界隈に広く伝わっているのか不安なのでQiitaにちゃんと書きます。この感動をみんなと共有したいんだ
重要 上のツイートにもある通り、この機能はChromeとFireFoxでは実装済みですがSafariはまだTP(Technology Preview)です。次の正式リリースに入るかどうかもたぶん確定していません。1
そもそもの問題: アニメーションの合成がないと何が辛いのか
最初に紹介したパーティクルの作例はちょっと極端なので、もうちょっと普通にある例で説明します。
この例ではキャラクター(たまさん)が常時CSS Animationで回転しています。「この状態でたまさんをクリックすると一瞬ズームしたい」というのが今回のお題です。ポイントは回転もズームもCSSではtransform
プロパティで指定するという点です。わかりやすく両方アニメーションにしていますが、ベースの回転はアニメーションでなくても構いません。とにかくtransform
プロパティを重ねて使っているのが問題です。
これは別に特殊なことではなく、ごく普通のWebページでも頻出するものです。transform: translate()
で位置指定をしつつ、ホバーした時だけtransform: scale()
でちょっと大きくする、みたいなの、よくありますよね?
で、何も考えずに作るとどうなるか
<div class="stage" id="stage1">
<!-- 「たまさん」のキャラ要素 -->
<div class="tama"></div>
</div>
<script>
const tama1 = document.querySelector('#stage1 .tama');
// キャラをズームする
// animateメソッド(Web Animations API)を使っていますが、CSS Animation等を使っても結果は同じです
const zoomTama1 = () => {
tama1.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(1.5)' }, // 1.5倍にズームして
{ transform: 'scale(1)' } // 元のサイズに戻す
], { duration: 1500 })
};
tama1.addEventListener('pointerdown', zoomTama1); // クリックで発動
</script>
ズームした瞬間回転が解除されてます
理屈は簡単ですね。回転もズームもどっちもtransform
プロパティなので、上書きされて片方しか効かなくなってるだけです。アニメーションを適用する方法はいくつかありますが、古典的なCSSのクラス名を追加する方法でも、CSS TransitionやCSS Animationを使う方法でも基本全部同じです。2
今までの解決法: divで全部入れ子にしようの術
結局のところ、ひとつの要素にtransform
プロパティは1つしか設定できないのが問題だとわかりました。つまりタイミングやイージングの異なる複数のアニメーションを適用したければ、要素を2つ作らないといけません。3 今回の例では、元の回転するキャラクターをdiv
で括って、「ズーム用のラッパー」を作ります。
<div class="stage" id="stage2">
<!-- ズーム用のラッパーdivを作る -->
<div class="zoomWrapper">
<div class="tama"></div>
</div>
</div>
<script>
// 追加したラッパー要素に対してズームを実行
const tama2wrapper = document.querySelector('#stage2 .zoomWrapper');
const zoomTama2 = () => {
tama2wrapper.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(1.5)' },
{ transform: 'scale(1)' }
], { duration: 1500 })
};
tama2wrapper.addEventListener('pointerdown', zoomTama2);
</script>
コングラチュレーション!!!おめでとう!!これで無事回転を維持しながらズームもできるようになりました。
でもさ、これ、めんどくないですか?面倒というか、クリーンじゃない。アニメーションの内容に合わせてDOM構造を変えないといけないこと自体が綺麗じゃないし、何よりも「アニメーションのモジュール化」がすごく困難になるのです。
任意の要素をズームするzoomElement(el, scale, duration)
みたいな汎用的な関数を作ることを考えてみてください。動的にラッパーのdiv
を差し込んでアニメーションして、終わったら元に戻す?...怖いですね。ReactとかVueとか使ってたら絶対ロクなことにならないです。
新しい解決法4: compositeならアニメーションの重ね掛けができるよ!
やっとここで本題のcomposite
です。composite
自体はWeb Animations API
が提供するオプションのひとつに過ぎません。なので使い方も簡単です。composite
を使って解決するコードは↓こんな感じ。
<div class="stage" id="stage3">
<div class="tama"></div>
</div>
<script>
const tama3 = document.querySelector('#stage3 .tama');
const zoomTama3 = () => {
tama3.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(1.5)' },
{ transform: 'scale(1)' }
], { duration: 1500, composite: 'accumulate' })
// composite: 'accumulate'を指定👆
};
tama3.addEventListener('pointerdown', zoomTama3);
</script>
動きは大体同じなのでキャプチャは省略
composite: 'accumulate'
を指定してるだけ。これだけで元のtransform: rotate()
を維持したままtransform: scale()
を重ねて適用することができます。
composite
にはreplace
・add
・accumulate
の3種類の値を指定できます。このうち、replace
は従来と同じ上書きの動作でadd
とaccumulate
が「重ね掛け」するための設定です。この2つは大体同じですが、transform
やfilter
みたいな複数の値リストを指定できるプロパティではちょっと結果が変わってくることがあります。詳しくは仕様読んでね。
注記: 実数や長さなど,多くの型のアニメーション用の累積は、 加算と一致するように定義される。 この 2 つの定義が相違するよくある事例として,リストに基づく型がある — そこでは、 加算は リストに付加するものとして, 累積は 成分ごとの加算として定義されることもある。 例えば,フィルタリスト値【 <filter-value-list> 】[ blur(2), blur(3) ]は、 加算されるときは blur(2) blur(3) を生産する一方で, 累積されるときは blur(5) を生産することになる。
引用元: CSS におけるカスケード法と継承 — CSS Cascading and Inheritance Level 5 § 3. 値の結合法: 補間, 加算, 累積
ちなみに、composite
による合成は3つ以上でもOKなので、この例ではマウスを連打するとたまさんがどんどん大きくなります。入れ子にする方法だとクリックするたびにズームがリセットされるので、ちょっと違う挙動ですね。
どっちが良いかはケースバイケースですが、無限にズームされちゃうのが困る場合、↓のようにasync
/await
を使えば連打防止の制御も簡単です。Web Animations API便利ですね
<div class="stage" id="stage4">
<div class="tama"></div>
</div>
<script>
const tama4 = document.querySelector('#stage4 .tama');
let isRunning = false;
const zoomTama4 = async () => {
if (isRunning) return;
isRunning = true;
await tama4.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(1.5)' },
{ transform: 'scale(1)' }
], { duration: 1500, composite: 'accumulate' }).finished
isRunning = false;
};
tama4.addEventListener('pointerdown', zoomTama4);
</script>
→ 「compositeを使う(新しい方法) + 連打できないように制御」を試す
いい感じですね
composite
を正式に使えるようになるのはもうちょっと先かもしれませんが、Web Animations APIの基本機能は現状でもほぼ問題なく利用できるので、使えるものから少しずつ取り入れていくのが良さそうです
おまけのFAQ
Q: composite
はいつから使えますか?今使うと何か問題になりますか?
ChromeとFirefoxのみでよければ使えます。SafariはTP版に入ったところなので正式リリースは未定です。現状Safariでcomposite: 'accumulate'
またはcomposite: 'add'
を指定すると、エラーにはなりませんが謎の挙動を楽しめます
Q: ポリフィルはありますか?
Web Animations API自体のポリフィルはありますが、私の知る限りcomposite
が動くものはないように思います(あったら教えてください)。仕組み的にもポリフィルできちんと実現するのはかなり難しいはずです
Q: Can I Use...で見るとMac Safari(iOSではなくmac OSの)は対応しているように見えるのですが...
大体対応しているらしいのですが、少なくともtransform
のcomposite
は動作しません。他にも色々あるのでTP142のリリースノートのWeb Animationsの項も参照してください
Q: パフォーマンスは変わりますか?
ラッパー要素を作る必要がなくなるので、CPU/メモリとも幾らかは向上する可能性があります。Mac版Chromeで軽く測定した感じでは僅かな改善は見られました。ただ、よほど特殊なケースでなければ誤差程度の違いかと思います
-
より正確にはcomposite機能自体は少し前から実装済みで、TP142で入ったのは一部の未対応だった機能の追加実装です。感触的にはぼちぼち正式リリースに入るのでは...と(希望的観測で)見ています ↩
-
方法の組み合わせによってどっちが優先されるかは変わってきます。でも「どっちか一方しか効かない」のは一緒 ↩
-
他に真っ当で簡単な方法ってないですよね...?もしあったら教えてください。
transform
じゃないrotate
を使う方法も考えたのですが、こっちはChromeが対応してないんですよね... ↩ -
仕様としては別に新しくないのですが、Safariの対応目処が立たなかったのでずっと使えなかったのです。。 ↩