2015/06/22

Web Animations API入門(ハンズオン資料)

1. はじめに

この資料はGoogle IO2015のコードラボ「Web Animations Transitions and Playback Control」を基に、DevFest Japan 2015 Summer - Google I/O 2015 報告会における九州会場ブレイクアウトセッションのハンズオン資料として使いやすいよう、手を加えたものです。

single-page WebアプリのためのWeb Animations APIのコードラボです。 コンテンツ間の遷移アニメーションやスクロールポジションに応じたアニメーションの使い方を学びます。

最終的には以下の様なページを作ることができます。

web-animations-nextというpolyfillを利用するので動作環境は以下のモダンブラウザです。
      Chrome
      Safari
      Firefox
      IE11+

2. サンプルコードを取得

以下のURLかGitレポジトリからサンプルコードを取得して下さい。
https://github.com/googlesamples/io2015-codelabs/archive/master.zip
or
% git clone https://github.com/googlesamples/io2015-codelabs.git


web-animationというフォルダーが今回のハンズオンの対象です。
今回のハンズオンのためstartというフォルダをコピーして「breakout」というフォルダを作っておきます。これが今回のハンズオンの作業スペースになります。


3. サンプルサイトを開いてみましょう

web-animations/breakoutフォルダーのindex.htmlを開いてみましょう。
そこで右上のメニューを幾つかクリックすると、ページ遷移なしに表示が切り替わる事が確認できます。
さらに、ブラウザの「戻る」「進む」などをクリックしてみてください。
別ページに移動するのではなく、先ほどの表示を「戻る」「進む」ができている事がわかると思います。
1つのページでコンテンツだけが入れ替わり、ページロードが発生しないため、サクサクとした使い心のサイトです。


コラム

モバイルならどうなるのか?テストしてみた。 http://goo.gl/yWJ88Y
Android 5.1(Nexus5)のChromeで開き「戻るボタン」をタップすると確かに戻る!
ただし、メニューのハイライトが変更されません。
「About」を表示しているのにメニューは「Projects」のまま…

4. Web Animationsを追加しよう

web-animations-nextというpolyfillを使います。
web-animations/breakout/index.htmlを開いて下記のweb-animations-next.min.jsのスクリプトタグを<head>タグ内の下記に示す場所に記入して下さい。
<link href="site.css" rel="stylesheet" type="text/css"></link>
<script src="https://cdn.rawgit.com/web-animations/web-animations-js/2.0.0/web-animations-next.min.js"></script>
<script src="../shared/codelab.js"></script>

CDNを利用したくない場合は、web-animations-nextからweb-animations-next.min.jsをダウロードします。
GitHubの右下にある「Download ZIP」からプロジェクトの全ファイルをダウンロードしてきても良いですし、gitを使ってrepoから
% git clone https://github.com/web-animations/web-animations-js.git
としてダウンロードしてきても良いです。
ダウンロードしてきたフォルダの第一階層に「web-animations-next.min.js」というファイルがあることを確認して下さい。

5. コンテンツ素材のローディング効果アニメーションを追加しよう

Google I/O 2015のサイトのように、メニューをクリックするとコンテンツが広がるようなエフェクトを付けてみましょう。
まずsite.jsファイルを開きanimateToSection()関数の中に下記のコードを書きます。
function animateToSection(link, current, previous) {
    var effectNode = document.createElement('div');
    effectNode.className = 'circleEffect';

    var bounds = link.getBoundingClientRect();
    effectNode.style.left = bounds.left + bounds.width / 2 + 'px';
    effectNode.style.top = bounds.top + bounds.height / 2 + 'px';

    var header = document.querySelector('header');
    header.appendChild(effectNode);
}

アニメーションエフェクトをつけるdivノードを作り、そのクラス名をcircleEffectとします。
linkはメニューのことで、その外形からアニメーションが始まる場所を先ほど作ったdivノードのスタイルに設定し、最後にheaderノードにこのdivノードを追加しています。

