Wednesday, November 27, 2013

Animate ngView transitions in AngularJS

Let me just start this post by saying that AngularJS is awesome! I have been playing with Angular recently and I must say that it has the things I loved in Flex and I missed in JavaScript frameworks. Things like declarative UI, bi-directional binding and a lot more, but now I will not write why Angular is cool.

This post will be covering something that I recently wanted to implement, but it seemed that it not so straight forward - animating ng-views when they change.

http://maverix7.appspot.com/files/ngview/index.html#/viewA


An example of such transitions you can check here  http://code.angularjs.org/1.1.4/docs/api/ng.directive:ngView#animations . As you can see when you click on a link, the view is changed by making this slick slide animation. One thing that we notice is that the view is sliding only in one direction, but it will be really nice to have it both ways, based on the hierarchy of the views.

In this great post http://blog.revolunet.com/blog/2013/04/30/angularjs-animations-mobile-applications/ we can see how we can make the sliding bi-directional, and it is looking much better.

However we also want the app to respond to browser's Back and Forward button, so we have to handle route changes and switch the animation CSS class based on where we were now and where are we going, so we can have this next and previous interaction.

In AngularJS this is easy we just add a route change handler

var oldLocation = '';
$scope.$on('$routeChangeStart', function(angularEvent, next) {
  console.log("routeChangeStart");
  var isDownwards = true;
  if (next && next.$$route) {
    var newLocation = next.$$route.originalPath;
    if (oldLocation !== newLocation && oldLocation.indexOf(newLocation) !== -1) {
      isDownwards = false;
    }
    
    oldLocation = newLocation;
  }
  
  $scope.isDownwards = isDownwards;

And having the following set for our view
<div ng-view ng-class="{slide: true, left: isDownwards, right: !isDownwards}"></div>

We have some kind of sliding animation when we switch between views. But there is a "gotcha". When the view is changed, the old view slides to the opposite direction, say "right" and disappears, then the new view slides from the "right" direction, where we expect the old view to slide to the "left" and disappear and the new view to appear and slide from the "right". This is because both the switching of views and assigning of "isDownwards" to the model is done in the same $digest cycle, and therefore the old view, when removed, does not have the new direction class applied, and is animated to the opposite (old) direction.

So in order to fix this we have to switch the views, after the cycle in which the "isDownwards" is applied to the model. AFAIK to invoke something after the current digest cycle you can invoke $timeout and pass 0 for delay. And to delay the view switching, we can pass a resolve map to the route parameter of $routeProvider, and if any of it's dependencies return a promise, Angular will wait until this promise is resolved. So our code for this will look like:

var resolve = {
  delay: function($q, $timeout) {
    var delay = $q.defer();
    $timeout(delay.resolve, 0, false);
    return delay.promise;
  }
};

angular.module('viewTransitionApp', ['ngRoute', 'ngAnimate'])
  .config(function ($routeProvider) {
    $routeProvider
      .when('/viewA', {
        templateUrl: 'viewA.html',
        resolve: resolve
      });
  });


Having all this, we can have an application with views that when switched are animated in a given direction, and this direction is based on the current location, and the location that we are changing to. Of course this sliding animation can be changed with any other animation that makes sense to have forward and backward behavior.

The final example can be seen here:
http://maverix7.appspot.com/files/ngview/index.html

You can right click and view its source or see this gist https://gist.github.com/tgeorgiev/7667648

5 comments:

  1. First of all, thank you! This saved me a few hours trying to figure it out on my own.

    Rather than using the resolve/$timeout functions to force the isDownloads change to happen in the same $digest cycle (which doesn't quite work when using ui-router with nested states), you can just wrap the call in $scope.$apply() -

    $scope.$apply($scope.isDownwards = isDownwards);

    I've tested that change with your example, and it still works like a charm.

    ReplyDelete
  2. I am really glad that you found this post helpful!

    I checked the solution that you provided and there is one issue. It does work as expected, without using the resolve function, however when calling $scope.$apply and error is thrown indicating that we are already executing in the digest cycle - "[$rootScope:inprog] $digest already in progress". I think it is working but as a side effect of this error, because $scope.$apply expects a function, but in this case we are passing an expression, and the expression is firstly evaluated and then the error is thrown.
    Can you re-check this?

    Thanks for your feedback!

    ReplyDelete
  3. Thank you for this post! It saved me from the really bad hack I was going down...

    ReplyDelete
  4. Hi!

    Thanks a lot for this very fascinating tutorial.

    Unfortunately, I can't fix the "leave" animation so that it would work properly if I hit the "back" button.

    Could you maybe elaborate a bit on what's happening here? It seems to me that the controller is fired before the resolve. Once the controller modified the scope "isDownward" variable, how can we retro-actively change the isDownward variable in the previous view?

    I am not quite clear what the resolve is doing exactly and how it's helping (while definitely it's working on your github example)

    Sorry about the noob question and thanks again for this great tutorial!

    Thomas

    ReplyDelete
    Replies
    1. Hi,

      The trick is that the animation is applied to both the old and new views, so the old slides to left (or right) and so does the new. The delay in the resolve, causes the actual new view to be instantiated and placed after the direction was picked up and the older view has started it's animation. I hope it's more clear.

      I just found that the example is not showing as there are some problems with the CDN

      Delete