Velocity.jsによるフェーズ有りアニメーションの実装

こんにちは&はじめまして、最近脱ペーパードライバーを目指し車の運転を始めたUITチームの手島です。

今回は最近の記事と比べると少し軽めな(?)フロントエンドのお話をさせていただきます。

LINE占い “宇宙兄弟 宇宙占い”特集ページ

先日、LINE占いの中で宇宙兄弟占いという特集ページがリリースされました。この特集は講談社様、コルク様、ロックミー様のご協力をいただいて企画・作成をしました。アプリ内ウェブビューで宇宙兄弟のキャラクター達による占いがおみくじ感覚で楽しめます。

この特集ページが提供するアニメーションは以下のように大きく4つのフェーズ(イントロ、スタート、占い、結果)に分かれています。
宇宙兄弟宇宙占いフェーズ全体像紹介
(c)小山宙哉/講談社 企画協力ロックミー

各フェーズの中で文字やキャラクターが移動したり、フェードイン/フェードアウトなどのアニメーション効果が付与されています。次フェーズへの遷移は各フェーズのアニメーション完了時もしくはclick/tapイベント発生時に行われます。

今回の記事ではこのようなフェーズごとに区切られたアニメーション(フェーズ有りアニメーション)をHTML/CSS/JSで実装する際に使用したVelocity.jsのご紹介と、実装時に行った工夫点であるJSONによるタイムラインの定義スプライト画像のプリロードについてご紹介させていただきます。

1. Velocity.jsとは

画面サイズによる位置調整やAndroid 2.x系サポート等を考慮し、今回のアニメーションの実装はcanvasやSVGではなく、DOM操作やCSSによりアニメーションを実現するJavaScriptライブラリを利用しています。

代表的なアニメーションJSライブラリにはCreateJSfamo.usVelocity.jsなどがあり、他にもCSSのクラス当て換えだけで実現するAnimate.cssといったCSSライブラリも数多く存在します。最近はブラウザの処理速度が向上したこともあり、このようなライブラリを利用することでFLASHムービーと比べても遜色の無いレベルのアニメーションを比較的容易に提供できるようになってきました。

今回はモバイル端末で軽快に滑らかなアニメーションを実現したいという、”軽量さ重視”の要件にマッチするライブラリとしてvelocity.jsを使用しています。

1-1. Velocity.jsの特徴

キャッシュによるDOMクエリの最小化、Tweeningによる不必要なレンダリングの防止、モバイル端末ハードウェアアクセラレーターの使用、などの手法を用いて高速化し、jQueryの$.animate()およびCSSアニメーションよりも優れたパフォーマンスを実現しているJSアニメーションのコアライブラリです。他に、以下に挙げるような特徴を持ちます。

  • jQueryに依存
<li>jQueryの$.animate()を拡張したインターフェースを提供</li>

<li>より高度なアニメーションを提供する<a href="http://velocityjs.org/#uiPack">拡張UI Pack</a>が存在</li>

<li>サイズが約8KBと軽量</li>

1-2. Velocity.jsのアニメーション記述例

例えば、opacity: 0;を指定した四角いノードをフェードインしながら右下へ移動させて、1秒間停止した後、フェードアウトしながら元の位置へ戻させるには以下のように記述します。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        .img01 {
            opacity: 0;
            position: absolute;
            width: 200px;
            height: 200px;
            background-color: red;
        }
    </style>
</head>
<body>
    <div class="img01"></div>
    <script src="js/lib/jquery.js"></script>
    <script src="js/lib/jquery.velocity.js"></script>
    <script>
        $(".img01").velocity({"opacity": 1, "top": "+=20px", "left": "+=40px"}).velocity("reverse", {"delay": 1000});
    </script>
</body>
</html>

上記のscriptタグの中の例では、直前に行ったアニメーションの逆を行うためのキーワードとしてreverseというキーワードを指定しています。これは$.animate()では提供されていないvelocityの便利機能です。このように$.velocity()はjQueryの$.animate()と同じインターフェースを提供した上で、独自の拡張機能を持っています。

複数のアニメーションで構成されたページを作るには、各ノードに対してvelocity(properties/* アニメーション効果の指定 */, options/* 継続時間などの設定 */)を繰り返し呼ぶことが基本になります。

この一行の例だけでもアニメーションを簡単に実現できるのがおわかりいただけるかと思います。

ただ、少し長めのアニメーションでイベントのハンドリング等を実装していくと、場合によってはJS内でアニメーションの実装と混在してきてコードの可読性が下がってしまいます。そこで、なるべくアニメーションの実装とアニメーション以外のJSロジックを混同させないように、今回はJSON形式でタイムラインの定義を行うようにしました。

2. JSONによるタイムラインの定義

通常のvelocity.jsの使い方を少し拡張して、それぞれのフェーズ内アニメーションを全てJSONで定義する方法をサンプルと共にご紹介します。

2-1. フェーズごとのHTMLの定義

