AngularJSのアニメーションの仕組みについて

このエントリーをはてなブックマークに追加

この記事は、Frontrend Advent Calendar 2013の10日です。

AngularJSのアニメーションの準備

AngularJS1.2から要望の多かったアニメーション機能が実装されました。 アニメーションは標準で実装されていないため、ngAnimateファイルの読み込みとそのモジュールを最初に依存注入しておく必要があります。 まずはアニメーション用のファイルの読み込みです。

<script src="http://code.angularjs.org/1.2.3/angular.min.js"></script>
<!-- アニメーション用のngAnimateファイルの読み込み -->
<script src="http://code.angularjs.org/1.2.3/angular-animate.min.js"></script>

次に依存性注入を行います。 下記は、自分のプロジェクトモジュールをmyAppとするとして、そのmyAppにngAnimateを注入しています。 これでAngularJSでアニメーションを使用する準備が整いました。

angular.module('myApp', ['ngAnimate'])

ng-showのアニメーション

ng-showディレクティブが適用された要素が非表示になるのは、 display: none !important;のCSSスタイルが割り当てられた.ng-hideのclass属性が割り当てられるためです。 この表示・非表示の動作にアニメーションを適用するために、AngularJSは更に特定の"class属性の付け外し"を行います。 その付け外しのclass属性は下記の通りです。

class属性 説明
.ng-hide-add .ng-hideに-addが付与されたclass名です。 .ng-hideが追加される際のCSS3アニメーションを定義します。
.ng-hide-add-active .ng-hideに-add-activeが付与されたclass名です。 .ng-hide-addのアニメーションをトリガするために使用されます。 .ng-hideが追加された結果、到達するスタイルを定義します。
.ng-hide-remove .ng-hideに-removeが付与されたclass名です。 .ng-hideが削除される際のCSS3アニメーションを定義します。
.ng-hide-remove-active .ng-hideに-remove-activeが付与されたclass名です。 .ng-hide-removeのアニメーションをトリガするために使用されます。 .ng-hideが削除された結果、到達するスタイルを定義します。

ngAnimateモジュールを適用しないと、上記のclass属性の付け外しは発生しません。 ブラウザツールのDOMインスペクタで観察してみてください。 ngAnimateモジュールが適用されていない場合は、ng-hideの付け外しが行われるのみです。

<!doctype html>
<html ng-app="myApp">
  <head>
    <link rel="stylesheet" type="text/css" href="style.css">
    <script src="http://code.angularjs.org/1.2.3/angular.min.js"></script>
    <script src="http://code.angularjs.org/1.2.3/angular-animate.min.js"></script>
    <script src="script.js"></script>
  </head>
  <body>
    <div>
      <label>
        <input type="checkbox" ng-model="checked">表示する
      </label>
      <!-- animate-show-hideクラスは、
           基本的なスタイル適用の他、
           アニメーションするクラスを特定するための、
           マーキングのような役割に使用されています。 -->
      <div class="check-element animate-show-hide" ng-show="checked">
        表示!
      </div>
    </div>
  </body>
</html>
angular.module('myApp', ['ngAnimate'])
input[checkbox] {
  float:left; margin-right:10px;
}
.check-element {
  style="clear:both;"
}

/* アニメーション定義 */
/* アニメーション対象要素の基本的なスタイル */
.animate-show-hide {
  padding:10px;
  border:1px solid black;
  background:white;
}
/* 表示・非表示時のアニメーションを定義 */
.animate-show-hide.ng-hide-add,
.animate-show-hide.ng-hide-remove {
  -webkit-transition:all linear 0.5s;
  -moz-transition:all linear 0.5s;
  -o-transition:all linear 0.5s;
  transition:all linear 0.5s;

  /*下記の定義に注目(詳細は後述)*/
  display:block!important;
}
/* ng-hide適用(=非表示)の終点となるスタイル */
/* また、ng-hide-removeに移行する際の始点のスタイルでもある */
.animate-show-hide.ng-hide-add.ng-hide-add-active,
.animate-show-hide.ng-hide-remove {
  opacity:0;
}
/* ng-hide削除(=表示)して到達するスタイル */
/* また、ng-hide-addに移行する際の始点のスタイルでもある */
.animate-show-hide.ng-hide-remove.ng-hide-remove-active,
.animate-show-hide.ng-hide-add {
  opacity:1;
}
<!doctype html>
<html ng-app="myApp">
  <head>
<style type="text/css">input[checkbox] {
  float:left; margin-right:10px;
}
.check-element {
  style="clear:both;"
}