なお、animateToSection()関数はshared/codelab.jsから呼び出される関数です。あたる引数がどういうオブジェクトなのか、どういう仕組みで呼び出しているのかなどなど、興味のある人は参考にしてみてください。

アニメーション用のdivノードに追加したcircleEffectクラスのCSSをsite.cssに追加します。 site.cssを開き、下記のコードを記入します。

div.circleEffect {
    width: 240vw;
    height: 240vw;
    margin-left: -120vw;
    margin-top: -120vw;
    border-radius: 100%;
    position: absolute;
    will-change: transform;
}
※vwはview-port widthの略でview-portの幅に対する割合を意味します

このCSSはwill-changeでtransformを指定しているので、CSSアニメーションにより形状が変更される設定になっています。
site.jsにanimateToSection()関数を追加して、CSSをアニメーションさせます。
var newColor = 'hsl(' + Math.round(Math.random() * 255) + ', 46%, 42%)';
effectNode.style.background = newColor;

var scaleSteps = [{transform: 'scale(0)'}, {transform: 'scale(1)'}];
var timing = {duration: 2500, easing: 'ease-in-out'};

var anim = effectNode.animate(scaleSteps, timing);

anim.addEventListener('finish', function() {
    header.style.backgroundColor = newColor;
    header.removeChild(effectNode);
});

アニメーションが終わったら、背景をエフェクトをかけた色に変更したいので、終了(finish)イベントをハンドリングする関数をEventListenerに登録し、効果を与えるeffectNodeを削除しています。
完成したsite.jsは以下のようになります。

/**
 * Called when a new section has been loaded.
 *
 * @param {Element} link element corresponding to new section
 * @param {Element} current now visible 
* @param {Element} previous previously visible
*/ function animateToSection(link, current, previous) { var effectNode = document.createElement('div'); effectNode.className = 'circleEffect'; var bounds = link.getBoundingClientRect(); effectNode.style.left = bounds.left + bounds.width / 2 + 'px'; effectNode.style.top = bounds.top + bounds.height / 2 + 'px'; var header = document.querySelector('header'); header.appendChild(effectNode); var newColor = 'hsl(' + Math.round(Math.random() * 255) + ', 46%, 42%)'; effectNode.style.background = newColor; var scaleSteps = [{transform: 'scale(0)'}, {transform: 'scale(1)'}]; var timing = {duration: 2500, easing: 'ease-in-out'}; var anim = effectNode.animate(scaleSteps, timing); anim.addEventListener('finish', function() { header.style.backgroundColor = newColor; header.removeChild(effectNode); }); }

ブラウザをリロードしてweb-animations/breakout/index.htmlを再表示し、右上のメニューをクリックしてみましょう。

6. フェードインアニメーションを付けてみよう

I/Oのサイトでは新しいコンテンツを開くとフェードインしてくるエフェクトがあり、ポップで楽しい気分になります。そのエフェクトを付けてみましょう。

先ほど作ったsite.jsのanimateToSectionに少し手を加えるだけです。

まずは下準備として、先ほど作成したエフェクトをカプセル化して同時実行できるようにしておきます。
var scaleEffect = new KeyframeEffect(effectNode, scaleSteps, timing);
var allEffects = [scaleEffect];
// Play all animations within this group.
var groupEffect = new GroupEffect(allEffects);
var anim = document.timeline.play(groupEffect);


animの位置と生成方法が変わっていることに注意が必要です。
KeyframeEffectという聞きなれない関数が出てきました。
これはeffectNodeの変更をカプセル化し覆い隠すことができる便利な関数です。
GroupEffectはグループに登録されたエフェクトを平行して実行する便利関数です。

準備は整いました、次にフェードインするアニメーションエフェクトを作ります。

function buildFadeIn(target) {
  var steps = [
    {opacity: 0, transform: 'translate(0, 20em)'},
    {opacity: 1, transform: 'translate(0)'}
  ];
  return new KeyframeEffect(target, steps, {
    duration: 500,
    easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)'
  });
}
そして、GroupEffectに登録します。
var allEffects = [scaleEffect, buildFadeIn(current)];
完成です。早速、web-animations/breakout/index.htmlを再読み込みして、メニューをクリックしてみましょう。
コンテンツがフェードインしてくるのが確認できましたか?