DOM操作でフェーズごとに画像を動かしていくために、まずはHTMLを以下のようにフェーズごとの親div要素を用意します。その小要素として各アニメーション対象のオブジェクトを列挙していきます。なお、各要素はCSSで適切にdisplay, opacity, position, backgroound-image等のスタイルが指定されていることを想定しています。

    <!--フェーズ1の定義 -->
    <div class="bg01">
        <div class="img01_01"></div>
        <div class="img01_02"></div>
        <div class="label01_01">フェーズ1のラベル</div>
    </div>
    <!-- フェーズ2の定義 -->
    <div class="bg02">
        <div class="img02_01"></div>
        <div class="label02_01">フェーズ2のラベル</div>
    </div>

2-2. タイムラインJSONの定義

各フェーズの時系列に沿ったアニメーションの集合であるタイムラインを以下のようなJSONで宣言します。

// フェーズ1用アニメーションの定義
var TIME_LINE_01 = {
    ".bg01": [{
        "anims": [{
            // フェーズ1をフェードイン
            "properties": "fadeIn"
        }]
    }],
    ".img01_01": [{
        // 400ms後にアニメーションをスタートする
        "start": 400,
        "anims": [{
            // フェードインと共に下へ移動
            "properties": {"opacity": 1, "top":  "+=30px"}
        }, {
            // 右左の移動を3回繰り返す
            "properties": {"left":  "+=100px"},
            "options": {"duration": 500, "display": "none", "loop": 3}
        },{
            // 下方向へ移動しながらフェードアウト
            "properties": {"opacity": 0, "top":  "+=100px"},
            "options": {"duration": 500, "display": "none", "complete": function(){
               // アニメーション終了時にフェーズ2のタイムライン処理開始
               play(TIME_LINE_02);
            }}
        }]
    }]
};
// フェーズ2用アニメーションの定義
var TIME_LINE_02 = {
    // フェーズ1をフェードアウト
    ".bg01": [{
        "anims": [{
            "properties": "fadeOut"
        }]
    }],
    // フェーズ2をフェードイン
    ".bg02": [{
        "anims": [{
            "properties": "fadeIn"
        }]
    }],
    // フェーズ2スタート後に600msしたら下方向へ移動しフェードイン、1秒停止後に上方向へフェードアウト
    ".label02_01": [{
        "start": 600,
        "anims": [{
            "properties": {"top": "100px", "opacity": 1}
        }, {
            "properties": "reverse",
            "options": {"delay": 1000, "display": "none"}
        }]
    }]
};

2-3. タイムラインを実行するplay()の追加

上記のタイムラインJSONを以下のplayに渡すと、キーをjQueryのセレクタとして、各DOM要素に対してstartで指定しているミリ秒後にvelocityによるアニメーションを実行します。

        var play = function(timeline){
            // タイムラインjsonの各エントリを処理
            $.each(timeline, function(selector, triggers){
                $.each(triggers, function(i, trigger){
                    // startで指定したタイミングでアニメーションを開始
                    setTimeout(function(){
                        // タイムラインjsonのキーをセレクタとしたjQueryオブジェクトに対して、定義されたvelocityアニメーションを順次実行する
                        var $elm = $(selector).show();
                        $.each(trigger.anims, function(j, anim){
                            $elm.velocity(anim.properties, anim.options);
                        });
                    }, trigger.start || 0);
                });
            });
        };

2-2で示したJSONの定義ではフェーズ1のタイムラインの中のcompleteで指定したfunctionの中で、次のタイムラインJSONをplayに渡しています。これによって、タイムライン1の完了時にタイムライン2がスタートされます。もしフェーズ1のスクリーンをclickした時にフェーズ2に遷移させたい場合は、以下のようにイベントハンドラでフェーズ2のタイムラインをplay()に渡せば良いです。

    $('.bg01').click(function(){
        play(TIME_LINE_02);
    });

このように、タイムラインJSONを用いたちょっとした仕組みを作るだけで、アニメーションの実装とそれ以外のJSのロジックの分離が明確にできます。アニメーションのタイミングや調整はJSONのパラメーターを修正するだけなので、もしかするとデザイナーさん自身がツール要らずでアニメーションを簡単に作れるようになるかもしれません。ピクセルレベルの微調整はやはりエンジニアには辛い時間ですからね。。笑

3. スプライト画像のプリロード

当初はモバイル端末でJSのロードやアニメーションによるDOM操作が遅いということを懸念していましたが、今回提供したレベルのアニメーション操作ではほとんど問題は無く、実際はサイズの大きな画像のロードが最もパフォーマンスのボトルネックになっていることがわかりました。高速化を行うためにJSとCSSのminifyや画像圧縮などは事前にした上で、今回は以下に紹介するようなCSSスプライトに関する工夫を施しました。

3-1. CSSスプライトの導入

弊社ではSassのフレームワークであるCompassを用いてCSSスプライト画像を生成しています。弊社でのSassやCompassに関する取り組みについては、上村の記事富田の記事が参考になります。

