Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Will Belden
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Will Belden@CaptainPalapa )

Preventing Animation During The Initial Render Of ngRepeat In AngularJS

By Ben Nadel on

As of AngularJS 1.2, directives that conditionally include content have also supported "enter" and "leave" style animations (with the help of the ngAnimate module). For most of these directives - like ngIf, ngInclude, and ngSwitch - animating the "enter" state makes a lot of sense. But, with ngRepeat, which is a much more stateful directive, animating the initial rendering of the data can lead to a kind of junky user experience. As such, it'd be nice to prevent the initial ngRepeat animation while still allowing animation on subsequent collection mutations.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

When an item is added to or removed from a list, it makes sense to have ngRepeat animate that transition. The movement pulls the user's eye to the change in data and allows the user to build a more natural, more organic mental model of what's going on. But, animating every item in the collection simultaneously on data-load makes little sense. It doesn't help the mental model and, in all likelihood, the animation was probably designed for a single-item change.

In order to prevent the animation of the initial ngRepeat data-load, we can leverage the fact that AngularJS prevents nested animations from taking place (by default). If we animate-in the parent container only once the ngRepeat data is ready, it will implicitly block the ngRepeat animation. Once everything is rendered, however, additional changes to the ngRepeat collection will naturally lead to "enter," "leave," and "move" style animations.

NOTE: Nested animations are allowed if the parent container has the ngAnimateChildren directive.

Now, just because we are "animating" the parent container, it doesn't actually mean that we're "animating" it. What I mean is, the transition can be instant. If, for example, we use ngIf to show the parent container, AngularJS will consider it "animating" even if we don't have a transition on it. As such, the ngIf can instantly show the nested ngRepeat while still blocking the initial ngRepeat animation.

AngularJS is pretty player that way!

