Mik378 Mik378 - 4 months ago 35
AngularJS Question

Apply loading spinner during ui-router resolve

resolve
property of
$routeProvider
allows to execute some jobs BEFORE corresponding view is rendered.

What if I want to display a spinner while those jobs are executed in order to increase user experience?

Indeed, otherwise the user would feel the application has been blocked since no view elements were displayed for some milliseconds > 600 for instance.

Of course, there was the way to define a global
div
element out of the current view to display in order to display the spinner thanks to the
$scope.$rootChangeStart
function.

But I don't want to hide the whole page with just a poor spinner in the middle.

I want some pages of my webapp differ regarding the way the loading is displayed.

I came across this interesting post containing the exact issue I described above:


That approach results in a horrible UI experience. The user clicks on
a button to refresh a list or something, and the entire screen gets
blanketed in a generic spinner because the library has no way of
showing a spinner just for the view(s) that are actually affected by
the state change. No thanks.

In any case, after I filed this issue, I realised that the "resolve"
feature is an anti-pattern. It waits for all the promises to resolve
then animates the state change. This is completely wrong - you want
your transition animations between states to run parallel to your data
loads, so that the latter can be covered up by the former.

For example, imagine your have a list of items, and clicking on one of
them hides the list and shows the item's details in a different view.
If we have an async load for the item details that takes, on average,
400ms, then we can cover up the load almost entirely in most cases by
having a 300ms "leave" animation on the list view, and a 300ms "enter"
animation on the item details view. That way we provide a slicker feel
to the UI and can avoid showing a spinner at all in most cases.

However, this requires that we initiate the async load and the state
change animation at the same moment. If we use "resolve", then the
entire async animation happens before the animation starts. The user
clicks, sees a spinner, then sees the transition animation. The whole
state change will take ~1000ms, which is too slow.

"Resolve" could be a useful way to cache dependencies between
different views if it had the option not to wait on promises, but the
current behaviour, of always resolving them before the state change
starts makes it almost useless, IMO. It should be avoided for any
dependencies that involve async loads.


Should I really stop using
resolve
to load some data and rather start loading them in the corresponding controller directly? So that I can update the corresponding view as long as the job is executed and in the place I want in the view, not globally.

Answer

You can use a directive that listens on $routeChangeStart and for example shows the element when it fires:

app.directive('showDuringResolve', function($rootScope) {

  return {
    link: function(scope, element) {

      element.addClass('ng-hide');

      var unregister = $rootScope.$on('$routeChangeStart', function() {
        element.removeClass('ng-hide');
      });

      scope.$on('$destroy', unregister);
    }
  };
});

Then you place it on the specific view's loader, for example:

View 1:

<div show-during-resolve class="alert alert-info">
  <strong>Loading.</strong>
  Please hold.
</div>

View 2:

<span show-during-resolve class="glyphicon glyphicon-refresh"></span>

The problem with this solution (and many other solutions for that matter) is that if you browse to one of the routes from an external site there will be no previous ng-view template loaded, so your page might just be blank during resolve.

This can be solved by creating a directive that will act as a fallback-loader. It will listen for $routeChangeStart and show a loader only if there is no previous route.

A basic example:

app.directive('resolveLoader', function($rootScope, $timeout) {

  return {
    restrict: 'E',
    replace: true,
    template: '<div class="alert alert-success ng-hide"><strong>Welcome!</strong> Content is loading, please hold.</div>',
    link: function(scope, element) {

      $rootScope.$on('$routeChangeStart', function(event, currentRoute, previousRoute) {
        if (previousRoute) return;

        $timeout(function() {
          element.removeClass('ng-hide');
        });
      });

      $rootScope.$on('$routeChangeSuccess', function() {
        element.addClass('ng-hide');
      });
    }
  };
});

The fallback loader would be placed outside the element with ng-view:

<body>
  <resolve-loader></resolve-loader>
  <div ng-view class="fadein"></div>
</body>

Demo of it all: http://plnkr.co/edit/7clxvUtuDBKfNmUJdbL3?p=preview