コラム

buildFadeIn()関数のKeyframeEffectで指定しているアニメーションの動作を規定するイージング関数が先ほどと少し異なりcubic-bezier(3次ベジエ曲線)を利用しています。
イージングによりアニメーションのタイムラインの進行速度をコントトロールする事ができますが、プリセットのイージング(例えばease-in-outなど)に加えて3次ベジエ曲線をしていきることで、かなり自由にアニメーションをコントロールできるようになっています。

ここまでのsite.jsは下記のようになっています。
/**
 * Called when a new section has been loaded.
 *
 * @param {Element} link element corresponding to new section
 * @param {Element} current now visible 
* @param {Element} previous previously visible
*/ function animateToSection(link, current, previous) { var effectNode = document.createElement('div'); effectNode.className = 'circleEffect'; var bounds = link.getBoundingClientRect(); effectNode.style.left = bounds.left + bounds.width / 2 + 'px'; effectNode.style.top = bounds.top + bounds.height / 2 + 'px'; var header = document.querySelector('header'); header.appendChild(effectNode); var newColor = 'hsl(' + Math.round(Math.random() * 255) + ', 46%, 42%)'; effectNode.style.background = newColor; var scaleSteps = [{transform: 'scale(0)'}, {transform: 'scale(1)'}]; var timing = {duration: 2500, easing: 'ease-in-out'}; var scaleEffect = new KeyframeEffect(effectNode, scaleSteps, timing); var allEffects = [scaleEffect, buildFadeIn(current)]; // Play all animations within this group. var groupEffect = new GroupEffect(allEffects); var anim = document.timeline.play(groupEffect); anim.addEventListener('finish', function() { header.style.backgroundColor = newColor; header.removeChild(effectNode); }); } function buildFadeIn(target) { var steps = [ {opacity: 0, transform: 'translate(0, 20em)'}, {opacity: 1, transform: 'translate(0)'} ]; return new KeyframeEffect(target, steps, { duration: 500, easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)' }); }


7. フェードアウトアニメーションを付けてみよう

フェードインのアニメーションがあったら、フェードアウトのアニメーションも欲しくなります。早速buildFadeOut()関数でアニメーションするKeyframeEffectを作りアニメーションを追加しましょう。
function buildFadeOut(target) {
  var angle = Math.pow((Math.random() * 16) - 6, 3);
  var offset = (Math.random() * 20) - 10;
  var transform =
      'translate(' + offset + 'em, 20em) ' +
      'rotate(' + angle + 'deg) ' + 
      'scale(0)';
  var steps = [
    {visibility: 'visible', opacity: 1, transform: 'none'},
    {visibility: 'visible', opacity: 0, transform: transform}
  ];
  return new KeyframeEffect(target, steps, {
    duration: 1500,
    easing: 'ease-in'
  });
}

さて、ここで困りました。
フェードアウトしてからフェードインのアニメーションをして欲しいのですが、GroupEffectでは、同時にアニメーションを実行してしまいます。
そこで活躍するのがSequenceEffectです。これはアニメーションに順番をもたせる関数です。これを利用することで、フェードアウトした後にフェードインさせることができます。

var fadeEffect = new SequenceEffect([buildFadeOut(previous), buildFadeIn(current)]);
var allEffects = [scaleEffect, fadeEffect];


これでweb-animations/breakout/index.htmlを再読み込みしてみてください。
フェードアウトでコンテンツが落ちていくエフェクトがあった後にフェードインのアニメーションが実行されているのがわかると思います。


どうじに、アニメーションに少し違和感を覚えるのではないでしょうか?
実は、よく見るフェードアウトとフェードインはクロスオーバー(一部動作が重なっている)しているのですが、ここでは順番に実行されているので、違和感があります。

そこで、エフェクトをクロスオーバーさせるために、フェードインの実行タイミングを前方向に1秒(つまり-1秒)移動します。
return new KeyframeEffect(target, steps, {
  duration: 500,
  delay: -1000,
  fill: 'backwards',
  easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)'
});



