Masonry with AngularJS

Masonry with AngularJS

I am developing an “art gallery” app.
Feel free to pull down the source on github and play around with it.
Plunker with full source.
The current work around for getting Masonry to play nice with Angular:
.directive(“masonry”, function($parse) {
return {
restrict: ‘AC’,
link: function (scope, elem, attrs) {
elem.masonry({ itemSelector: ‘.masonry-brick’});
}
};
})
.directive(‘masonryBrick’, function ($compile) {
return {
restrict: ‘AC’,
link: function (scope, elem, attrs) {
scope.$watch(‘$index’,function(v){
elem.imagesLoaded(function () {
elem.parents(‘.masonry’).masonry(‘reload’);
});
});
}
};
});

This doesn’t work well because:

As the content grows, so does the overhead of tiggering reload on the entire container.

The reload function:

Does not “append” items, rather re-arranges every item in the container.
Does work for triggering a reload when items are filtered out of a result set.

In context with the app I’ve given links to above, this problem becomes very easy to replicate.
I am looking for a solution that will use directives to leverage:
.masonry(‘appended’, elem) and .masonry(‘prepended’, elem)
Rather than executing .masonry(‘reload’) every time.
.masonry(‘reload’) for when elements are removed from result set.

EDIT
The project has been updated to use the working solution below.
Grab the source on GitHub
See a working version on Plunker

Solutions/Answers:

Solution 1:

I’ve been playing around with this a bit more and @ganaraj’s answer is pretty neat. If you stick a $element.masonry('resize'); in his controller’s appendBrick method and account for
the images loading then it looks like it works.

Here’s a plunker fork with it in: http://plnkr.co/edit/8t41rRnLYfhOF9oAfSUA

The reason this is necessary is because the number of columns is only calculated when masonry is initialized on the element or the container is resized and at this point we haven’t got any bricks so it defaults to a single column.

If you don’t want to use the ‘resize’ method (I don’t think it’s documented) then you could just call $element.masonry() but that causes a re-layout so you’d want to only call it when the first brick is added.

Edit: I’ve updated the plunker above to only call resize when the list grows above 0 length and to do only one “reload” when multiple bricks are removed in the same $digest cycle.

Directive code is:

angular.module('myApp.directives', [])
  .directive("masonry", function($parse, $timeout) {
    return {
      restrict: 'AC',
      link: function (scope, elem, attrs) {
        elem.masonry({ itemSelector: '.masonry-brick'});
        // Opitonal Params, delimited in class name like:
        // class="masonry:70;"
        //elem.masonry({ itemSelector: '.masonry-item', columnWidth: 140, gutterWidth: $parse(attrs.masonry)(scope) });
      },
      controller : function($scope,$element){
          var bricks = [];
          this.appendBrick = function(child, brickId, waitForImage){
            function addBrick() {
              $element.masonry('appended', child, true);

              // If we don't have any bricks then we're going to want to 
              // resize when we add one.
              if (bricks.length === 0) {
                // Timeout here to allow for a potential
                // masonary timeout when appending (when animating
                // from the bottom)
                $timeout(function(){
                  $element.masonry('resize');  
                }, 2);  
              }

              // Store the brick id
              var index = bricks.indexOf(brickId);
              if (index === -1) {
                bricks.push(brickId);
              }
            }

            if (waitForImage) {
              child.imagesLoaded(addBrick);      
            } else {
              addBrick();
            }
          };

          // Removed bricks - we only want to call masonry.reload() once
          // if a whole batch of bricks have been removed though so push this
          // async.
          var willReload = false;
          function hasRemovedBrick() {
            if (!willReload) {
              willReload = true;
              $scope.$evalAsync(function(){
                willReload = false;
                $element.masonry("reload");
              });
            }
          }

          this.removeBrick = function(brickId){
              hasRemovedBrick();
              var index = bricks.indexOf(brickId);
              if (index != -1) {
                bricks.splice(index,1);
              }
          };
      }
    };     
  })
  .directive('masonryBrick', function ($compile) {
    return {
      restrict: 'AC',
      require : '^masonry',
      link: function (scope, elem, attrs, MasonryCtrl) {

      elem.imagesLoaded(function () {
        MasonryCtrl.appendBrick(elem, scope.$id, true);
      });

      scope.$on("$destroy",function(){
          MasonryCtrl.removeBrick(scope.$id);
      }); 
    }
  };
});

Solution 2:

This is not exacly what you are looking for (prepend and append), but should be just what you are looking for:

Related:  How to define custom sort function in javascript?

http://plnkr.co/edit/dmuGHCNTCBBuYpjyKQ8E?p=preview

Your version of the directive triggers reload for every brick. This version triggers only reload only once for the whole list change.

The approach is very simple:

  1. Register new bricks in parent masonry controller
  2. $watch for changes in the registered bricks and fire masonry('reload')
  3. Remove brick from bricks registry when you are removing the element – $on('$destroy')
  4. ?
  5. Profit

You can extend this approach to do what you wanted (use prepend and append) but I don’t see any reason why you would want to do that. This also would be much more complicated, since you would have to manually track the order of the elements. I also don’t belive it would be any faster – on the contrary it may be slower, since you would have to trigger multiple append/prepend if your changes a lot of bricks.

I am not quite sure, but I guess you could use ng-animate for this (the JavaScript animation version)

We have implemented something similar for tiling events in our calendar app. This solution turned out to be the fastest. If anyone has better solution, I’d love to see that.

Related:  Can Mustache Templates do template extension?

For those who want to se the code:

