ngLazy: Making an AngularJS Library
In pursuit of re-usablity
A little while ago, I wrote a blog post about implementing a lazy load / infinite scroll in an app I was hacking on. The logic was spread between a controller and directive, and it needed decoupling. It couldn't be re-used without copying a ton of code into any controller that wanted to use the feature. Further, implementing my spinner required logic throughout the controller that exists just to manipulate the DOM. It was a fine first implementation, but now it's time to refactor. I planned to:
- extract all DOM logic and spinner related manipulation into a directive
- extract the entire concept and turn it into an Angular library.
The end goal is for the user to be able to implement a lazy-loading infinite scroll and spinner via a simple directive.
The result is ngLazy.
Now my controller is as agnostic to this feature as any other controller:
.controller('listController', ['$scope', function($scope){
$scope.data = {
mongo : [],
gh : {},
keen : {}
};
}])
The feature still requires a good amount of configuration per use case, so I have designed it with a factory and directive which takes configuration via the directive's element's attributes:
<lazy-load
lazy-data="data"
lazy-data-service="dataService"
lazy-fetch-method="getData"
lazy-range="12"
lazy-data-collection-key="mongo"
lazy-data-keys="['mongo','gh','keen']"
lazy-start-delay="150"
lazy-append-delay="1000"
lazy-spinner-color="#4FA7D9"
>
<table>
<thead>
<tr>
<td>NAME</td>
<td>STATS</td>
<td>COMMENTS</td>
</tr>
</thead>
<tbody>
<tr ng-repeat="datum in data.mongo">
<td class="cohort-pic">
<span class="cohort-name">{{ datum.First }} {{ datum.Last }}</span><br>
</td>
<td class="cohort-stats-col cohort-score">
<div> some stats</div>
</td>
<td ><textarea class="form-control cohort-comments-form" rows="3">{{ datum.comments }}</textarea>
</td>
</tr>
</tbody>
</table>
</lazy-load>
(function(angular){
'use strict';
angular.module('ngLazy',[
'ngLazy.factories',
'ngLazy.directives'
]);
angular.module('ngLazy.directives',[])
.directive('lazyLoad', ['$injector','$window','$document','$timeout','$rootScope',function($injector, $window, $document, $timeout, $rootScope){
var appendAnimations = function(){
var style = document.createElement('style');
var keyframes = '@-webkit-keyframes spin {\n' +
'\t0%{-webkit-transform: rotate(0deg);}\n' +
'\t100%{-webkit-transform: rotate(360deg);}\n' +
'}\n' +
'@keyframes spin{\n' +
'\t0%{transform: rotate(0deg);}\n' +
'\t100%{transform: rotate(360deg);}\n' +
'}';
style.innerHTML = keyframes;
document.head.appendChild(style);
};
var makeSpinner = function(el){
el.css({
WebkitBoxSizing: 'border-box',
boxSizing: 'border-box',
display: 'block',
width: '43px',
height: '43px',
margin: 'auto',
borderWidth: '8px',
borderStyle: 'solid',
borderColor: 'transparent rgb(85, 148, 250) rgb(85, 148, 250) rgb(85, 148, 250)',
borderRadius: '22px',
animation: 'spin 0.8s linear infinite',
WebkitAnimation: 'spin 0.8s linear infinite'
});
return el;
};
return {
restrict: 'E',
scope: {
lazyData : '=',
lazyDataCollectionKey : '@',
lazyDataService : '@',
lazyFetchMethod : '@',
lazyRange : '@',
lazyDataKeys : '=',
lazyStartDelay : '@',
lazyAppendDelay : '@',
lazySpinnerColor : '@'
},
transclude: true,
template: '<div ng-transclude></div>' +
'<div class=\'col-md-12 loading\' ng-hide=\'spinner.hide\'>' +
'<div class=\'loading-widget\'></div>' +
'</div>'+
'<div id=\'lazy-bottom\'></div>',
link: function(scope) {
var winEl = angular.element($window),
win = winEl[0],
lazyBottom = angular.element(document.querySelector('#lazy-bottom'))[0],
lazyBottomOffset = lazyBottom.offsetTop - 20,
lazyLoader = $injector.get('lazyLoader'),
dataService = $injector.get(scope.lazyDataService),
loadingWidget = angular.element(document.querySelector('.loading-widget')),
hasRun = false,
loading = false;
appendAnimations();
loadingWidget = makeSpinner(loadingWidget);
scope.spinner = { hide : false };
var lazyLoad = function(){
lazyLoader.configure({
data : scope.lazyData,
collectionKey : scope.lazyDataCollectionKey,
fetchData : dataService[scope.lazyFetchMethod],
range : scope.lazyRange,
dataKeys : scope.lazyDataKeys,
startDelay : scope.lazyStartDelay,
appendDelay : scope.lazyAppendDelay
});
lazyLoader.load().then(function(data){
if(!hasRun){
angular.forEach(Object.keys(data), function(key){
scope.lazyData[key] = data[key];
});
} else {
scope.lazyData[scope.lazyDataCollectionKey] = data[scope.lazyDataCollectionKey];
}
loading = false;
});
};
$rootScope.$on('hideLoading', function(){ scope.spinner.hide = true; });
$rootScope.$on('showLoading', function(){ scope.spinner.hide = false; });
winEl.bind('scroll', function(){
if (!loading && win.scrollY >= lazyBottomOffset) {
loading = true;
lazyBottomOffset = lazyBottomOffset * 2;
win.requestAnimationFrame(function(){
scope.$apply(function(){
lazyLoad();
lazyBottomOffset = lazyBottom.offsetTop-10;
});
});
}
});
lazyLoad();
}
};
}]);
angular.module('ngLazy.factories',[])
.factory('lazyLoader', ['$timeout','$rootScope', '$q', function($timeout, $rootScope, $q){
var cache = { data : {} },
config,
data,
collectionKey,
fetch,
responseKeys,
range,
appendDelay,
startDelay;
return ({
configure: function(options){
config = options;
},
getData : function(){
data = config.data;
collectionKey = config.collectionKey;
fetch = config.fetchData;
responseKeys = config.dataKeys;
range = config.range;
appendDelay = config.appendDelay;
startDelay = config.startDelay;
var deferred = $q.defer();
$rootScope.$broadcast('showLoading');
if (!cache.data[collectionKey]) {
fetch().then(function(res){
angular.forEach(responseKeys, function(key){
cache.data[key] = res.data[key];
if (key === collectionKey) {
data[key] = [];
data[key] = data[key].concat(cache.data[key].splice(0, range));
} else {
data[key] = cache.data[key];
}
});
deferred.resolve(data);
$rootScope.$broadcast('hideLoading');
});
} else {
$timeout(function(){
data[collectionKey] = data[collectionKey].concat(cache.data[collectionKey].splice(0, range));
deferred.resolve(data);
$rootScope.$broadcast('hideLoading');
}, appendDelay);
}
return deferred.promise;
},
load : function(){
var deferred = $q.defer();
var _this = this;
$rootScope.$broadcast('showLoading');
var loadTimer = $timeout(function(){
_this.getData().then(function(col){
deferred.resolve(col);
});
}, startDelay);
loadTimer.then(function(){
$timeout.cancel(loadTimer);
});
return deferred.promise;
}
});
}]);
})(angular);
In the future, I am looking to make this module more configurable, such as adding a custom spinner or easily changing the spinner styling. I also intend to further refactor this to remove elements from the DOM when they have left the screen, and re-add them upon scrolling up.
The configuration is explained in the repo's README, but the main-takeaway is this: with the <lazy-load>
tag, simply wrap the element that will be displaying your list of info. Tell the lazy-load library what service you will use to fetch that data and how you would like it to be divided and presented. ngLazy handles the rest.
NEXT UP: Testing ngLazy