5

I want to limit as much possible the "flickering" in my AngularJS application. I use resolve: from the router (works with ngRouter and ui-router) to load the data so that everything needed to display my page is available before changing the route.

Common example:

.state('recipes.category', {
    url: '/:cat',
    templateUrl: '/partials/recipes.category.html',
    controller: 'RecipesCategoryCtrl as recipeList',
    resolve: {
         category: ['$http','$stateParams', function ($http, $stateParams) {
             return $http.get('/recipes/' + $stateParams.cat).then(function(data) { return data.data; });
         }]
     }
});

Now my RecipesCategoryCtrl controller can load category and the promise will be directly resolved.

Is there a way to embed the loading code directly inside my controller? Or somewhere else more "clean"? I don't like having too much logic inside the route definition…

Something like:

.state('recipes.category', {
    url: '/:cat',
    templateUrl: '/partials/recipes.category.html',
    controller: 'RecipesCategoryCtrl as recipeList',
    resolve: 'recipeList.resolve()' // something related to RecipesCategoryCtrl and "idiomatic" AngularJS
});
Thomas
  • 1,360
  • 1
  • 9
  • 15

4 Answers4

4

Maybe this is not what you are looking for, but you can move some logic from the router to your controller using like:

//prepare content
$scope.$on('$viewContentLoading', function(event) {

     //show a progress bar whatever

     //fetch/request your data asynchronously

     //when promise resolves, add your data to $scope

});


//remove spinning loader when view is ready
$scope.$on('$viewContentLoaded', function(event) {

    //remove progress bar

});

Basically the user is sent to the page first, then the dynamic content is loaded, then you show the full page.

This might be completely off topic, but I am using this approach and works ace. It is also a good practice to display the new view first and then get the data. There is a really nice video here explaining why. The video is about SPA with Phonegap, but there are lots of tips about SPA in general. The interesting part (for this specific case) is at 1hr 1 minute in roughly.

Edit: if $viewContentLoading does not get fired, look here. You might need to place all your logic inside $viewContentLoaded

This is what I am currently doing:

 $scope.$on('$viewContentLoaded', function(event) {

            //show loader while we are preparing view 
            notifications.showProgressDialog();

            //get data
            getData().then(function(data) {

                //bind data to view
                $scope.data = data;

                //remove spinning loader as view is ready
                notifications.hideProgressDialog();
            });
});

I am still not 100% happy with $scope.data = data; as if my data object is big, I might hide the progress dialog before the binding with the view is finished, therefore some flickering could occur. The solution is to use custom directives handling scope.$last, see this answer (even though binding to $stateChangeSuccess could be enough, look here)

This is how ui-router currently works when changing state/view:

  1. $viewContentLoading gets broadcasted
  2. Dependencies under the resolve section are resolved
  3. Controller gets instantiated and resolve dependencies injected.
  4. $viewContentLoaded is emitted.
  5. The controller reacts to $viewContentLoaded (when it is setup as the delegate to dispatch those events of course).
Community
  • 1
  • 1
Paranoid Android
  • 4,672
  • 11
  • 54
  • 73
3

This is common problem, which often occurs in applications using ngRoute or uiRouter. In such cases I usually use caching.

For example if you use Active Record like pattern for communication with our business layer you can proceed as follows:

States definition

//...
.state('users', {
  url: '/users',
  templateUrl: '/partials/users.html',
  controller: 'UsersCtrl',
  resolve: {
    users: ['Users', function (Users) {
      return Users.getList();
    }]
  }
})
.state('user', {
  url: '/users/:id',
  templateUrl: '/partials/user.html',
  controller: 'UserCtrl',
  resolve: {
    users: ['Users', '$stateParams', function (Users, $stateParams) {
      return Users.get($stateParams.id);
    }]
  }
});

Service definition

myModule.factory('User', function ($q, $http) {
  var cachedUsers = null;
  function User() {
  }

  User.getList = function () {
    if (cachedUsers) {
      return $q.when(cachedUsers);
    } else {
      return $http.get('/users')
          .then(function (resp) {
            cachedUsers = resp.data;
            return cachedUsers;
          });
    }
  };

  User.get = function (id) {
    if (cachedUsers && cachedUsers[id]) {
      return $q.when(cachedUsers[id]);
    } else {
      return $http.get('/users/' + id)
         .then(function (resp) {
           cachedUsers = cachedUsers || {};
           cachedUsers[id] = resp.data;
           return cachedUsers[id];
         });

    }
  };
  return User;
});