angular.module('myApp.directives', [])
  .directive("masonry", function($parse) {
    return {
      restrict: 'AC',
      controller:function($scope,$element){
        // register and unregister bricks
        var bricks = [];
        this.addBrick = function(brick){
          bricks.push(brick)
        }
        this.removeBrick = function(brick){
          var index = bricks.indexOf(brick);
          if(index!=-1)bricks.splice(index,1);
        }
        $scope.$watch(function(){
          return bricks
        },function(){
          // triggers only once per list change (not for each brick)
          console.log('reload');
          $element.masonry('reload');
        },true);
      },
      link: function (scope, elem, attrs) {
        elem.masonry({ itemSelector: '.masonry-brick'});
      }
    };     
  })
  .directive('masonryBrick', function ($compile) {
    return {
      restrict: 'AC',
      require:'^masonry',
      link: function (scope, elem, attrs,ctrl) {
        ctrl.addBrick(scope.$id);

        scope.$on('$destroy',function(){
          ctrl.removeBrick(scope.$id);
        });
      }
    };
  });

Edit:
there is one thing I forgot about (loading images) – just call ‘reload’ when all images were loaded. Ill try to edit the code later.

Solution 3:

Hey I just made masonry directive for AngularJS that is far more simpler than most of the implementations I’ve seen. Check the gist out here: https://gist.github.com/CMCDragonkai/6191419

It’s compatible with AMD. Requires jQuery, imagesLoaded and lodash. Works with dynamic amount of items, AJAX loaded items (even with initial items), window resizing, and custom options. Prepended items, appended items, reloaded items… etc. 73 lines!

Here’s a plunkr showing it work: http://plnkr.co/edit/ZuSrSh?p=preview (without AMD, but the same code).

Solution 4:

One of the least documented feature of Angular is its Directive Controllers ( though it is on the front page of www.angularjs.org – Tabs ).

Here is a modified plunker that makes use of this mechanism.

http://plnkr.co/edit/NmV3m6DZFSpIkQOAjRRE

People do use Directive Controllers but it has been used ( and abused ) for things it probably was not meant for.

In the plunker above I have only modified the directives.js file. Directive controllers are a mechanism for communication between directives. Sometimes , it is not sufficient / easy to do everything in one directive. In this case, you have already created two directives but the right way to make them interact is through a directive controller.

I was not able to figure out when you wanted to prepend and when you wanted to append. I have only implemented “append” currently.

Also on a side note : If resources doesnt already implement promises, you can implement them yourself. It isnt really hard to do that. I noticed you are using a callback mechanism (which I wouldnt recommend ). You have already put in promises there but still you are using callbacks which I was not able to understand why.

Does this provide a proper solution to your problem ?

Related:  How to clear radio button in Javascript?

For documentation see http://docs.angularjs.org/guide/directive > Directive Definition Object > controller.

Solution 5:

I believe that I have had exactly the same problem:

Many images in a ng-repeat loop, and want to apply masonry/isotope to them when they are loaded and ready.

The issue is that even after imagesLoaded is called back there is a period of time when the images are not ‘complete’, and so can not be measured and layed out properly.

I have come up with the following solution that works for me and only requires one layout pass. It occurs in three stages

  1. Wait for images to load (when the last one is added from the loop – uses the jQuery images loaded plugin).
  2. Wait for all images to ‘complete’
  3. Layout the images.

angularApp.directive('checkLast', function () {
    return {
        restrict: 'A',
        compile: function (element, attributes) {
            return function postLink(scope, element) {
                if (scope.$last === true) {
                    $('#imagesHolder').imagesLoaded(function () {
                        waitForRender();
                    });
                }
            }
        }
    }
});

function waitForRender() {
    //
    // We have to wait for every image to be ready before we can lay them out
    //
    var ready = true;
    var images = $('#imagesHolder').find('img');
    $.each(images,function(index,img) {
        if ( !img.complete ) {
            setTimeout(waitForRender);
            ready = false;
            return false;
        }
    });
    if (ready) {
        layoutImages();
    }
}

function layoutImages() {
    $('#imagesHolder').isotope({
        itemSelector: '.imageHolder',
        layoutMode: 'fitRows'
    });
}

This works with layout like this

<div id="imagesHolder">
    <div class="imageHolder"
         check-last
         ng-repeat="image in images.image"
        <img ng-src="{{image.url}}"/>
    </div>
</div>

I hope this helps.

Solution 6:

Rather than using two directives you could incorporate them both into one directive. Something like:

.directive("masonry", function($timeout) {
    return {
        restrict: 'AC',
        template: '<div class="masonry-brick" ng-repeat="image in pool | filter:{pool:true}">' +
                        '<span>{{image.albumTitle|truncate}}</span>' +
                        '<img ng-src="{{image.link|imageSize:t}}"/>' +
                  '</div>',
        scope: {
            pool: "="
        },
        link: function(scope, elem, attrs){
            elem.masonry({itemSelector: '.masonry-brick'});

            // When the pool changes put all your logic in for working out what needs to be prepended
            // appended etc
            function poolChanged(pool) {

                //... Do some logic here working out what needs to be appended, 
                // prepended...

                // Make sure the DOM has updated before continuing by doing a $timeout
                $timeout(function(){
                    var bricks = elem.find('.masonry-brick');
                    brick.imagesLoaded(function() {
                        // ... Do the actual prepending/appending ...
                    });
                });
            }

            // Watch for changes to the pool
            scope.$watch('pool', poolChanged, true); // The final true compares for 
                                                     // equality rather than reference
        }
    }
});

and html usage:

<div class="masonry" pool="pool"></div>

References