36

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.

Mark Amery
  • 143,130
  • 81
  • 406
  • 459
Mik378
  • 21,881
  • 15
  • 82
  • 180
  • 1
    We've been working on a spinner thing just like this one for like 2 weeks. We've tried everything. We have used the 'resolve' function, opening the spinner in the controller while loading, using a "spinner-directive". Nothing works in a clean way. In the end we have a mixed solution, using a watch on a var in the rootscope to activate/deactivate a modal spinner view. It still has some quirks, but the overall solution is acceptable. IF you want a snippet I can provide it to you. – Th0rndike Jun 13 '14 at 08:35
  • @Th0rndike What if we load the data directly in the controller? We would bypass the resolve property. Code design would not be as clean as it should be, but I don't figure out what this would be a bad way. Indeed, we would have full view available for manipulation while tasks are loading. – Mik378 Jun 13 '14 at 08:37
  • Most of our problem was to show the spinner during transitions. Loading the spinner when instantiating and populating the controller does not solve the "wait-spinner" while transitioning views. If that's not your requirement then you can go with it. – Th0rndike Jun 13 '14 at 08:40
  • Suppose two pages: A leading to B. If you expect A to display a "leaving" animation, and then lets the place to B, then I think that there is no easy solution currently. However, if as soon as B is asked, A leaves (directly), and B displays itself through its controller its own spinner, then using the B's controller would be easy and good. For the user, it would make no difference if A's controller was displaying the controller in the view A or B's one in the view B. – Mik378 Jun 13 '14 at 08:43
  • We're experiencing some delay when leaving a view, it takes 300ms to 500ms because of multiple data bindings, that's why we decided to use a "global" spinner which can be turned on/off on any of our views. – Th0rndike Jun 13 '14 at 08:46
  • "Some delay when leaving a view" => meaning just the process of clearing controller's element (destroying $scope etc..) just before opening the targeted view takes more than 300ms ?!? – Mik378 Jun 13 '14 at 08:49
  • Actually it's been a major problem. We don't know what angular is doing! it just freezes for a bit before changing pages. This happens when loading a view a second time, with same or different data :( – Th0rndike Jun 13 '14 at 08:57
  • You should have a huge amount of data binding, no? I think I'll try the solution aiming to load data directly on controller. Leaving the source view would not be an issue, I don't have a lot of data displaying in it. – Mik378 Jun 13 '14 at 09:03
  • Why can't you use `$routeChangeStart` without hiding the whole page with "just a poor spinner in the middle"? Trying to understand the problem. – tasseKATT Jun 13 '14 at 17:03
  • @tasseKATT If I want to display a spinner in a specific place within a specific view (for instance in the very bottom of a specific `div`), I would be stuck. I've just succeeded by loading the tasks in the targeted controller itself. I used `$routeChangeStart` just before. – Mik378 Jun 13 '14 at 18:01
  • Can't you just place the spinner in the specific place where you want it and start it when `$routeChangeStart` fires? Or are you using ui-router with multiple views? – tasseKATT Jun 13 '14 at 18:14
  • @tasseKATT I don't use ui-router for now. I can't see how could I handle easily specific spinner position for each page using the `$rootScope.$routeChangeStart();` – Mik378 Jun 13 '14 at 18:49

6 Answers6

51

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

tasseKATT
  • 38,470
  • 8
  • 84
  • 65
  • Nice way, thanks a lot :) I'm pretty sure it doesn't fit all use cases. I will study that and let you my feeling. – Mik378 Jun 13 '14 at 19:20
  • @tasseKATT The resolveLoader directive works great ! Until you decide to load the template with templateUrl : then it never shows up. I don't know why. http://plnkr.co/edit/8LRrZNTeswY0wVCqAtp7?p=preview – Sbu Mar 06 '15 at 06:52
  • 4
    It's a timing issue with the link function. If using templateUrl this should be all you need: http://plnkr.co/edit/31qu9xcZCCEzSfHn5YyN?p=preview I will take closer look at it later when I have time and update my answer. – tasseKATT Mar 06 '15 at 07:15
  • 3
    Update from `$routeChangeStart` to `$stateChangeStart` for newer versions of ui-router https://github.com/angular-ui/ui-router/wiki#state-change-events – SimplGy Dec 22 '15 at 21:43
  • Probably you can use `scope.$on` instead of `$rootScope.$on`, since event is broadcasted, and so should go down to all scopes (not 100% sure). https://docs.angularjs.org/api/ngRoute/service/$route – DarthVanger Jan 17 '17 at 18:15
  • @DarthVanger Correct. – tasseKATT Jan 17 '17 at 18:22
29

i think this is pretty neat

app.run(['$rootScope', '$state',function($rootScope, $state){

  $rootScope.$on('$stateChangeStart',function(){
      $rootScope.stateIsLoading = true;
 });


  $rootScope.$on('$stateChangeSuccess',function(){
      $rootScope.stateIsLoading = false;
 });

}]);

and then on view

<div ng-show='stateIsLoading'>
  <strong>Loading.</strong>
</div>
Pranay Dutta
  • 2,483
  • 2
  • 30
  • 42
  • @Pranay Dytta You made it as simple as possible – Mo. Jan 05 '16 at 07:16
  • 1
    Simple but it doesn't work for nested ui-view. Because the global variable affects all sections and Loading. message appears even on already loaded sections. – Kalyan Jul 02 '16 at 12:28
11

To further Pranay's answer this is how I did it.

JS:

app.run(['$rootScope',function($rootScope){

    $rootScope.stateIsLoading = false;
    $rootScope.$on('$routeChangeStart', function() {
        $rootScope.stateIsLoading = true;
    });
    $rootScope.$on('$routeChangeSuccess', function() {
        $rootScope.stateIsLoading = false;
    });
    $rootScope.$on('$routeChangeError', function() {
        //catch error
    });

}]);

HTML

<section ng-if="!stateIsLoading" ng-view></section>
<section ng-if="stateIsLoading">Loading...</section>
sidonaldson
  • 24,431
  • 10
  • 56
  • 61
3

I'm two years late to this, and yes these other solutions work but I find it easier to just handle all this in a just a run block like so

.run(['$rootScope','$ionicLoading', function ($rootScope,$ionicLoading){


  $rootScope.$on('loading:show', function () {
    $ionicLoading.show({
        template:'Please wait..'
    })
  });

  $rootScope.$on('loading:hide', function () {
    $ionicLoading.hide();
  });

  $rootScope.$on('$stateChangeStart', function () {
    console.log('please wait...');
    $rootScope.$broadcast('loading:show');
  });

  $rootScope.$on('$stateChangeSuccess', function () {
    console.log('done');
    $rootScope.$broadcast('loading:hide');
  });

}])

You don't need anything else. Pretty easy huh. Here's an example of it in action.

Draško Kokić
  • 1,280
  • 1
  • 19
  • 34
garrettmac
  • 8,417
  • 3
  • 41
  • 60
1

In ui-router 1.0 $stateChange* events are deprecated. Use transition hook instead. See migration guide below for more details.

https://ui-router.github.io/guide/ng1/migrate-to-1_0#state-change-events

  • 3
    _Links to external resources are encouraged, but please add context around the link so your fellow users will have some idea what it is and why it’s there. Always quote the most relevant part of an important link, in case the target site is unreachable or goes permanently offline._ – Bugs Dec 22 '16 at 11:34
0

The problem with '$stateChangeStart' and '$stateChangeSuccess' is "$rootScope.stateIsLoading" doesn't get refreshed when you go back to last state. Is there any solution on that? I also used:

    $rootScope.$on('$viewContentLoading',
        function(event){....});

and

    $rootScope.$on('$viewContentLoaded',
        function(event){....});

but there is the same issue.

Hamid Hoseini
  • 1,560
  • 17
  • 21
  • This does not provide an answer to the question. To critique or request clarification from an author, leave a comment below their post - you can always comment on your own posts, and once you have sufficient reputation you will be able to comment on any post – Dethariel Jul 29 '16 at 21:54