To demonstrate this, I am going to use the ngIf directive to hide the ngRepeat directive until the ngRepeat data has loaded (via a $timeout-based network latency simulation). What you'll notice (if you watch the video or try the demo) is that the initial list shows up instantly while additional items are animated in:

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Preventing Animation During The Initial Render Of ngRepeat In AngularJS
  • </title>
  •  
  • <style type="text/css">
  •  
  • a[ ng-click ] {
  • color: red ;
  • cursor: pointer ;
  • text-decoration: underline ;
  • user-select: none ;
  • -moz-user-select: none ;
  • -webkit-user-select: none ;
  • }
  •  
  • li.friend.ng-enter {
  • opacity: 0.2 ;
  • padding-left: 30px ;
  • transition: all ease 250ms ;
  • }
  •  
  • li.friend.ng-enter-active {
  • opacity: 1.0 ;
  • padding-left: 0px ;
  • }
  •  
  • </style>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Preventing Animation During The Initial Render Of ngRepeat In AngularJS
  • </h1>
  •  
  • <h2>
  • Friends
  • </h2>
  •  
  • <form ng-submit="processForm()">
  •  
  • <input ng-model="form.name" type="text" />
  • <input type="submit" value="Add Friend" />
  •  
  • </form>
  •  
  • <!--
  • In order to prevent the initial ngRepeat render from animating each ngRepeat
  • clone, we have to hide the parent container (UL) until the ngRepeat data is
  • actually available. This way, when the data is available, we'll show the
  • container which, while instant, will get flagged as "animating". This will
  • prevent the ngRepeat directive from animating its elements during the initial
  • render of the friends collection.
  •  
  • Take care that not all "show" directives block / disable nested animations.
  • Class-based transition animations like ngShow and ngHide will not block or
  • cancel existing or successive animations. As such, using ngShow would not
  • prevent the ngRepeat animation from happening on the first load.
  • --
  • NOTE: I am using the term "initial render" very loosely to define the visual
  • rendering of the (not the initial linking of the directive).
  • -->
  • <ul ng-if="friends.length">
  •  
  • <!-- These will animate-in using CSS class-based animations. -->
  • <li
  • ng-repeat="friend in friends track by friend.id"
  • class="friend">
  •  
  • {{ friend.name }}
  •  
  • </li>
  •  
  • </ul>
  •  
  • <!-- Show the loading state if no friends yet. -->
  • <p ng-if="! friends">
  • <em>Loading data...</em>
  • </p>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.3.8.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs/angular-animate-1.3.8.min.js"></script>
  • <script type="text/javascript">
  •  
  • // Create an application module for our demo.
  • var app = angular.module( "Demo", [ "ngAnimate" ] );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I control the root of the application.
  • app.controller(
  • "AppController",
  • function( $scope, friendService ) {
  •  
  • // I will hold the collection of friends to render.
  • $scope.friends = null;
  •  
  • // I hold the ngModel data.
  • $scope.form = {
  • name: ""
  • };
  •  
  • // Initialize the local data store.
  • loadRemoteData();
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I process the form, adding a new friend.
  • $scope.processForm = function() {
  •  
  • if ( ! $scope.form.name ) {
  •  
  • return;
  •  
  • }
  •  
  • friendService.addFriend( $scope.form.name )
  • .then( loadRemoteData )
  • ;
  •  
  • $scope.form.name = "";
  •  
  • };
  •  
  •  
  • // I load the friends collection from the remote repository.
  • function loadRemoteData() {
  •  
  • friendService.getFriends()
  • .then(
  • function handleResolve( friends ) {
  •  
  • $scope.friends = friends;
  •  
  • }
  • )
  • ;
  • }
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I provide access to the "remote" friend repository.
  • // --
  • // NOTE: Obviously, this is not a remote service; but, I am putting the "get"
  • // behind a $timeout() so that we can see a bit of latency, which is where the
  • // animations need to be a bit more thoughtful.
  • app.factory(
  • "friendService",
  • function( $q, $timeout ) {
  •  
  • // Default our internal collection.
  • var friends = [
  • {
  • id: 1,
  • name: "Sarah"
  • },
  • {
  • id: 2,
  • name: "Heather"
  • }
  • ];
  •  
  • // Return the public API.
  • return({
  • addFriend: addFriend,
  • getFriends: getFriends
  • });
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I add a friend with the given name and return the new ID (promise).
  • function addFriend( name ) {
  •  
  • var friend = {
  • id: ( new Date() ).getTime(),
  • name: name
  • };
  •  
  • friends.push( friend );
  •  
  • return( $q.when( friend.id ) );
  •  
  • }
  •  
  •  
  • // I get all the friends (promise).
  • function getFriends() {
  •  
  • var deferred = $q.defer();
  •  
  • $timeout(
  • function() {
  •  
  • // NOTE: Return a COPY of the collection so that we don't
  • // break encapsulation and allow external forces to mutate
  • // our internal collection.
  • deferred.resolve( angular.copy( friends ) );
  •  
  • },
  • 350,
  •  
  • // We don't need to trigger a digest - $q will do that for us
  • // when we resolve the deferred value.
  • false
  • );
  •  
  • return( deferred.promise );
  •  
  • }
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

In the comments, I mention that the animation would not be blocked if you were to use the the ngShow or ngHide directives instead of ngIf. This is because ngShow and ngHide use CSS class-based transitions. Meaning, they work by adding and removing the ".ng-hide" CSS class, as opposed to creating and destroying actual DOM (Document Object Model) elements. Such class-based transitions do not prevent nested animations (from the documentation):

Class-based transitions refer to transition animations that are triggered when a CSS class is added to or removed from the element (via $animate.addClass, $animate.removeClass, $animate.setClass, or by directives such as ngClass, ngModel and form). They are different when compared to structural animations since they do not cancel existing animations nor do they block successive transitions from rendering on the same element. This distinction allows for multiple class-based transitions to be performed on the same element

When AngularJS 1.2 added animations, I was a bit concerned that the multi-transclusion limitation was overly problematic. But, the more I experiment with the ngAnimate module (which, admittedly, is very new to me), the more excited I get. The AngularJS team seems to have really thought about transitions very deeply.




Reader Comments

Your article is very good, very authoritative. But your blog is really not like, the layout is not the focus, it looks like a fee

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.