Cacomania: A simple AngularJS client side pagination module

Cacomania

A simple AngularJS client side pagination module

Guido Krömer - 1. June 2013 - Tags: ,

After my last tutorial, I wanted to do some more complex with AngularJS and solve my news feed problem in a similar way I solved my bookmark chaos. During this, I ran into the problem that displaying large lists, containing hundred of entries, can slow down the app significant using ng-repeat. On slow browsers like the Firefox or Opera the application hangs for some seconds during rendering the list of feed items. Since the JSON encoded and GZIP compressed list with 1.000 feed items for example is transferred quite fast, I did not want to do the pagination on the server side instead of this I wanted to paginate the list on the client. Doing the pagination on the client has the benefit that other filters could be used at the same time, too.

After googling around, I found this fiddle which inspired my how to do client side pagination, using array.slice, in AngularJS, but I wanted a more reusable solution without messing my controller up with the pagination logic. Therefore, I create a module called caco.ClientPaginate which contains a service, some filters and a directive automating this task.

The service

A service in AngularJS is a singleton, this means that all filters, controllers, modules, or directives will use the same instance of the pagination service. The Paginator itself has only three fields storing the current page, the rows per page and the total count of items in the list and some methods for navigating to a page, getting the total number of pages or for determining if the current page is the first or last page. Those large set of methods keeps the template code, which is explained later, clean and readable.

.service('Paginator', function () {
    this.page = 0;
    this.rowsPerPage = 50;
    this.itemCount = 0;

    this.setPage = function (page) {
        if (page > this.pageCount()) {
            return;
        }

        this.page = page;
    };

    this.nextPage = function () {
        if (this.isLastPage()) {
            return;
        }

        this.page++;
    };

    this.perviousPage = function () {
        if (this.isFirstPage()) {
            return;
        }

        this.page--;
    };

    this.firstPage = function () {
        this.page = 0;
    };

    this.lastPage = function () {
        this.page = this.pageCount() - 1;
    };

    this.isFirstPage = function () {
        return this.page == 0;
    };

    this.isLastPage = function () {
        return this.page == this.pageCount() - 1;
    };

    this.pageCount = function () {
        return Math.ceil(parseInt(this.itemCount) / parseInt(this.rowsPerPage));
    };
})

The filters

The module has two filters, the filter which does the pagination itself and a little helper which creates a for loop.

Let's take a look at the paginate filter first. The first function argument is the list of all items, the second parameter is optional and sets the rows per page. The if construct at the beginning might look a little bit odd, but it makes sense, the ng-repeat directive can handle uninitialized arrays, this filter cannot handle it, so the input gets returned directly if it is null. After the item count has been set the input array gets sliced to a part matching the current page and gets returned.

.filter('paginate', function(Paginator) {
    return function(input, rowsPerPage) {
        if (!input) {
            return input;
        }

        if (rowsPerPage) {
            Paginator.rowsPerPage = rowsPerPage;
        }
        
        Paginator.itemCount = input.length;

        return input.slice(parseInt(Paginator.page * Paginator.rowsPerPage), parseInt((Paginator.page + 1) * Paginator.rowsPerPage + 1) - 1);
    }
})