/* アニメーション定義 */
/* アニメーション対象要素の基本的なスタイル */
.animate-show-hide {
  padding:10px;
  border:1px solid black;
  background:white;
}
/* 表示・非表示時のアニメーションを定義 */
.animate-show-hide.ng-hide-add,
.animate-show-hide.ng-hide-remove {
  -webkit-transition:all linear 0.5s;
  -moz-transition:all linear 0.5s;
  -o-transition:all linear 0.5s;
  transition:all linear 0.5s;

  /*下記の定義に注目(詳細は後述)*/
  display:block!important;
}
/* ng-hide適用(=非表示)の終点となるスタイル */
/* また、ng-hide-removeに移行する際の始点のスタイルでもある */
.animate-show-hide.ng-hide-add.ng-hide-add-active,
.animate-show-hide.ng-hide-remove {
  opacity:0;
}
/* ng-hide削除(=表示)して到達するスタイル */
/* また、ng-hide-addに移行する際の始点のスタイルでもある */
.animate-show-hide.ng-hide-remove.ng-hide-remove-active,
.animate-show-hide.ng-hide-add {
  opacity:1;
}
</style>
    <script src="http://code.angularjs.org/1.2.3/angular.min.js"></script>
    <script src="http://code.angularjs.org/1.2.3/angular-animate.min.js"></script>
<script>angular.module('myApp', ['ngAnimate'])
</script>
  </head>
  <body>
    <div>
      <label>
        <input type="checkbox" ng-model="checked">表示する
      </label>
      <!-- animate-show-hideクラスは、
           基本的なスタイル適用の他、
           アニメーションするクラスを特定するための、
           マーキングのような役割に使用されています。 -->
      <div class="check-element animate-show-hide" ng-show="checked">
        表示!
      </div>
    </div>
  </body>
</html>

ng-repeatのアニメーション

次はng-repeatのアニメーションの仕組みについて確認してみます。 ここではフィルターによって、項目が表示される際にはng-enterが非表示にされる際にはng-leaveのclassの付け外しが行われます。 この2つについては、ngShowでのaddとremoveと似ているので特に問題ないと思います。 問題なのはng-moveで、「上へ」ボタンで移動する要素に割り当てられるのだなと予測はすぐ付きますが、 top: -40px;といった定義があり、 このCSSを見て何故こうするのかよく分からないという方が多いと思います。 これについて、次のセクションで解説していきます。

<!doctype html>
<html ng-app="myApp">
  <head>
    <link rel="stylesheet" type="text/css" href="style.css">
    <script src="http://code.angularjs.org/1.2.3/angular.min.js"></script>
    <script src="http://code.angularjs.org/1.2.3/angular-animate.min.js"></script>
    <script src="script.js"></script>
  </head>
  <body>
    <div ng-controller="friendsController">
      お友達リスト : 計{{friends.length}}人:
      <input type="search" ng-model="q" placeholder="条件絞込み" />
      <ul class="example-animate-container">
        <li class="animate-repeat"
          ng-repeat="friend in friends | filter:q">
          [{{$index + 1}}] {{friend.name}}さんは、
          {{friend.age}}歳です。
          <button ng-click="friendUp($index)">上へ</button>
        </li>
      </ul>
    </div>
  </body>
</html>
angular.module('myApp', ['ngAnimate'])
  .controller('friendsController', ['$scope', function($scope){
    $scope.friends = [
      {name:'山田一郎', age:25, gender:'boy'},
      {name:'山田花子', age:30, gender:'girl'},
      {name:'鈴木一郎', age:26, gender:'boy'},
      {name:'鈴木次郎', age:32, gender:'boy'},
      {name:'鈴木三郎', age:24, gender:'boy'},
      {name:'鈴木花子', age:29, gender:'girl'},
    ];
    $scope.friendUp = function(index){
      if (index <= 0 || index >= $scope.friends.length){
        return;
      }
      var temp = $scope.friends[index];
      $scope.friends[index] = $scope.friends[index - 1];
      $scope.friends[index - 1] = temp;
    }
  }]);
.example-animate-container {
  background:white;
  border:1px solid black;
  list-style:none;
  margin:0;
  padding:0 10px;
}

.animate-repeat {
  line-height:40px;
  list-style:none;
  box-sizing:border-box;
}


/* アニメーション定義 */
/* 移動・追加・除去時のアニメーションを定義 */
.animate-repeat.ng-move,
.animate-repeat.ng-enter,
.animate-repeat.ng-leave {
  -webkit-transition:all linear 0.5s;
  -moz-transition:all linear 0.5s;
  -o-transition:all linear 0.5s;
  transition:all linear 0.5s;
}

