Show loading animation for slow script using AngularJS?
Asked Answered
F

8

31

In angularjs 1.2 operations like filtering an ng-repeat with many rows (>2,000 rows) can become quite slow (>1 sec).
I know I can optimize execution times using limitTo, pagination, custom filters, etc. but I'm still interested to know if it's possible to show a loading animation while the browser is busy running long scripts.

In case of angular I think that could be invoked whenever $digest is running because that seems to be the main function that takes up most time and might be called several times.

In a related question there were no useful answers given. Any help greatly appreciated!

Foliage answered 11/4, 2014 at 3:48 Comment(2)
What do you exactly mean by a loading animation?Cockaigne
For example an overlay with a gif animation.Foliage
M
22

The problem is that as long as Javascript is executing, the UI gets no chance to update. Even if you present a spinner before filtering, it will appear "frozen" as long as Angular is busy.

A way to overcome this is to filter in chunks and, if more data are available, filter again after a small $timeout. The timeout gives the UI thread a chance to run and display changes and animations.

A fiddle demonstrating the principle is here.

It does not use Angular's filters (they are synchronous). Instead it filters the data array with the following function:

function filter() {
    var i=0, filtered = [];
    innerFilter();

    function innerFilter() {
        var counter;
        for( counter=0; i < $scope.data.length && counter < 5; counter++, i++ ) {
            /////////////////////////////////////////////////////////
            // REAL FILTER LOGIC; BETTER SPLIT TO ANOTHER FUNCTION //
            if( $scope.data[i].indexOf($scope.filter) >= 0 ) {
                filtered.push($scope.data[i]);
            }
            /////////////////////////////////////////////////////////
        }
        if( i === $scope.data.length ) {
            $scope.filteredData = filtered;
            $scope.filtering = false;
        }
        else {
            $timeout(innerFilter, 10);
        }
    }
}

It requires 2 support variables: $scope.filtering is true when the filter is active, giving us the chance to display the spinner and disable the input; $scope.filteredData receives the result of the filter.

There are 3 hidden parameters:

  • the chunk size (counter < 5) is small on purpose to demonstrate the effect
  • the timeout delay ($timeout(innerFilter, 10)) should be small, but enough to give the UI thread some time to be responsive
  • the actual filter logic, which should probably be a callback in real life scenarios.

This is only a proof of concept; I would suggest refactoring it (to a directive probably) for real use.

Mercorr answered 15/4, 2014 at 15:32 Comment(0)
P
8

Here are the steps:

  1. First, you should use CSS animations. No JS driven animations and GIFs should be used within heavy processes bec. of the single thread limit. The animation will freeze. CSS animations are separated from the UI thread and they are supported on IE 10+ and all major browsers.
  2. Write a directive and place it outside of your ng-view with fixed positioning.
  3. Bind it to your app controller with some special flag.
  4. Toggle this directive's visibility before and after long/heavy processes. (You can even bind a text message to the directive to display some useful info to the user). -- Interacting with this or anything else directly within a loop of heavy process will take way longer time to finish. That's bad for the user!

Directive Template:

<div class="activity-box" ng-show="!!message">
    <img src="img/load.png" width="40" height="40" />
    <span>{{ message }}</span>
</div>

activity Directive:

A simple directive with a single attribute message. Note the ng-show directive in the template above. The message is used both to toggle the activity indicator and also to set the info text for the user.

app.directive('activity', [
    function () {
        return {
            restrict: 'EA',
            templateUrl: '/templates/activity.html',
            replace: true,
            scope: {
                message: '@'
            },
            link: function (scope, element, attrs) {}
        };
    }
]);

SPA HTML:

<body ng-controller="appController">
    <div ng-view id="content-view">...</div>
    <div activity message="{{ activityMessage }}"></div>
</body>

Note that the activity directive placed outside of ng-view. It will be available on each section of your single-page-app.

APP Controller:

app.controller('appController',
    function ($scope, $timeout) {
        // You would better place these two methods below, inside a 
        // service or factory; so you can inject that service anywhere
        // within the app and toggle the activity indicator on/off where needed
        $scope.showActivity = function (msg) {
            $timeout(function () {
                $scope.activityMessage = msg;
            });
        };
        $scope.hideActivity = function () {
            $timeout(function () {
                $scope.activityMessage = '';
            }, 1000); // message will be visible at least 1 sec
        };
        // So here is how we do it:
        // "Before" the process, we set the message and the activity indicator is shown
        $scope.showActivity('Loading items...');
        var i;
        for (i = 0; i < 10000; i += 1) {
            // here goes some heavy process
        }
        // "After" the process completes, we hide the activity indicator.
        $scope.hideActivity();
    });

Of course, you can use this in other places too. e.g. you can call $scope.hideActivity(); when a promise resolves. Or toggling the activity on request and response of the httpInterceptor is a good idea too.