Controllers definition

myModule.controller('UsersCtrl', function ($scope, users) {
  $scope.users = data;
});

myModule.controller('UserCtrl', function ($scope, user) {
  $scope.users = data;
});

This way your application caches the result from the request and in each subsequent route change it gets the requested value by the in-memory cache. Since this is a dummy example I'd recommend you to use the built-in AngularJS caching mechanism since it takes advantage of different HTTP headers.

Minko Gechev
  • 25,304
  • 9
  • 61
  • 68
2

You can inject a service in your controller which does the $http.get to clean up your code.

.state('recipes.category', {
  url: '/:cat',
  templateUrl: '/partials/recipes.category.html',
  controller: 'RecipesCategoryCtrl as recipeList',
  resolve: {
       category: ['recipeService','$stateParams', function (recipeService, $stateParams) {
           return recipeService.get($stateParams.cat)
       }]
  }
});
philippd
  • 606
  • 5
  • 6
  • 1
    I was more thinking about a way to embed the resolving logic inside the controller. I will update the original post. – Thomas Mar 10 '14 at 08:41
  • 2
    No, it seems that this is not possible in ui-router and also not in ngRouter. The AngularJS team is aware that this feels 'misplaced' as you can see in the weaknesses section of the current routing implementation in their doc about routing in AngularJS 2.0: https://t.co/d6qhdGM2VY So it seems to me that moving the logic to a service is the best you can do right now... – philippd Mar 17 '14 at 10:28
  • @philippd which is quite a bummer! – Paranoid Android Sep 25 '14 at 12:16
2

I came here looking for info about how ui-router handles resolve, and if it's different than how ngRoute does. I didn't find the answer to that, but I recently fought for hours with the same problem you're having, so I thought this might be useful. The caveat is that the solution was for ngRoute and not ui-router, so YMMV (and I'd love to hear if/how it varies, in fact).

I discovered that the best way to encapsulate code in resolve is to use a factory (or service). When you write an anonymous function into resolve:, as you have above, you're essentially creating a service on the fly. Hence, you can refer to an existing factory (or service) -- as long as it uses promises (from $q). You're then telling the router to wait for any promise from that factory to resolve.

In my case, I was using a custom factory built on top of angular-pouch -- for the sake of simplicity, let's just say I was accessing CouchDB via http. My factory used deferred objects that got resolved.

I learned a lot from this article: http://www.undefinednull.com/2014/02/17/resolve-in-angularjs-routes-explained-as-story. But here's my code:

Router

  .config(function ($routeProvider) {
     $routeProvider
     .when('/',{
        controller: 'TestController',
        templateUrl: 'templates/myview.html',
        resolve: {"pouchDbFactory":"pouchDbFactory"}
    })
    .otherwise({ redirectTo: '/' });
 })
 /*snip*/

Factory

.factory('pouchDbFactory', function (myPouch,$q) {
    var myDefer = $q.defer();
    myDefer.resolve({
        db: myPouch,
        all: function(){
            // another factory is doing the work behind the scenes
           // but here's where it returns a pouchDB promise
           return this.db.allDocs({include_docs: true});        
        },
        /* more functions, etc. */            
     });
     // Return the promise, along with its result
     return myDefer.promise;
    });

Controller (here's where the resolve logic gets encapsulated)

.controller('TestController',function ($scope,pouchDbFactory){
    // this variable makes it to the template right away
    $scope.foo = "Hello Foo"; 

    pouchDbFactory.all().then(
        function(result){
          /*  this one gets added to the template once it's fetched
              AND the template isn't rendered until that's done -- 
              because it's pouchDbFactory, from "resolve:" property above
          */
          $scope.username = result.username;
        }
    );
 }) 
 /*snip*/

I'd be interested to know what approaches have worked for others.

fredrover
  • 2,997
  • 2
  • 17
  • 24