/* ng-leave適用(=除去)の終点となるスタイル */
/* また、ng-enterに移行する際の始点のスタイルでもある */
.animate-repeat.ng-leave.ng-leave-active,
.animate-repeat.ng-enter {
  opacity:0;
  max-height:0;
}

/* ng-enter適用(=追加)の終点となるスタイル */
/* また、ng-leaveに移行する際の始点のスタイルでもある */
.animate-repeat.ng-leave,
.animate-repeat.ng-enter.ng-enter-active {
  opacity:1;
  max-height:40px;
}

/* 「上へ」で移動する際に最終的に上になる要素の始点となるスタイル */
.animate-repeat.ng-move {
  position: relative;
  top: 40px;
}
/* 「上へ」で移動する際に最終的に上になる要素の終点となるスタイル */
.animate-repeat.ng-move.ng-move-active {
  position: relative;
  top: 0;
}

/* 「上へ」で移動する際に最終的に下になる要素の始点となるスタイル */
.animate-repeat.ng-move + li {
  /* ここにtransitionを持つことは出来ない? */
  position: relative;
  top: -40px;
}

/* 「上へ」で移動する際に最終的に下になる要素の終点となるスタイル */
.animate-repeat.ng-move.ng-move-active + li {
  -webkit-transition:all linear 0.5s;
  -moz-transition:all linear 0.5s;
  -o-transition:all linear 0.5s;
  transition:all linear 0.5s;
  position: relative;
  top: 0;
}
<!doctype html>
<html ng-app="myApp">
  <head>
<style type="text/css">.example-animate-container {
  background:white;
  border:1px solid black;
  list-style:none;
  margin:0;
  padding:0 10px;
}

.animate-repeat {
  line-height:40px;
  list-style:none;
  box-sizing:border-box;
}


/* アニメーション定義 */
/* 移動・追加・除去時のアニメーションを定義 */
.animate-repeat.ng-move,
.animate-repeat.ng-enter,
.animate-repeat.ng-leave {
  -webkit-transition:all linear 0.5s;
  -moz-transition:all linear 0.5s;
  -o-transition:all linear 0.5s;
  transition:all linear 0.5s;
}

/* ng-leave適用(=除去)の終点となるスタイル */
/* また、ng-enterに移行する際の始点のスタイルでもある */
.animate-repeat.ng-leave.ng-leave-active,
.animate-repeat.ng-enter {
  opacity:0;
  max-height:0;
}

/* ng-enter適用(=追加)の終点となるスタイル */
/* また、ng-leaveに移行する際の始点のスタイルでもある */
.animate-repeat.ng-leave,
.animate-repeat.ng-enter.ng-enter-active {
  opacity:1;
  max-height:40px;
}

/* 「上へ」で移動する際に最終的に上になる要素の始点となるスタイル */
.animate-repeat.ng-move {
  position: relative;
  top: 40px;
}
/* 「上へ」で移動する際に最終的に上になる要素の終点となるスタイル */
.animate-repeat.ng-move.ng-move-active {
  position: relative;
  top: 0;
}

/* 「上へ」で移動する際に最終的に下になる要素の始点となるスタイル */
.animate-repeat.ng-move + li {
  /* ここにtransitionを持つことは出来ない? */
  position: relative;
  top: -40px;
}

/* 「上へ」で移動する際に最終的に下になる要素の終点となるスタイル */
.animate-repeat.ng-move.ng-move-active + li {
  -webkit-transition:all linear 0.5s;
  -moz-transition:all linear 0.5s;
  -o-transition:all linear 0.5s;
  transition:all linear 0.5s;
  position: relative;
  top: 0;
}
</style>
    <script src="http://code.angularjs.org/1.2.3/angular.min.js"></script>
    <script src="http://code.angularjs.org/1.2.3/angular-animate.min.js"></script>
<script>angular.module('myApp', ['ngAnimate'])
  .controller('friendsController', ['$scope', function($scope){
    $scope.friends = [
      {name:'山田一郎', age:25, gender:'boy'},
      {name:'山田花子', age:30, gender:'girl'},
      {name:'鈴木一郎', age:26, gender:'boy'},
      {name:'鈴木次郎', age:32, gender:'boy'},
      {name:'鈴木三郎', age:24, gender:'boy'},
      {name:'鈴木花子', age:29, gender:'girl'},
    ];
    $scope.friendUp = function(index){
      if (index <= 0 || index >= $scope.friends.length){
        return;
      }
      var temp = $scope.friends[index];
      $scope.friends[index] = $scope.friends[index - 1];
      $scope.friends[index - 1] = temp;
    }
  }]);
