How can I create an AngularJS directive to animate an element on data change?

Problem

In the app I'm building I have a headline that shows on the screen. It's bound to a property in my scope. I'm using ng-bind-html to ease the way I'm building the headline string. The HTML looks something like this:

<div class="headline slide-and-fade-up" animate-on-change="headline" ng-bind-html="headline"></div>

animate-change is a directive I'm trying to write that will cause the headline to animate when it changed. The requested animation is for the old animation to move up and off the screen while the new one moves up from the bottom and into place. I've built an animation that is compatible with ng-animate called slide-and-fade-up that does what I want it to do. Since I need to animate the old value leaving as the new one comes on, I need to duplicate the element and have one with the old value and one with the new both in the DOM and then have the old one leave.

So far, my directive looks like this:

angular.module('myApp.directives').directive('animateOnChange', ['$animate',
    function($animate) {
        return {
            restrict: 'A',
            link: function(scope, element, attrs) {
                scope.$watch(attrs.animateOnChange, function(newValue, oldValue) {
                    if (newValue !== undefined && oldValue !== undefined && newValue !== oldValue) {
                        $animate.enter(element.clone(), element.parent(), element);
                        $animate.leave(element);
                    }
                });
            }
        }
    }
]);

This has a couple of problems:

  1. The cloned element does not seem to have a "live" directive on it, i.e. it exists in the DOM but doesn't actually fire this code which means the animation fires exactly once then never again
  2. The element that enters has the old value in it and the enter that leaves has the new value in it. I have no idea why this would be the case but it's basically exactly backward

How can I finish this directive to get it to do what I want? I did get it to work by using an ng-if and watching for a data change and toggling a boolean on, waiting a short time and then toggling it back on. That worked, but seemed overly hacky to me compared to a self contained directive with $animate hooks. Thanks for the help.

Problem courtesy of: Morinar

Solution

Compile the cloned content you're passing into $animate.enter, then set the innerHTML of the original element to the "oldValue" provided as an argument to your $watch just before calling $animate.leave:

.directive('animateOnChange', function($animate, $compile) {
  var watchers = {};
  return {
    restrict: 'A',
    link: function(scope, element, attrs) {
      // deregister `$watch`er if one already exists
      watchers[scope.$id] && watchers[scope.$id]();  
      watchers[scope.$id] = scope.$watch(attrs.animateOnChange, function(newValue, oldValue) {          
        if (newValue !== oldValue) {
          $animate.enter($compile(element.clone())(scope), element.parent(), element);
          element.html(oldValue);
          $animate.leave(element); 
        }
      });
    }
  }
})

Note: since the directive is recompiled and linked each time the $watch expression change test is met, you'll want to account for the fact that new watches will be created each time as well.

In my example, I store the deregistration function in an object available from any compiled directive instance and call it before I create any new watcher beyond the first. If you don't do this, you will see double the number watchers each and every time the directive compiles... obviously not a good thing.

Fork of your JS Bin

Solution courtesy of: Marc Kline

Discussion

The link function runs when the element is initially placed on the page, during its compilation phase.

$animate.leave() will, upon completion, remove the leaving element from the DOM, and this is the element that you attached your behavior to. This is likely why it only happens once.

For starters, you would need to introduce a variable to store the "current" element and than manage that on each transition.

Now the problem is that calling element.clone() is problematic. All of the Angular goodness doesn't get attached to it by magic. This is running during a watch phase, which means the scope (data model) has changed but the DOM may not necessarily have updated yet. So you clone the element before Angular updates it, and the clone becomes "stupid." That's the one, with the old value, that you see entering.

Meanwhile, a split second after starting the animation, Angular catches up and updates the element (that is now on its way out) to its new value, just before it is destroyed.

You may need to take over complete responsibility for rendering the content, and not use ng-bind-html. Just take the elements you're creating and call element.html(newValue), which you already have since you're watching that expression. Or you could have the cloned (dumb) element be the one that leaves, and allow the Angularified element to enter, assuming when you begin the animation that the value will change "really soon now."

Discussion courtesy of: David Boike

This recipe can be found in it's original form on Stack Over Flow.