How to do loading spinners, the Angular way.

Updated on 6/17/16
Pro tip: A lot of the code here are examples of how to do things that aren't actually in the final spinner package, such as building an HTML 5 animated spinner or going through code step by step with lots of comments for learning. If you skim the post and think "that's too much code" like several commenters seem so intent on saying then by all means do it however you think is best. Nobody is forcing you to do anything. Take it from me though, I've witnessed the twist my colleagues get themselves into when trying to toggle a simple spinner half way around their app, traversing scope trees and broadcasting events down the entire scope chain, injecting$rootScope
everywhere, etc. Before this all I've seen are ugly hacks that require even more code.
Maybe there's an even more elegant way to bake spinners into any angular app with minimal effort but I've yet to see it. Feel free to enlighten me, but just telling me "that's a lot of code" helps nobody and is actually not even true. Either that or we have very different ideas about what "a lot of code" is. You can see the entirety of the the module here, which as you can see is nothing but a single service and a single directive, both of which are rather small in my opinion, but what do I know ¯\(ツ)/¯
Updated on 8/27/15
This post was popular enough that I decided to turn it into a package! :D
https://github.com/codetunnel/angular-spinners
You can find it in Bower and NPM.$ npm install angular-spinners
$ bower install angular-spinners
One interesting problem I had to solve recently was how to elegantly deal with loading spinners on the page without violating separation of concerns. Many times, the spinner element is in an area of the DOM controlled by the relevant controller and you can simply toggle a variable in scope and it just works. For example:
<div ng-controller="myCtrl">
<img id="mySpinner" src="/my/loading/spinner.gif" ng-show="loading" />
</div>
app.controller('myCtrl', function ($scope, $http) {
// Show loading spinner.
$scope.loading = true;
$http.get('/some/awesome/content')
.success(function (data) {
// Do stuff with data.
})
.catch(function (err) {
// Log error somehow.
})
.finally(function () {
// Hide loading spinner whether our call succeeded or failed.
$scope.loading = false;
});
});
This works great, but what if your spinner is in a DOM tree that isn't managed by the controller that needs to show it? What if you need to hide/show spinner(s) from within a service? You then need to start thinking through a somewhat more complex solution to get at that spinner without making the nasty call to angular.element('#mySpinner').show()
(which is just a wrapper around JQuery or JQlite). If you stoop to that level then you're coupling the DOM with your controller logic and you make it extremely difficult to test in isolation.
Even if you manage to solve this scenario, how then do you solve situations where you need to show/hide multiple loading spinners? It's often smart to hide every loading spinner on the page in some sort of global error handler, ensuring that any uncaught exceptions don't end up leaving an endless spinner behind to frustrate the user. If we want to do this while playing nice with Angular and keeping our app testable, then we need to come up with a generic solution that can be implemented anywhere.
The directive:
Of course, we love directives and some kind of spinner
directive seems to make immediate sense. My first instinct at this point was to create a directive that would register a few different event listeners on $rootScope
. It could listen for events like show-spinner-my-spinner
, hide-spinner-my-spinner
, show-spinner-group-foo
, hide-spinner-group-foo
, show-all-spinners
, and hide-all-spinners
. That idea lasted all of ten seconds in my brain before I realized just how unwieldy that solution was going to be. An evented system sounds like the magic bullet we need, but what happens when in a large single page app, two people create spinners with the same name? Well, two event listeners for the same name are going to be registered. When logic triggers an event that is supposed to show a single spinner, it's going to show both spinners with duplicate names.
What we really need here is the ability to register spinners with a service and allow that service to keep track of them all in an intuitive way. Rather than subscribe to six different events in every instance of our spinner directive, each directive will simply register itself with a service. Here is an example of a directive I recently put together to do just that.
angular.module('angularSpinners')
.directive('spinner', function () {
return {
restrict: 'EA',
replace: true,
transclude: true,
scope: {
name: '@?',
group: '@?',
show: '=?',
imgSrc: '@?',
register: '@?',
onLoaded: '&?',
onShow: '&?',
onHide: '&?'
},
template: [
'<span ng-show="show">',
' <img ng-show="imgSrc" ng-src="{{imgSrc}}" />',
' <span ng-transclude></span>',
'</span>'
].join(''),
controller: function ($scope, spinnerService) {
// register should be true by default if not specified.
if (!$scope.hasOwnProperty('register')) {
$scope.register = true;
} else {
$scope.register = !!$scope.register;
}
// Declare a mini-API to hand off to our service so the
// service doesn't have a direct reference to this
// directive's scope.
var api = {
name: $scope.name,
group: $scope.group,
show: function () {
$scope.show = true;
},
hide: function () {
$scope.show = false;
},
toggle: function () {
$scope.show = !$scope.show;
}
};
// Register this spinner with the spinner service.
if ($scope.register === true) {
spinnerService._register(api);
}
// If an onShow or onHide expression was provided,
// register a watcher that will fire the relevant
// expression when show's value changes.
if ($scope.onShow || $scope.onHide) {
$scope.$watch('show', function (show) {
if (show && $scope.onShow) {
$scope.onShow({
spinnerService: spinnerService,
spinnerApi: api
});
} else if (!show && $scope.onHide) {
$scope.onHide({
spinnerService: spinnerService,
spinnerApi: api
});
}
});
}
// This spinner is good to go.
// Fire the onLoaded expression if provided.
if ($scope.onLoaded) {
$scope.onLoaded({
spinnerService: spinnerService,
spinnerApi: api
});
}
}
};
});
See how the directive calls spinnerService._register(api)
? Our service's _register
method accepts an API object for the spinner and files it away for later interaction. Each spinner that registers with the spinner service has a unique name, making it easy to keep track of all our spinners.
The directive would look something like this:
<spinner name="mySpinner"></spinner>
It can be shown by default like this:
<spinner name="mySpinner" show="true"></spinner>
The show
option is a two-way bound variable. You can pass in a simple true
/false
, but it also allows you to pass a variable from the parent scope so that you can hide/show the spinner based on your application's state.
app.controller('myController', function ($scope, $http) {
$scope.loading = false;
$http.get('/path/to/data')
.success(function (data) {
// do stuff with data
})
.catch(function (err) {
// handle err
})
.finally(function () {
$scope.loading = true;
});
});
<div ng-controller="myController">
<spinner name="mySpinner" show="loading"></spinner>
</div>
You can give it a group name like this:
<spinner name="mySpinner" group="foo"></spinner>
You can give your spinner element a loading graphic:
<spinner name="mySpinner" img-src="/path/to/loader.gif"></spinner>
You can also stop the spinner from registering with the spinner service if you need to:
<spinner name="mySpinner" register="false"></spinner>
If you do stop the spinner directive from registering itself with the service then you'll need a manual way to interact with it. You can get ahold of the spinnerApi
for that spinner by supplying an expression to the onLoaded
option.
<spinner name="mySpinner" register="false" on-loaded="spinnerLoaded(spinnerApi)"></spinner>
The expression you supply to onLoaded
has access to spinnerApi
in the same way that expressions you supply to ngClick
have access to $event
.
$scope.spinnerLoaded = function (mySpinner) {
// Now you can do
// mySpinner.show();
// mySpinner.hide();
};
Your expressions also have access to spinnerService
for convenience, but that can also be injected like any other Angular service.
Transclusion
I'm not done showing off our directive quite yet! Did you happen to notice this little tidbit in our directive template just beneath the image element?
<span ng-transclude></span>
Transclusion is Angular's fancy term for allowing you to nest markup within a directive tag and have that markup included somewhere inside the resulting directive template. The ngTransclude
directive specifies where transcluded content should be placed. Through the use of transclusion it is possible to supply custom HTML to our spinner instead of or in addition to, our loading graphic. For example, we could make an HTML 5 animation and use it as our spinner instead of an animated gif:
<spinner name="html5spinner">
<div class="overlay"></div>
<div class="spinner">
<div class="double-bounce1"></div>
<div class="double-bounce2"></div>
</div>
<div class="please-wait">Please Wait...</div>
</spinner>
.spinner {
width: 40px;
height: 40px;
position: relative;
}
.double-bounce1, .double-bounce2 {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #9c9;
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
z-index: 10;
-webkit-animation: bounce 2.0s infinite ease-in-out;
animation: bounce 2.0s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
@-webkit-keyframes bounce {
0%, 100% { -webkit-transform: scale(0.0) }
50% { -webkit-transform: scale(1.0) }
}
@keyframes bounce {
0%, 100% {
transform: scale(0.0);
-webkit-transform: scale(0.0);
} 50% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}
}
Thanks to Tobias Ahlin for the fancy loading animation. With this we now have a loading spinner that is made entirely out of CSS and placed inside a directive that will manage it for us. Here is a working demo of the fancy spinner above. Supply a dummy email and password and click "Login" to see the spinner in action.
See the Pen VLzVbb by Alex Ford (@Chevex) on CodePen.
With the ability to include your own custom markup your spinners can now be almost anything you need them to be. One type of animation you may want to put in your spinner would be an HTML5 <canvas>
animation. That's absolutely possible, but the unique thing about <canvas>
is that you draw/animate on it via JavaScript. If you want to hide and show a canvas animation at will then you need to run the animation JavaScript logic whenever the spinner is shown. To that end our directive also provides onShow
and onHide
options. Both options accept an expression just like our onLoaded
option does. The onShow
expression will be evaluated whenever the spinner is shown, and obviously the onHide
expression will run whenever the spinner is hidden.
With onShow
displaying a canvas animation is easy:
app.controller('myCtrl', function ($scope) {
$scope.animateSpinner = function () {
// <canvas> drawing API logic goes here
};
});
<spinner name="mySpinner" on-show="animateSpinner()">
<canvas id="myAnimation"></canvas>
</spinner>
The next piece of this puzzle is the service logic.
The service:
Our service needs to store all of our spinner directive API objects and expose some kind of service API for interacting with them all. Our spinners have unique names so we need a way to show a spinner by its name. We also have the option to specify a group on each spinner so we'll need a way to show all spinners registered with a specific group. Lastly, it would be nice if there were a way to show/hide all spinners regardless of group.
Here is the service that I recently put together:
/*
* The spinner-service is used by the spinner directive to register new spinners.
* It's also used by anyone who wishes to interface with the API to hide/show spinners on the page.
*/
app.factory('spinnerService', function () {
// create an object to store spinner APIs.
var spinners = {};
return {
// private method for spinner registration.
_register: function (data) {
if (!data.hasOwnProperty('name')) {
throw new Error("Spinner must specify a name when registering with the spinner service.");
}
if (spinners.hasOwnProperty(data.name)) {
throw new Error("A spinner with the name '" + data.name + "' has already been registered.");
}
spinners[data.name] = data;
},
// unused private method for unregistering a directive,
// for convenience just in case.
_unregister: function (name) {
if (spinners.hasOwnProperty(name)) {
delete spinners[name];
}
},
_unregisterGroup: function (group) {
for (var name in spinners) {
if (spinners[name].group === group) {
delete spinners[name];
}
}
},
_unregisterAll: function () {
for (var name in spinners) {
delete spinners[name];
}
},
show: function (name) {
var spinner = spinners[name];
if (!spinner) {
throw new Error("No spinner named '" + name + "' is registered.");
}
spinner.show();
},
hide: function (name) {
var spinner = spinners[name];
if (!spinner) {
throw new Error("No spinner named '" + name + "' is registered.");
}
spinner.hide();
},
showGroup: function (group) {
var groupExists = false;
for (var name in spinners) {
var spinner = spinners[name];
if (spinner.group === group) {
spinner.show();
groupExists = true;
}
}
if (!groupExists) {
throw new Error("No spinners found with group '" + group + "'.")
}
},
hideGroup: function (group) {
var groupExists = false;
for (var name in spinners) {
var spinner = spinners[name];
if (spinner.group === group) {
spinner.hide();
groupExists = true;
}
}
if (!groupExists) {
throw new Error("No spinners found with group '" + group + "'.")
}
},
showAll: function () {
for (var name in spinners) {
spinners[name].show();
}
},
hideAll: function () {
for (var name in spinners) {
spinners[name].hide();
}
}
};
});
Our spinnerService
allows us to inject it anywhere we need it and invoke a single spinner, a group of spinners, or all spinners. You can see that the _register
function takes in the spinner API and adds it to the spinners
object. Once a spinner is registered, showing it is as easy as spinnerService.show('mySpinner')
. Showing a group of spinners is simple as well, spinnerService.showGroup('foo')
. If we really need to, we can even show every spinner registered with the service, spinnerService.showAll()
. Though in reality only the hideAll
method is really ever used, usually inside of a global error handler to ensure there are no left behind spinners after uncaught exceptions.
Now your spinner(s) can exist anywhere in the DOM and you don't have to implement complicated scope hierarchy traversal logic or emit a bunch of arbitrary events on $rootScope
. Every spinner will automatically register itself with the service, then you need only inject that same service anywhere you need it.
I took the liberty of turning this solution into a Bower package. You can find it here if you'd like to use it. Here is a working demonstration as well: