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