This filter is just a helper simulating a classic for loop, the usage is quite simple: "ng-repeat="i in [] | forLoop:0:10""" and is used for displaying a list of pages in the template. The input array gets dropped and a new array with the needed size gets created and filled.

.filter('forLoop', function() {
    return function(input, start, end) {
        input = new Array(end - start);
        for (var i = 0; start < end; start++, i++) {
            input[i] = start;
        }

        return input;
    }
})

The directive

Through the usage of this custom directive the paginator can included in a template easily by using this tag: <paginator></paginator>. The directive is simple it just contains a mini controller which assigns the injected Paginator to the templates scope, defines the templateUrl and is restricted to elements (restrict: 'E').

.directive('paginator', function factory() {
    return {
        restrict: 'E',
        controller: function ($scope, Paginator) {
            $scope.paginator = Paginator;
        },
        templateUrl: 'paginationControl.html'
    };
});

The template

The template uses the Twitter Bootstrap pagination CSS classes, has two button for navigate to the first and last page, two buttons for navigate forward and backward and one button for each page. The buttons for accessing a page directly are generated with the use of the forLoop filter. As mentioned before the service has some methods for determining if the current page is first or last page for example, this methods keeps the template code readable since no wired equations like this is needed: ng-show="(paginator.page == (Math.ceil(parseInt(paginator.itemCount) / parseInt(paginator.rowsPerPage)) - 1) && 'disabled'"

<div class="pagination text-center" ng-show="paginator.pageCount() > 1">
    <ul>
        <li ng-click="paginator.firstPage()" ng-class="paginator.isFirstPage() && 'disabled'">
            <a>
                <i class="icon-fast-backward" ng-class="paginator.isFirstPage() && 'icon-white'"></i>
            </a>
        </li>
        <li ng-click="paginator.perviousPage()" ng-class="paginator.isFirstPage() && 'disabled'">
            <a>
                <i class="icon-step-backward" ng-class="paginator.isFirstPage() && 'icon-white'"></i>
            </a>
        </li>
        <li ng-click="paginator.setPage(i)" ng-repeat="i in [] | forLoop:0:paginator.pageCount()" ng-class="paginator.page==i && 'active'">
            <a>{{i+1}}</a>
        </li>
        <li ng-click="paginator.nextPage()" ng-class="paginator.isLastPage() && 'disabled'">
            <a>
                <i class="icon-step-forward" ng-class="paginator.isLastPage() && 'icon-white'"></i>
            </a>
        </li>
        <li ng-click="paginator.lastPage()" ng-class="paginator.isLastPage() && 'disabled'">
            <a>
                <i class="icon-fast-forward" ng-class="paginator.isLastPage() && 'icon-white'"></i>
            </a>
        </li>
    </ul>
</div>

The paginator would look like this one:
Client side pagination with AngularJS

Usage of the paginator

The usage is as simple as possible and can be used by changing at least two parts in the template without touching existing controllers, but do not forget to inject the module. As you can see below the example controller does not need any modification:

angular.module('examplePaginator', ['caco.ClientPaginate'])
    .controller('TestCrtl', function ($scope) {
        $scope.testValues = [];
        for (var i = 0; i < 10000; i++) {
            $scope.testValues.push(i);
        }
    });

Just "pipe" the list of items in the ng-repeat directive to the paginate filter:

<tr ng-repeat="value in testValues | paginate">
    <td>{{value}}</td>
</tr>

The second task is, adding the paginator element where the paginator should get displayed, that's it.

<paginator></paginator>

demo

A posting in this blog helped me a lot putting this into a working jsFiddle. Take a look and play around with this fiddle:

That's All Folks! I hope you enjoyed my tiny post, if you want to read the whole code here is a complete and copy paste friendly view of it:

angular.module('caco.ClientPaginate', [])

    .filter('paginate', function(Paginator) {
        return function(input, rowsPerPage) {
            if (!input) {
                return input;
            }

            if (rowsPerPage) {
                Paginator.rowsPerPage = rowsPerPage;
            }
            
            Paginator.itemCount = input.length;

            return input.slice(parseInt(Paginator.page * Paginator.rowsPerPage), parseInt((Paginator.page + 1) * Paginator.rowsPerPage + 1) - 1);
        }
    })

    .filter('forLoop', function() {
        return function(input, start, end) {
            input = new Array(end - start);
            for (var i = 0; start < end; start++, i++) {
                input[i] = start;
            }

            return input;
        }
    })

    .service('Paginator', function ($rootScope) {
        this.page = 0;
        this.rowsPerPage = 50;
        this.itemCount = 0;

        this.setPage = function (page) {
            if (page > this.pageCount()) {
                return;
            }

            this.page = page;
        };

        this.nextPage = function () {
            if (this.isLastPage()) {
                return;
            }

            this.page++;
        };

        this.perviousPage = function () {
            if (this.isFirstPage()) {
                return;
            }

            this.page--;
        };

        this.firstPage = function () {
            this.page = 0;
        };

        this.lastPage = function () {
            this.page = this.pageCount() - 1;
        };

        this.isFirstPage = function () {
            return this.page == 0;
        };

        this.isLastPage = function () {
            return this.page == this.pageCount() - 1;
        };

        this.pageCount = function () {
            return Math.ceil(parseInt(this.itemCount) / parseInt(this.rowsPerPage));
        };
    })

    .directive('paginator', function factory() {
        return {
            restrict:'E',
            controller: function ($scope, Paginator) {
                $scope.paginator = Paginator;
            },
            templateUrl: 'paginationControl.html'
        };
    });