さらに重要なのが「fill: ‘backwards’」オプションです。
これはアニメーションが始まっていなくても、そのスタート地点のエフェクトをすぐさま描画に適用することをAPIに伝えるオプションです。
今回の例ではフェードアウトアニメーションのスタート位置のエフェクトは「不透明度ゼロ(opacity: 0)」なので、fill: backwardオプションにより、フェードアウトアニメーションが始まる前は全て透明になります。

ここまでのsite.jsの全コードは下記のようになります。
/**
 * Called when a new section has been loaded.
 *
 * @param {Element} link element corresponding to new section
 * @param {Element} current now visible 
* @param {Element} previous previously visible
*/ function animateToSection(link, current, previous) { var effectNode = document.createElement('div'); effectNode.className = 'circleEffect'; var bounds = link.getBoundingClientRect(); effectNode.style.left = bounds.left + bounds.width / 2 + 'px'; effectNode.style.top = bounds.top + bounds.height / 2 + 'px'; var header = document.querySelector('header'); header.appendChild(effectNode); var newColor = 'hsl(' + Math.round(Math.random() * 255) + ', 46%, 42%)'; effectNode.style.background = newColor; var scaleSteps = [{transform: 'scale(0)'}, {transform: 'scale(1)'}]; var timing = {duration: 2500, easing: 'ease-in-out'}; var scaleEffect = new KeyframeEffect(effectNode, scaleSteps, timing); var fadeEffect = new SequenceEffect([buildFadeOut(previous), buildFadeIn(current)]); var allEffects = [scaleEffect, fadeEffect]; // Play all animations within this group. var groupEffect = new GroupEffect(allEffects); var anim = document.timeline.play(groupEffect); anim.addEventListener('finish', function() { header.style.backgroundColor = newColor; header.removeChild(effectNode); }); } function buildFadeIn(target) { var steps = [ {opacity: 0, transform: 'translate(0, 20em)'}, {opacity: 1, transform: 'translate(0)'} ]; return new KeyframeEffect(target, steps, { duration: 500, delay: -1000, fill: 'backwards', easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)' }); } function buildFadeOut(target) { var angle = Math.pow((Math.random() * 16) - 6, 3); var offset = (Math.random() * 20) - 10; var transform = 'translate(' + offset + 'em, 20em) ' + 'rotate(' + angle + 'deg) ' + 'scale(0)'; var steps = [ {visibility: 'visible', opacity: 1, transform: 'none'}, {visibility: 'visible', opacity: 0, transform: transform} ]; return new KeyframeEffect(target, steps, { duration: 1500, easing: 'ease-in' }); }

8. Playbackコントロールをつけよう

最後に、アニメーションにPlaybackコントロールを付けてみます。
アニメーションは基本的に時間軸で前方に動作します。AからBへと状態が変化する様に。しかしAnimations APIは非常に多彩で、アニメーションの時間方向を変更できるのです。

今回はスクロールの位置によってアイコンの大きさや色を変更するアニメーションです。
まずページロード時に左下のアイコンにアニメーションを設定します。
window.addEventListener('load', function() {
  var icon = document.querySelector('.icon');

  var steps = [
    {color: 'hsl(206, 46%, 89%)', transform: 'scale(0.5)'},
    {color: 'hsl(13, 79%, 96%)', transform: 'scale(2)'},
    {color: 'red', transform: 'scale(1)'}
  ];
  var timing = {duration: 1, fill: 'both'};
  var anim = icon.animate(steps, timing);
});
iconオブジェクトを取得し、そのtransformアニメーションを設定しています。
「HSL(206, 46%, 89%)、大きさ0.5」からスタートし、「色HSL(13, 79%, 96%)、大きさ2.0」を経由して、「色RED, 大きさ1.0」に遷移するアニメーションです。
このaminオブジェクトに下記のようにPlaybackコントロールを付けます。

anim.pause();  // never play this animation forward

function updatePlayer() {
    var top = window.scrollY;
    var height = document.body.scrollHeight - window.innerHeight;
    anim.currentTime = top / height;
  }
  updatePlayer();
  window.addEventListener('scroll', updatePlayer);
まず、animationをpauseし、前方向へアニメーションしないようにしておきます。
updatePlayer()関数はスクロールの位置に応じてanimの時間を変更する、いわばアニメーションの進行位置を変更する関数です。
これをスクロールイベントに設定して完了です!

さぁ、web-animations/breakout/index.htmlを開いてみましょう!
いかがでしょうか?左下のハートのアイコンがスクロール位置に応じて、大きくなったり、色が変わったりするのが確認出来ましたか?


これで、本ハンズオンは終了です。
Web Animationsはまだまだ発展途上ですが、どんどん面白いことができるようになるだけでなく、Webアプリがより軽快でスムースに利用しやくすなるために重要な要素だと思います。

最後に、完成したsite.jsは以下のようになります。お疲れ様でした!
/**
 * Called when a new section has been loaded.
 *
 * @param {Element} link element corresponding to new section
 * @param {Element} current now visible 
* @param {Element} previous previously visible
*/ function animateToSection(link, current, previous) { var effectNode = document.createElement('div'); effectNode.className = 'circleEffect'; var bounds = link.getBoundingClientRect(); effectNode.style.left = bounds.left + bounds.width / 2 + 'px'; effectNode.style.top = bounds.top + bounds.height / 2 + 'px'; var header = document.querySelector('header'); header.appendChild(effectNode); var newColor = 'hsl(' + Math.round(Math.random() * 255) + ', 46%, 42%)'; effectNode.style.background = newColor; var scaleSteps = [{transform: 'scale(0)'}, {transform: 'scale(1)'}]; var timing = {duration: 2500, easing: 'ease-in-out'}; var scaleEffect = new KeyframeEffect(effectNode, scaleSteps, timing); var fadeEffect = new SequenceEffect([buildFadeOut(previous), buildFadeIn(current)]); var allEffects = [scaleEffect, fadeEffect]; // Play all animations within this group. var groupEffect = new GroupEffect(allEffects); var anim = document.timeline.play(groupEffect); anim.addEventListener('finish', function() { header.style.backgroundColor = newColor; header.removeChild(effectNode); }); } function buildFadeIn(target) { var steps = [ {opacity: 0, transform: 'translate(0, 20em)'}, {opacity: 1, transform: 'translate(0)'} ]; return new KeyframeEffect(target, steps, { duration: 500, delay: -1000, fill: 'backwards', easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)' }); } function buildFadeOut(target) { var angle = Math.pow((Math.random() * 16) - 6, 3); var offset = (Math.random() * 20) - 10; var transform = 'translate(' + offset + 'em, 20em) ' + 'rotate(' + angle + 'deg) ' + 'scale(0)'; var steps = [ {visibility: 'visible', opacity: 1, transform: 'none'}, {visibility: 'visible', opacity: 0, transform: transform} ]; return new KeyframeEffect(target, steps, { duration: 1500, easing: 'ease-in' }); } window.addEventListener('load', function() { var icon = document.querySelector('.icon'); var steps = [ {color: 'hsl(206, 46%, 89%)', transform: 'scale(0.5)'}, {color: 'hsl(13, 79%, 96%)', transform: 'scale(2)'}, {color: 'red', transform: 'scale(1)'} ]; var timing = {duration: 1, fill: 'both', easing: 'ease-in-out'}; var anim = icon.animate(steps, timing); anim.pause(); // never play this animation forward function updatePlayer() { var top = window.scrollY; var height = document.body.scrollHeight - window.innerHeight; anim.currentTime = top / height; } updatePlayer(); window.addEventListener('scroll', updatePlayer); });