Example CSS:

.activity-box {
    display: block;
    position: fixed; /* fixed position so it doesn't scroll */
    z-index: 9999; /* on top of everything else */
    width: 250px;
    margin-left: -125px; /* horizontally centered */
    left: 50%;
    top: 10px; /* displayed on the top of the page, just like Gmail's yellow info-box */
    height: 40px;
    padding: 10px;
    background-color: #f3e9b5;
    border-radius: 4px;
}

/* styles for the activity text */
.activity-box span {
    display: block;
    position: relative;
    margin-left: 60px;
    margin-top: 10px;
    font-family: arial;
    font-size: 15px;
}

/* animating a static image */
.activity-box img {
    display: block;
    position: absolute;
    width: 40px;
    height: 40px;
    /* Below is the key for the rotating animation */
    -webkit-animation: spin 1s infinite linear;
    -moz-animation: spin 1s infinite linear;
    -o-animation: spin 1s infinite linear;
    animation: spin 1s infinite linear;
}

/* keyframe animation defined for various browsers */
@-moz-keyframes spin {
    0% { -moz-transform: rotate(0deg); }
    100% { -moz-transform: rotate(359deg); }
}
@-webkit-keyframes spin {
    0% { -webkit-transform: rotate(0deg); }
    100% { -webkit-transform: rotate(359deg); }
}
@-o-keyframes spin {
    0% { -o-transform: rotate(0deg); }
    100% { -o-transform: rotate(359deg); }
}
@-ms-keyframes spin {
    0% { -ms-transform: rotate(0deg); }
    100% { -ms-transform: rotate(359deg); }
}
@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(359deg); }
}

Hope this helps.

Portiere answered 19/4, 2014 at 20:34 Comment(2)
+1 This seems to be a proper way to accomplish the task. In production I would add throttling inside showActivity() function to suppress messages for short operations and probably refactor to make controller thinner.Turgescent
Unfortunately this is not supported in all browsers yet. See this test page: Works for Chrome (my version: 34.0.1847.116), does NOT work for IE 10 (my version: 10.0.9200.16844), neither for Firefox (my version: 28.0).Mercorr
O
5

Use spin.js and the site http://fgnass.github.com/spin.js/ shows the step which is quite easy. the loading animation is in CSS which separated from the UI thread and therefore loaded smoothly.

Oceangoing answered 8/8, 2014 at 9:55 Comment(0)
C
2

What you can do is detect the end of the ngRepeat as this post says.

I'll do something like, in the controller:

$scope.READY = false;

And in the directive, as the post above says, I'll do something like:

if (scope.$last) {
    $scope.READY = true;
}

And you can have a css based loader/spinner with

<div class="loader" ng-show="!READY">
</div>

Ofcourse you can also have css based animations which are independent of js execution.

Conner answered 17/4, 2014 at 9:26 Comment(0)
H
1

You could run the filter in another thread using WebWorkers, so your angularjs page won't block.

If you don't use webworkers the browser could get a javascript execution timeout and stop your angular app completely and even if you don't get any timeout your application freezes until the calculation is done.

UPDATE 23.04.14

I've seen a major performance improvement in a large scale project using scalyr and bindonce

Holtorf answered 22/4, 2014 at 14:21 Comment(0)
E
1

Here is an working example :-

angular
  .module("APP", [])
  .controller("myCtrl", function ($scope, $timeout) {
    var mc = this
    
    mc.loading = true
    mc.listRendered = []
    mc.listByCategory = []
    mc.categories = ["law", "science", "chemistry", "physics"]
    
    
    mc.filterByCategory = function (category) {
      mc.loading = true
      // This timeout will start on the next event loop so 
      // filterByCategory function will exit just triggering 
      // the show of Loading... status

      // When the function inside timeout is called, it will
      // filter and set the model values and when finished call 
      // an inbuilt $digest at the end.
      $timeout(function () {
        mc.listByCategory = mc.listFetched.filter(function (ele) {
          return ele.category === category          
        })
        mc.listRendered = mc.listByCategory
        $scope.$emit("loaded")
      }, 50)
    }
    
    // This timeout is replicating the data fetch from a server
    $timeout(function () {
      mc.listFetched = makeList()
      mc.listRendered = mc.listFetched
      mc.loading = false    
    }, 50)
    
    $scope.$on("loaded", function () { mc.loading = false })
  })
  
  function makeList() {
    var list = [
      {name: "book1", category: "law"},
      {name: "book2", category: "science"},
      {name: "book1", category: "chemistry"},
      {name: "book1", category: "physics"}      
    ]
    var bigList = []
    for (var i = 0; i <= 5000; i++) {
      bigList = bigList.concat(list)
    }
    return bigList
  }
