Per-component loading spinner for AngularJS

One of the first things that people want to do with AngularJS is to have a loading spinner on their page to prevent the unseemly appearance of a page with no content loaded because you’re waiting on an ajax xhr request. There are quite a lot of these spinner plugins available, or you can relatively easily roll your own.

However most of these are whole-page ie if any infly request is happening, the whole page appears blocked to the user. This can be quite annoying and give the impression of your site being pretty slow. What other sites heavily dependent on ajax (eg Facebook and LinkedIn) typically do is have each individual block/component on the page display a loading graphic so that perhaps your friends list is marked as loading but your news feed had already loaded.

Fortunately with AngularJS’s awesome scope, factory and component design it’s very easy to bolt this on to an existing app in just a few minutes. Let’s look at some code.

Firstly, (as you should be doing already) you need to have your ajax request going through a single point in your code such as the skeletal factory below. I’d typically do something like this:

angularApp.factory('api', function( $http ) {
    var fns = {};
    var req = function( path, args, opts ) {
        var promise = $http.post( fns.get_url(path), args );

        return promise.then(function(res) {
            return res.data;
        });
    };

    // Two calls - nonblocked which doesnt show the spinner and req which does
    fns.nonblocked = req;
    fns.req = req
    return fns;
});

Then we extend this so that the req function can have a scope passed in which will have a variable called infly_http_request which contains the number of outstanding ajax requests under that scope. We now add this in to the api service replacing the req function with something that will check the requests:

    ...
    function setup_spinner( scope ) { 
        if( scope.hasOwnProperty('infly_http_request') )
            return;

        scope.infly_http_request = 0;
        
        var cur_timeout;
        scope.stop_blocked_request = function( ) { 
            if( cur_timeout )
                $timeout.cancel(cur_timeout);
                
            scope.infly_http_request--;
     
            if( scope.infly_http_request < 0 ) 
                scope.infly_http_request = 0;
        };  
        scope.start_blocked_request = function( ) {
            if( cur_timeout )
                $timeout.cancel(cur_timeout);

            cur_timeout = $timeout(function() {
                scope.stop_blocked_request( );
                // XXX raise error
            }, 10000);

            scope.infly_http_request++;
        };
    }
    fns.req = function( path, args, opts ) {
        if( !opts )
            opts = {};

        var scope = opts.scope || $rootScope;
        setup_spinner( scope );

        scope.start_blocked_request();
        return req( path, args, opts )
            ['finally'](function() {
                scope.stop_blocked_request( );
            });
     };

Basically if a scope option is passed in this will scope the spinner to that block, otherwise it will use the global scope so you can still do a whole-page lock.

Finally here’s a quick directive to apply to a nice and easy spinner using fontawesome:

// XXX has to be a subdirective to an ngController - can't be on the same level as it.
window.angularApp.directive('showSpinner', function() {
    return {
        transclude: true,
        template: '<div><ng-transclude ng-show="infly_http_request == 0"></ng-transclude><div ng-hide="infly_http_request == 0" class="subspinner-container"><i class="fa fa-cog fa-spin"></i></div></div>',
    }
});

And the LESS (CSS) to go with it:

@subspinner-size: 3em;
.subspinner-container {
    text-align: center;
    .fa-spin {
        font-size: @subspinner-size;
    }
}

You can then write your Angular component and HTML as:

angularApp.controller('Product.List', function( $scope, api ) {
    api.req( '/api/path', { data... }, { scope: $scope } )
        .then(...)
});

<div ng-controller="Product.List">
  <div show-spinner>
    ...
  </div>
</div>

Anything within the show-spinner container under the controller and the scope attribute passed in the req() call will be replaced by a spinner while the request is in progress. If not you can have something in the main body of your page to show a spinner like:

<div ng-if="infly_http_request" class="spinner-container">
    <div id="spinner">
        <i class="fa fa-cog fa-spin"></i>
    </div>
</div>

@spinner-size: 5em;
.spinner-container {
    position: fixed;
    top:0;
    left:0;
    right:0;
    bottom:0;
    z-index:10000;
    background-color:gray;
    background-color:rgba(70,70,70,0.2);
    #spinner {
        position: absolute;
        font-size: @spinner-size;
    
        margin-left: -0.5em;
        margin-top: -0.5em;

        z-index: 20000;
        left: 50%;
        top: 50%;
    }
}

Leave a Reply

Your email address will not be published. Required fields are marked *