</script>
  </head>
  <body>
    <div ng-controller="friendsController">
      お友達リスト : 計{{friends.length}}人:
      <input type="search" ng-model="q" placeholder="条件絞込み" />
      <ul class="example-animate-container">
        <li class="animate-repeat"
          ng-repeat="friend in friends | filter:q">
          [{{$index + 1}}] {{friend.name}}さんは、
          {{friend.age}}歳です。
          <button ng-click="friendUp($index)">上へ</button>
        </li>
      </ul>
    </div>
  </body>
</html>

アニメーションの仕組み

シンプルに考えるために、再度前述したng-showの例で考えてみます。 そもそもアニメーションを適用した場合でも、チェックボックスのチェックを外した瞬間に、(おそらく).ng-hideのclass属性を割り当てています。 (.ng-hideには、display: none !important;のCSSスタイルが割り当てられています。) それにより本来であれば即座に要素は非表示なっているはずですが、(おそらく)同時または直後に.ng-hide-addが割り当てられ、 ここに定義されているdisplay:block!important;が再度上書きしているものと思われます。手順を下記にまとめてみます。

  1. チェックが外され、Angularによって.ng-hideのclassが割り当てられます。
  2. 実際は即座に非表示になっているところですが、これではアニメーションにすることが出来ません。
  3. アニメーションをするために.ng-hideの非表示スタイルに、Angularが更に.ng-hide-addの表示スタイルを被せます。 これによりスタイルが変更前の元々の状態に戻されます。"変化後のスタイルをキャンセルするスタイルが割り当てられた"、とも言えます。
  4. また、.ng-hide-addには、opacity:1;のスタイルも割り当てられていて、 これがアニメーションの始点となります。
  5. 次に、Angularによって.ng-hide-add-activeが割り当てられ、これにはopacity:0;が割り当てられています。 ここでopacity:1;からopacity:0;へ向けてのアニメーションがトリガされます。
  6. Angularはアニメーションが完了すると、.ng-hide-add.ng-hide-add-activeのclassを削除します。

3番目の"変化後のスタイルをキャンセルするスタイルが割り当てられた"という部分が肝です。 つまり、変化後の状態を擬似的に元に戻す(キャンセルする)スタイルを定義すれば良いということになります。

ng-moveで、気を付けなければいけないのは+ liの部分です。 これは「上へ」ボタンを押された際に動く要素が1つだけでなく、入れ替わりで動くもう1つの要素の指定になります。 ngRepeatはng-moveを動く2つの要素のうち、(おそらくですが…)必ず本来入れ替わった後に上にあるはずの要素にだけ割り当てるので、 隣接セレクタである+で直下のli要素を指定しているわけです。 このようにng-moveはちょっと厄介です。 公式サイトで公言しているDOMをたくさん動かすようなプロジェクトには向かないというのには、こういった事も含んでの事なのかもしれません。

それでは上記のng-moveの仕組みを解説します。 公式ドキュメントでの詳細な解説を見つけることが出来なかったため、 あくまで検証による予測の上での解説になりますが、その点はご了承ください。

  1. 「上へ」ボタンが押され、双方向バインディングによって即座に要素の場所が入れ替わります。 その際に、入れ替わって上に来た要素に対して.ng-moveのclassが割り当てられます。
  2. このままではアニメーションが出来ないため、予め.ng-moveに対して入れ替わった後の位置を、 入れ替わる前に戻すスタイルを適用しています。 すぐ下の+ liにも同様に元に戻すスタイルが適用されています。
  3. 次に、Angularによって.ng-move-activeが割り当てられ、これにはtop:0;が割り当てられています。 ここでtop:40;(+liには-40)からtop:0;へ向けてのアニメーションがトリガされます。
  4. Angularはアニメーションが完了すると、.ng-move.ng-move-activeのclassを削除します。

まとめ

AngularJSアニメーションの仕組みについて解説してきました。 公式サイトのガイドのアニメーションを翻訳したものについては、下記のリンク先を参照してください。 CSS3が使用できない場合のための、javaScriptによるフォールバックの解説もあります。
AngularJSのアニメーション

また、このページではng-showとng-repeatについて解説してきましたが、他にも組み込みディレクティブでアニメーションをサポートされるものがあります。 これは上記リンク先の「どのディレクティブがアニメーションをサポートするのか?」のセクションを参照してください。

下記はアニメーション用のclassを付け外しする$animateサービスの解説を翻訳したものになります。
$animate

.ng-moveの事は公式サイトでの詳細な解説が見つけられなかったため挙動がよく分からず、 Stack Overflowのおかげでやっと仕組みを理解することが出来ました…。

 Back to top

© 2010 - 2017 STUDIO KINGDOM