AngularJS でモデルの変化時にフェードイン・アウトするディレクティブを作る

勉強がてら AngularJS の簡単なディレクティブを作ってみました。

## デモ

 - [http://jsbin.com/nivud/4/](http://jsbin.com/nivud/4/)

## 使い方

要素に app-fade 属性でモデルを指定すると、モデルの変化に連動してフェードイン・フェードアウトします。
次の例では、チェックボックスの ON/OFF に連動して span がフェードイン・フェードアウトします。

```html
<label><input type="checkbox" ng-model="check" ng-init="check=true"> Check</label>
<span app-fade="check">Checked</span>
```

app-fade 属性には式も記述できます。

```html
<label><input type="checkbox" ng-model="check2"> Check</label>
<span app-fade="!check2">Unchecked</span>
```
 
app-fade-duration 属性でフェードイン・フェードアウトのスピードを指定できます。
次の例ではゆっくりフェードイン・フェードアウトします。

```html
<label><input type="checkbox" ng-model="slowly"> Slowly</label>
<span app-fade="slowly" app-fade-duration="1000">Checked</span>
```

app-fade 属性に指定したモデルを要素の内部で使用するときは注意が必要です。
次の例ではチェックボックスの value を要素の内部で表示していますが、とても不自然な動きになります。

```html
<label><input type="checkbox" ng-model="yesno" ng-true-value="Yes" ng-false-value="No" ng-init="yesno='No'"> Yes/No</label>
<span app-fade="yesno">{{yesno}}</span>
```

これはフェードアウトの開始時点で既に yesno モデルの値は変更されているため・・・

 1. span の内容を変更
 2. フェードアウト
 3. フェードイン

という順番で処理されているためです。

次のように要素の内部で appFade.value を使うと自然にフェードアウト・フェードインされます。
appFade.value には app-fade 属性で指定されたモデルの値が入っています。

```html
<label><input type="checkbox" ng-model="onoff" ng-true-value="On" ng-false-value="Off"  ng-init="onoff='Off'"> On/Off</label>
<span app-fade="onoff">{{appFade.value}}</span>
```

次のようにボタンのクリックでモデルに値をセットし、メッセージをフェードインさせることができます。
これだけなら ngShow と ngAnimation でもできますね。

```html
<button class="btn btn-default" ng-click="message='Pushed Button'">Button</button>
<span app-fade="message">{{appFade.value}}</span>
```

上の例だと、ボタンを何回クリックしてもモデルの値は変化しないため、フェードインするのは最初の１回だけです。なのでクリックしている感がありません。

次のように app-fade にオブジェクトを指定し、ボタンのクリックでモデルをオブジェクトごと上書きすれば、ボタンをクリックする度にメッセージがフェードインします。

```html
<button class="btn btn-default" ng-click="msg={message:'Pushed Button'}">Button</button>
<span app-fade="msg">{{appFade.value.message}}</span>
```

## 作り方

ディレクティブの全文は次の通りです。

```js
angular.module('app', []).directive('appFade', function($timeout){

    function appFadeLink(scope, elem, attr)
    {
        var duration = attr.appFadeDuration - 0 || 200;

        scope.appFade = {
            value: scope.$eval(attr.appFade)
        };

        if (scope.appFade.value)
        {
            elem.show();
        }
        else
        {
            elem.hide();
        }

        var initializing = true;

        scope.$watch(attr.appFade, function(value){
            if (initializing)
            {
                $timeout(function(){ initializing = false });
            }
            else
            {
                elem.stop(true).fadeOut(duration).queue(function(){
                    scope.appFade = { value: value };
                    scope.$$phase || scope.$apply();
                    angular.element(this).dequeue();
                });

                if (value)
                {
                    elem.fadeIn(duration);
                }
            }
        });
    }

    return {
        restrict: 'A',
        scope: true,
        link: appFadeLink
    };
});
```

要点を説明します。

```js
var duration = attr.appFadeDuration - 0 || 200;
```

`attrs.appFadeDuration` で `app-fade-duration` 属性の値を取り出しています。

```js
scope.appFade = {
    value: scope.$eval(attr.appFade)
};
```

要素の内部で `appFade.value` が使えるようにスコープに追加します。

```js
if (scope.appFade.value)
{
    elem.show();
}
else
{
    elem.hide();
}
```

ページの最初の表示にフェードインするのは不自然なため、最初は普通に表示させます。

```js
var initializing = true;

scope.$watch(attr.appFade, function(value){
    if (initializing)
    {
        $timeout(function(){ initializing = false });
    }
    else
    {
        // ...
    }
});
```

app-fade 属性に指定された式を監視してフェードイン・フェードアウトさせているのですが、最初の１回目は無視します。

- [How do I ignore the initial load when watching model changes in AngularJS? - Stack Overflow](http://stackoverflow.com/questions/16947771/how-do-i-ignore-the-initial-load-when-watching-model-changes-in-angularjs "How do I ignore the initial load when watching model changes in AngularJS? - Stack Overflow")

```js
scope.$watch(attr.appFade, function(value){
    // ...
    else
    {
        elem.stop(true).fadeOut(duration).queue(function(){
            scope.appFade = { value: value };
            scope.$$phase || scope.$apply();
            angular.element(this).dequeue();
        });

        if (value)
        {
            elem.fadeIn(duration);
        }
    }
});
```

jQuery でフェードアウト・フェードインのアニメーションをします。

フェードアウトの完了時にスコープの appFade を更新しますが、このタイニングは AngularJS に補足されないので `scope.$apply()` を呼びます。

が、たまに `$digest already in progress` というエラーになるので `scope.$$phase` が空かどうかで呼び分けます。

- [angularjs - Prevent error $digest already in progress when calling $scope.$apply() - Stack Overflow](http://stackoverflow.com/questions/12729122/prevent-error-digest-already-in-progress-when-calling-scope-apply "angularjs - Prevent error $digest already in progress when calling $scope.$apply() - Stack Overflow")

```js
    return {
        restrict: 'A',
        scope: true,
        link: appFadeLink
    };
```

ディレクティブの定義を返します。スコープに `appFade` プロパティを追加しているので `scope: false` だと親のスコープが更新されてしまいます。
`scope: true` なら親スコープを継承したディレクティブのスコープが作成されるので親スコープには影響しません。

## 別の実装

要素の内部で app-fade 属性に指定したモデルを表示させると処理順が不自然になる問題について、次のようにディレクティブのスコープで親のスコープのモデルを上書きし、親のスコープを直接監視する方法を考えましたが・・・

```js
angular.module('app', []).directive('appFade', function($timeout){

    function appFadeLink(scope, elem, attr)
    {
        var duration = attr.appFadeDuration - 0 || 200;
        var name = attr.appFade;

        function watch()
        {
            return scope.$parent.$eval(name);
        }

        scope[name] = watch();

        if (scope[name])
        {
            elem.show();
        }
        else
        {
            elem.hide();
        }

        var initializing = true;

        scope.$watch(watch, function(value){
            if (initializing)
            {
                $timeout(function() { initializing = false });
            }
            else
            {
                elem.stop(true).fadeOut(duration).queue(function(){
                    scope[name] = value;
                    scope.$$phase || scope.$apply();
                    angular.element(this).dequeue();
                });

                if (value)
                {
                    elem.fadeIn(duration);
                }
            }
        });
    }

    return {
        restrict: 'A',
        scope: true,
        link: appFadeLink
    };
});
```

app-fade 属性で式を指定すると、その式の文字列でディレクティブのスコープにプロパティが作成されます。

例えば `app-fade="!check2"` などとすると `!check2` というプロパティが追加されます。

なんとなく気持ち悪いのでこの方法は止めました。