さて、単純に使用している全ての画像をスプライト化することで、画像ごとに発生していたHTTPリクエストが1つだけになります。ブラウザは同時接続できるリクエスト数が限られているので、多くの画像に対してリクエストを発行するより、一般的には1つのスプライト画像をロードする方がネットワークによる遅延が少なくなります。

今回の宇宙兄弟占いの特集ページで使用している画像の枚数とサイズはフェーズごとに以下のようになっています。(最後のフェーズ4では占い結果に応じて必要な画像のみロードし、スプライト画像を使用しないため記載していません。)

フェーズ1 フェーズ2 フェーズ3
画像の枚数 4 8 17
合計画像のサイズ 約100KB 約190KB 約750KB

フェーズ3では配色の多い15枚のRetina対応した星の画像がシャッフルされながら表示されるためサイズが大きいです。全フェーズ合計すると約1MB近くの画像を使用しています。
この多くの画像をスプライト画像無しで読み込んだ場合、29回のHTTPリクエストが必要になってしまいます。

合計サイズ1MBの画像を全て一つのスプライト画像に変換し、読み込むことで一回のリクエストで画像の読み込みが可能になります。
しかし、今回の特集のような比較的大きめの画像が多く登場するアニメーションの場合は、後半に登場する画像を初めにロードしてしまうと初期ロードに時間がかかりすぎて、イントロ時に画像が読み込まれない現象が起きてしまいます。これでは画像だけ表示されないままアニメーションが進行していくという悲しい状態になります。

画像を多用したアニメーションの実装には、使用する画像を全部一つにスプライト化するのは適さないことがわかります。

3-2. フェーズごとに分割したスプライト画像のdisplay属性による遅延ロード

最近のブラウザはdisplay:none;のスタイルを適用した要素のbackground-imageで指定した画像は表示されるまでロードされない挙動をします(もちろんブラウザの実装によりますが)。その性質を利用して、表示される画像をフェーズごとに分割してスプライト画像を生成した後、フェーズ切り替え時にdisplay属性をnoneからblockに変更することで遅延ローディングが実現できます。

以下がイメージ図です。各フェーズが始まるタイミングでdisplay: block;を指定し、必要な画像をロードしていきます。

分割sprite

FirefoxのFirebugやChromeのデベロッパーツール等のネットワークタブで確認すると、フェーズ開始時にスプライト画像をロードしていく様子がわかると思います。
このようにフェーズごとにスプライト画像を分割し、遅延ローディングを行うことで、初期ロード時に画像が見えなくなる状況を回避できるようになりました。

3-3. スプライト画像のプリロード

フェーズごとにスプライトを分割するだけでも全てを一つのスプライト画像にした時と比べて体感的な高速化が実現されています。しかし、画像のロードとアニメーションのスタートが同時に行われるため、各フェーズの開始直後に画像が見えなくなる状況になりえます(特に今回のフェーズ3のような画像サイズが大きいため)。

そこで、もう一つの工夫として、次フェーズのスプライト画像の中に仕込んだロード用のトリガー画像を現行フェーズで読み込むことでプリロードを行います。
例えば、以下の図で示すようにフェーズ2とフェーズ3のスプライト画像の中に前のフェーズで使用している画像を一つ移動させます。その画像を持つDOM要素が前フェーズの段階でdisplay: block;の指定がされたタイミングで次のフェーズのスプライト画像のロードが始まります。

分割spriteプリロード

注意点はこのロードトリガー用の画像はフェーズ1の開始直後に表示されるものであってはならないことです。フェーズ1開始時にロードするサイズを大きくしてしまうと分割した意味がなくなってしまいます。また、必ず遅延無く表示されることを期待している画像も適していません。好ましい画像はフェードアウトして出てくるような要素で、さらに最悪遅延してロードしても本編に害がないような装飾的な要素です。

なお、このプリロードを行う際に、必ずしもフェーズごとにスプライト画像を作り直前のフェーズでロードする必要はありません。実は極端な話、負荷のかかるロード処理を行っても影響の無いタイミングがあれば、1pxのトリガー用透明画像等を仕込んで適宜display: block;に指定することでも実現可能です。ただし、フェーズという単位で画像をまとめ、前フェーズで次フェーズの画像をロードしていくことで、画像とコードのメンテナンスが容易になり、極端なロードの偏りといった状況も避けることができると思います。

今回の特集ではこの手法を用いることによって、フェーズ切り替わり時には既に必要な画像は全てロードされ、スムーズなアニメーションを提供することができるようになりました。

最後に

今回の記事では、フェーズごとに区切られたアニメーションをVelocity.jsを用いて実装する例と高速化の工夫についてご紹介させていただきました。ブラウザ上での描画に関してはHTML5、CSS3、 canvas、SVG, WebGLなど様々な技術が日々進化しています。今回のアニメーションの実装は一つの例であり、今後も技術の進化と共に、目的に適した方法を模索・採用していくことが重要だなと感じました。