button {
  display: inline-block;      
}
<html>
  <head>
    <title>This is an Angular Js Filter Workaround!!</title>
  </head>
   
  <body ng-app="APP">
    
    <div ng-controller="myCtrl as mc">
      <div class = "buttons">
        <label>Select Category:- </label>
        <button ng-repeat="category in mc.categories" ng-click="mc.filterByCategory(category)">{{category}}</button>
      </div>
      
      <h1 ng-if="mc.loading">Loading...</h1>
      
      <ul ng-if="!mc.loading">
        <li ng-repeat="ele in mc.listRendered track by $index">{{ele.name}} - {{ele.category}}</li>
      </ul>
    </div>
    
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular.min.js"></script>
    
  </body>
  
<html>
Erleena answered 11/5, 2016 at 1:56 Comment(0)
S
0

Promise/deferred can be used in this case, you can call notify to watch the progress of your code, documentation from angular.js: https://code.angularjs.org/1.2.16/docs/api/ng/service/$q Here is a tutorial on heavy JS processing that uses ng-Promise: http://liamkaufman.com/blog/2013/09/09/using-angularjs-promises/, hope it is helpful.

//app Factory for holding the data model

app.factory('postFactory', function ($http, $q, $timeout){

var  factory = {

    posts : false,

    getPosts : function(){

        var deferred = $q.defer();

        //avoiding the http.get request each time 
        //we call the getPosts function

        if (factory.posts !== false){
            deferred.resolve(factory.posts);

        }else{

            $http.get('posts.json')
            .success(function(data, status){
                factory.posts = data

                //to show the loading !
                $timeout(function(){
                    deferred.resolve(factory.posts)
                }, 2000);

            })
            .error(function(data, status){
                deferred.error('Cant get the posts !')
            })

        };  

        return deferred.promise;
    },

    getPost : function(id){

        //promise
        var deferred = $q.defer();

        var post = {};
        var posts = factory.getPosts().then(function(posts){
            post = factory.posts[id];

            //send the post if promise kept
            deferred.resolve(post);

        }, function(msg){
            deferred.reject(msg);
        })

        return deferred.promise;
    },

};
return factory;

});

Sheffie answered 22/4, 2014 at 13:48 Comment(7)
this is wrong since heavy javascript calculations are blocking and not async, so you won't have any benefits using promisesHoltorf
the browsertab actually freezes until the calculation is doneHoltorf
It still can be done, Edit an example that i mad to test it.Sheffie
$timeout differs from real computation. A timeout just starts a function N ms later. In the meantime other stuff can be done. Computation blocks the complete javascript engine and you cannot do anything in the meantime. Try this: for (var i = 99999999; i > 0; i--) var x = 123;Holtorf
You are right, i think what he should do is a sort of work around, display a hidden div (for loading, with an animated gif for example) and then when the treatment is done, hide it, how about that ?!Sheffie
Using Ubuntu 13.10 with current firefox and current chrome an animated gif image freezes too.Holtorf
you still can put a text instead ^_^ loading... ! As you said, it freezes and the straight way to do this is WebWorkers, still webworkers are not fully supported by every web browser, so he may think about optimizing his code and improving the UI so it feels less laggy and improves the overall UX.Sheffie
C
0

You can use this code taken from this url: http://www.directiv.es/angular-loading-bar

there you can find a workin demo also.

Here is thei code:

    <!DOCTYPE html>
<html ng-app="APP">
<head>
    <meta charset="UTF-8">
    <title>angular-loading-bar example</title>
    <link rel="stylesheet" type="text/css" href="/application/html/js/chieffancypants/angular-loading-bar/loading-bar.min.css"/>
    <style>
        body{
            padding:25px;
        }
    </style>
</head>
<body ng-controller="ExampleController">
    <button id="reloader" ng-click="getUsers()">Reload</button>
    <ul ng-repeat="person in data">
        <li>{{person.lname}}, {{person.fname}}</li> 
    </ul>
    <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.2/angular.min.js"></script>
    <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.2/angular-animate.min.js"></script>
    <script src="/application/html/js/chieffancypants/angular-loading-bar/loading-bar.min.js"></script>
    <script>
    angular.module('APP', ['chieffancypants.loadingBar', 'ngAnimate']).
    controller("ExampleController",['$scope','$http',function($scope,$http){
        $scope.getUsers = function(){
            $scope.data=[];
            var url = "http://www.filltext.com/?rows=10&fname={firstName}&lname={lastName}&delay=3&callback=JSON_CALLBACK"
            $http.jsonp(url).success(function(data){
                $scope.data=data;
            })
        }
        $scope.getUsers()

    }])
    </script>
</body>
</html>

How do I use it?

Install it via npm or bower

$ npm install angular-loading-bar
$ bower install angular-loading-bar

To use, simply include it as a dependency in your app and you're done!

angular.module('myApp', ['angular-loading-bar'])
Costar answered 13/10, 2014 at 10:29 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.