AngularJS - ngInfiniteScroll Example
ngInfiniteScroll: Converting a home-grown “Show More” function to a more elegant AngularJS solution
I can think of numerous times in the past where I had some list of data on a webpage that would frequently require the ability for users to crawl deeper into the results in search of something. In many of the more complex web and mobile applications that we build for clients, there is simply too much data to present in a confined space. We frequently come across the requirement for a data list control that allows for paging or scrolling. This requirement has frequently materialized into a data grid or a sorted list with a custom function to go back to the server and request the next set(s) of records. The goals are usually to gather the most essential subset of data, present that to the user on first load, and then allow the user to easily get to the rest of the data as needed. I have recently started using the ngInfiniteScroll directive in our Angular projects for this type of feature, after seeing one of my teammates, Taylor Smith, implement it successfully.
In this article, I will review moving from a button to load more data to using the ngInfiniteScroll directive to load data as the user scrolls the list. Months ago, I had created a function to load an additional 30 days of patient data with a “Show More” button. As the application matured and we had greater amounts of older data that was only reviewed occassionally, I switched that to use this infinite scroll directive for a more elegant user experience.
The Old (Custom):
In order to display 30 days of the patient’s data on the page load, there is an Angular service call to an api controller method. The initial call passes in a patient’s id and parameters to get the most recent 30 days’ worth of data.
The Angular service:
function DocumentationService($resource, $q) {
this.getByPatientIdForDays = function (id, daystart, dayend) {
var deferred = $q.defer();
var resource = $resource('api/Documentation/GetByPatientIdForDays', { Id: id, DayStart: daystart, DayEnd: dayend }, { 'query': { method: 'Get', isArray: true } });
resource.query(function (response) {
deferred.resolve(response);
}, function (response) {
deferred.reject(response);
});
return deferred.promise;
};
}
module.service('Documentation', ['$resource', '$q', DocumentationService]);
The MVC API Controller method
[Route("GetByPatientIdForDays")]
public IList<DocumentationDTO> GetByPatientIdForDays(int Id, int DayStart, int DayEnd)
{
List<DocumentationDTO> docDTOs = new List<DocumentationDTO>();
DateTime limitStart = DateTime.Now.AddDays(-DayStart);
//If the start date was passed in as 0, find the most recent date for documentation
if (DayStart == 0) { limitStart = DateTime.Now.AddDays(-(CalculateMostRecentDaysAgo(Id) + 30)); }
DateTime limitEnd = DateTime.Now.AddDays(-DayEnd);
IQueryable<Document> docs = _docRepository.AsQueryable().Where(x => x.PatientId == Id && x.CreatedDate > limitStart && x.CreatedDate <= limitEnd).OrderByDescending(x => x.CreatedDate);
//The code then maps the query results to the DTO objects and returns that.
}
The original load of the page sends in 0 for the date parameters and returns the array of results to a scope parameter named $scope.Documents
return Documentation.getByPatientIdForDays(id, 0, 0);
In order for this feature to work, I needed to know a few things about the document data for the patient:
An integer value for the number of days from today to the earliest date of a patient’s documents.
- I added this as
$scope.mostDaysAgo
- This value is set by an api call (GET) to a controller method called GetMostDaysAgo, which returns an integer.
- I added this as
An integer value for the number of days from today to the latest date of a patient document.
- I added this as
$scope.leastDaysAgo
- This value is set by an api call (GET) to a controller method called GetLeastDaysAgo, which returns an integer.
- I added this as
//Allow the user to move back until they reach the oldest records... 30 days at a time
//Get most recent data's date
$scope.getLeastDaysAgo = function () {
$http({
method: 'get',
url: 'api/Documentation/GetLeastDaysAgoByPatientId?id=' + patient.Id
}).success(function (data) {
$scope.leastDaysAgo = data + 30; //start with 30 days back from first available data
});
};
//Get oldest data's date
$scope.getMostDaysAgo = function () {
$http({
method: 'get',
url: 'api/Documentation/GetMostDaysAgoByPatientId?id=' + patient.Id
}).success(function (data) {
$scope.mostDaysAgo = data;
});
};
On the html page, we have Angular displaying the list of documents and the document properties that are important to the users.
<div class="documentation-box">
<div class="document-item" ng-repeat="doc in Documents">
<h2></h2>
...
At the bottom of the results, we have a button to “Show More”, if applicable. If the $scope.mostDaysAgo
is less than 30 days ago, then this button and section will not be displayed at all. The section and button will continue to be displayed until the user browses back in time to the oldest document data for this patient.
<div class="document-item" ng-show="mostDaysAgo > 60">
<h4>Currently showing results from through today.</h4>
<h4>The oldest results for this patient are from .</h4>
<button class="show-more btn" ng-click="showMore()" ng-show="mostDaysAgo > 30 && leastDaysAgo < mostDaysAgo">
Show 30 Days More
</button>
</div>
The leastDaysAgoFmt
and mostDaysAgoFmt
scope variables are a formatted version of the start and end date. They are used for display only.
The Angular ng-click of the button (when enabled) makes a new call to a function that:
Increments the days back in time you are viewing.
Calls the controller method GetByPatientIdForDays(int Id, int DayStart, int DayEnd) to get a new set of data for the new parameters.
Appends the resulting documents to what we are already displaying on the page so that the user can continue to scroll downward. The document data already loaded remains on the page and the new data appears beneath it.
$scope.showMore = function () {
$scope.leastDaysAgo += 30;
Documentation.getByPatientIdForDays(patient.Id, $scope.leastDaysAgo, $scope.leastDaysAgo - 30).then(function (response) {
_.each(response, function (item) {
$scope.Documents.push(item);
});
});
}
This process repeats until the user reaches the oldest documents for the patient and then the button will disappear.
Currently showing results from 2/4/2016 through today. The oldest results for this patient are from 5/20/2015.
The user:
Clicks button… (scrolls to bottom)…. Clicks button… (scrolls to bottom)…
Currently showing results from 5/20/2015 through today.
The New (ngInfiniteScroll):
There weren’t a ton of changes that needed to happen to implement an infinite scroll on the document data list. We first needed to add ngInfiniteScroll to our application. We used the instructions from the github site:
We then added it to our Angular bundle and made it a new dependency in our main application module:
var module = angular.module('{OUR_APPLICATION_NAME}', ['ngRoute', ..., 'infinite-scroll']);
Once the infinite scroll package was included in our application we made the necessary changes to the JavaScript functions on the document list page along with setting some new attributes on the corresponding elements in the html page:
<div infinite-scroll='showMore()' infinite-scroll-disabled='loadingDocuments || mostDaysAgo <= 30 || leastDaysAgo >= mostDaysAgo' infinite-scroll-distance='1'>
The infinite-scroll attribute defines the script function to call when triggered.
The infinite-scroll-disabled attribute defines when the scrolling function is off. In our case, scrolling will be disabled while the showMore() JavaScript function is in progress (waiting for results from the api call). It will also be disabled when there are no more data points more than 30 days ago.
The infinite-scroll-distance represents how close the bottom of the control (div) is to the bottom of the browser window and it defines when to trigger the infinite-scroll. It is multiplier based on the height of the browser window. The default value is 0, which would cause the event to trigger only when the bottom of the control came into view in the browser window. If you set it at 2, for example, and your browser window was 1000 pixels in height, the event would fire when the bottom of the control was within 2000 pixels. I played around with this value until it worked for our needs (set at 1). Each document in our data takes up several hundred pixels in height, but if you are working with more row-oriented data that takes up little vertical space, then leave the attribute out altogether or set it to the default of 0.
I removed the “SHOW 30 DAYS MORE” button from the bottom, added a loading spinner, and kept the rest the same so that the user could still see the additional information about where they were in the history of the documents.
<div class="document-item" ng-show="mostDaysAgo > 30">
<h4>Currently showing results from through today. <span ng-show="loadingDocuments">Loading next 30 days...</span> <i ng-show="loadingDocuments" class="fa fa-spinner fa-pulse fa-4x loading-spinner"></i></h4>
<h4>The oldest results for this patient are from .</h4>
</div>
I needed to add one additional $scope
item (loadingDocuments
to keep track of when the retrieval of more data was in process) and set it to false by default. I then modified the showMore
function to set that value to true while the script was waiting for the next set of documents and false afterwards. This is also what triggers the loading spinner to show.
$scope.showMore = function () {
if ($scope.loadingDocuments) return;
$scope.loadingDocuments = true;
$scope.leastDaysAgo += 30;
Documentation.getByPatientIdForDays(patient.Id, $scope.leastDaysAgo, $scope.leastDaysAgo - 30).then(function (response) {
_.each(response, function (item) {
$scope.Documents.push(item);
});
$scope.loadingDocuments = false;
});
}
With the new changes, the page initially loads with the past 30 days of documentation like it did before. If the user scrolls to the bottom of the list of documents, a new message saying “Loading next 30 days…”, the loading spinner graphic appear. When the api call returns, the results show while the loading message and spinner hide. The same will continue to happen when the user again scrolls to the bottom of the page until there are no longer any older results. In our example, the end of the line would be 5/20/2015.
Conclusion
Like most of you out there, I look for ways to improve my development work in order to make applications run more smoothly and to make them easier for the user. This article explored a place where I had previously built a feature to allow users to explore older data, but users found this feature frustrating as the application matured and there was more archived data to browse through. The ngInfiniteScroll directive provided an easier and more intuitive solution for users and I used this practical example to introduce you to this great tool.