A custom AngularJS directive to add lazy loading (infinite scroll) capability to an HTML table

For scenarios where the amount of data to be displayed in a table is likely to affect the page load, a nice enhancement is to have the data split in smaller pieces, being retrieved and rendered according to the way the user interacts with the table.

The user experience will be quite similar to what Facebook and Twitter have in their timeline pages. On the page load, the table will have just a subset of the data. The user can scroll the table, but before such a scrolling hits the bottom, the table content is augmented with another data subset. The user is then able to scroll over that new subset and the process of presenting data sets goes on.

This main purpose of this blog entry is to describe how to implement such a lazy loading capability (also referred as infinite scroll) in AngularJS, taking advantage of custom directives and some JQuery functions.

To ilustrate the concept, let us regard a table that displays historical prices of a fictitious stock.

A running version of the example can be accessed in this link

Below, the HTML code, along with AngularJS tags:

<!DOCTYPE html>
<html>

<head lang="en">
    <meta charset="utf-8">

    <title>Historical Stock Quote Price</title>

    <style>
        thead { background: lightgray; align-content: center; }
        tbody { display: block; overflow: auto; height: 250px; border-collapse: collapse; }
        th { height: 40px; width: auto; border: 1px solid black; text-align: center; }
        td { height: 30px; width: 120px; margin: 0px; padding: 5px; border: 1px solid black;}
    </style>

</head>

<body>
    <table ng-app="StockHistoryApp" ng-controller="StockHistoryController">
        <thead>
            <tr>
                <th colspan="4">Historical Stock Quote Price</th>
            </tr>
        </thead>
        <tbody when-scroll-ends="loadMoreRecords()">
            <tr ng-repeat="stock in stockList">
                <td>{{stock.dateValue | date:'mediumDate'}}</td>
                <td>{{stock.price | currency:'$':2}}</td>
            </tr>
        </tbody>
    </table>

    <script src="http://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>
</body>

The table is being managed a StockHistoryController controller as part of a StockHistoryApp module. Each table record comes from the stockList collection in the
scope. Each row has a date and the corresponding stock price.

In order to have lazy loading in place, there has to be something to monitor the scrolling and trigger the load of more data. A custom AngularJS directive is an elegant approach, where a element can be enhanced in a declarative fashion. In our current example, that is the purpose of the when-scroll-ends directive in the tbody element

Now, the AngularJS code for the module:

    var appModule = angular.module('StockHistoryApp', []);

    appModule.constant('chunkSize', 50);

    appModule.controller('StockHistoryController', function ($scope, chunkSize) {
        $scope.stockList = [];

        var currentIndex = 0;
        var todayDate = new Date();
        $scope.loadMoreRecords = function () {
            // Mocking stock values
            // In an real application, data would be retrieved from an external system

            var stock;
            var i = 0;
            while (i < chunkSize) {
                  currentIndex++;
                  var newDate = new Date();
                  newDate.setDate(todayDate.getDate() - currentIndex);
                  if (newDate.getDay() >= 1 && newDate.getDay() <= 5) {
                    stock = {
                        dateValue: newDate,
                        price: 20.0 + Math.random() * 10
                    };
                    $scope.stockList.push(stock);
                    i++;
                }
            }
        };

        $scope.loadMoreRecords();
    });

    appModule.directive('whenScrollEnds', function () {
        return {
            restrict: "A",
            link: function (scope, element, attrs) {
                var processingScroll = false;

                var visibleHeight = element.height();
                var threshold = 100;

                element.scroll(function () {
                    var scrollableHeight = element.prop('scrollHeight');
                    var hiddenContentHeight = scrollableHeight - visibleHeight;

                    if (hiddenContentHeight - element.scrollTop() <= threshold) {
                        // Scroll is almost at the bottom. Loading more rows
                        scope.$apply(attrs.whenScrollEnds);
                    }
                });
            }
        };
    });

The loadMoreRecords function added to the scope is in charge of retrieving the stock data. For the sake of simplificity, the data is made-up randomly, rather than fetched from another system.

The custom directive

The directive takes advantage of JQuery to bind any scrolling of an element (in our case, the table body) to the execution of a callback (for our example, invokes the loadMoreRecords function) in case the scroll bar is close to the bottom.

Notice that the name directive added to the module is whenScrollEnds, a camel case without dashes name to match the when-scroll-ends in the HTML code, according to the normalization process performed by AngularJS.

In terms of directive properties, only two: restrict to specify that the directive needs to be declared as an element attribute (the “A” value) in HTML code, and link, given the need to manipulate DOM elements.

The second argument in the link function is a JQuery wrapper to the table body DOM element. By the way, if JQuery had not been loaded in the HTML code, the argument in the function would be a jqLite (that has a subset of JQuery API) wrapper instead.

The element.scroll(…) invocation is a shorthand available in JQuery API that would have the same effect of invoking element.on(‘scroll’, ….), namely the binding of a callback to the DOM onscroll event.

To verify whether the scroll bar is close to the bottom, what the callback does is checking the number of pixels scrolled down (the DOM scrollTop property) against the maximum number of pixels that can be scrolled, which is the difference between the entire table body height (the DOM scrollHeight property) and the visible table body height (the DOM height property). If there are less than 100 pixels (that is an arbitrary threshold) to the bottom, the loadMoreRecords is invoked. The attrs argument is a map of all attribute values inside the table body element and when-scroll-ends attribute is referable in AngularJS code through the same normalization process mentioned before.

References

Angular documentation – Creating custom directives

The JQuery API

HTML DOM Events

The HTML DOM Element Object