10

I'm experiencing some problems regarding manipulation of the DOM after looping through the data.

We have a jQuery slider plugin that is tied to data and works normally, but when using ng-repeat, we have to wrap its initialization with $timeout for it to work — and now that isn't even working.

I think using $timeout is unreliable, which makes for a bad fix. In jQuery I could use $(document).ready() — which was solid, but using angular.element(document).ready() doesn't seem to work either.

The slider directive is invoked but cannot obtain the height of the images in the slider because the images have not been loaded into the DOM — resulting in the slider having a calculated height of 0.

I'm finding it very frustrating at the moment - there must be a way to manipulate the DOM after data (in an ng-repeat for example) has cycled through.

The Initialization of the slider is performed as follows:

var sliderLoad = function () {
    $timeout(function () {
        var setHeight = elem.find('.slide:eq(0)').outerHeight(true);
        elem.css({
            height: setHeight
        });
    }, 1000);
    // Show the slider nav buttons
    elem.parent().find('.direction-nav').show();
};

… and here is a reproduction demo.

Eliran Malka
  • 15,821
  • 6
  • 77
  • 100
30secondstosam
  • 4,476
  • 4
  • 28
  • 33
  • 1
    What is the behaviour of a slider once initialised? I think there is a better way of doing this. **EDIT**: If it's from a library a link to a demo would be great. – Ed_ Mar 13 '14 at 16:07
  • Hey @EdHinchliffe - I haven't quite got the slider to work in plunkr but the code looks a bit like this: http://plnkr.co/edit/PupKzfsu3uSov555Atfx?p=preview – 30secondstosam Mar 13 '14 at 16:45
  • I see the problem.. it's a tricky one. Basically the directive you have created gets compiled and linked to the scope before the transcluded content. So when you link function runs, the `ng-repeat` inside it hasn't been compiled. Thinking.. – Ed_ Mar 13 '14 at 17:17
  • p.s. Load jquery before you load angular: http://plnkr.co/edit/S7Nv0Bb4T3ahUwD2JTC0?p=preview – Ed_ Mar 13 '14 at 17:18
  • I can't think of an easy way to avoid this without having that timeout. I think basically this jQuery plugin is not very angular friendly. Angular is a different way of thinking than jQuery and this plugin's implementation is very much in the jQuery mode.. – Ed_ Mar 13 '14 at 17:23
  • 1
    Damn :( @EdHinchliffe. I was concerned about this. I'm gonna put a directive in it to detect when the ng-repeat is finished. and then broadcast a message to build the slider maybe? It's a difficult one indeed...!!! To be honest it could be ANY plugin in my opinion, Angular just seems a bit messy! – 30secondstosam Mar 13 '14 at 18:06
  • Coming from a jQuery world, perhaps it does, but actually I think the plugin's approach is messy - it relies on a specific structure in your DOM and then traverses it. If you were to build it from scratch in jQuery you could use a template in the directive, and the individual `slide` elements could handle their own styling and behaviour manipulations. – Ed_ Mar 13 '14 at 18:20
  • @30secondstosam, just saw your comment on detecting `ng-repeat` (after posting my answer...), you're almost there, and that's definitely an 'angulary' way of thinking about it. see my answer on where to go from there. – Eliran Malka Mar 13 '14 at 21:48
  • @30secondstosam, please tick my answer accepted if it solved your issue, thanks. – Eliran Malka Mar 19 '14 at 22:49
  • 1
    Hey @EliranMalka - we're still implementing this but I just want to say thank you again for the answer. Really appreciate the detail and time you must have taken over it. – 30secondstosam Mar 20 '14 at 09:38

1 Answers1

39


Quick-'n-Dirty it (Demo)

We would want to make sure that all images are loaded, so let's write a directive for that:

app.directive('loadDispatcher', function() {
    return {
        restrict: 'A',
        link: function(scope, element, attrs) {
            element.bind('load', function() {
                scope.$emit('$imageLoaded');
            });
        }
    };
})

… and attach it to the ng-src'd elements:

<img class="thumb-recipe" ng-src="{{ object.tile_url }}" load-dispatcher/>

Now we can compare the number of events caught with our model, and act as needed:

var loadCount = 0;
scope.$on('$imageLoaded', function () {
    if (loadCount++ === scope.videos.objects.length - 1) {
        _initSlider(); // act!
    }
});


Decouple it (Demo)

This is a little disturbing, as it's not adhering to the Law of Demeter — any directive that will watch the $imageLoaded event will have to know about the model (scope.videos.objects.length).

We can prevent this coupling by finding out how many images were loaded without explicitly addressing the model. Let's assume that the events will be handled in an ng-repeat.

  1. Make sure ng-repeat has finished, and fire an event with the items count. We can do that by attaching a controller with a sole purpose - to watch the $last property. Once it's found (with a truthy value), we will fire an event to notify about it:

    .controller('LoopWatchCtrl', function($scope) {
        $scope.$watch('$last', function(newVal, oldVal) {
            newVal && $scope.$emit('$repeatFinished', $scope.$index);
        });
    })
    
    <div ng-repeat="object in videos.objects" ng-controller="LoopWatchCtrl">
    
  2. Now, catch the events and activate the slider initialization accordingly:

    var loadCount = 0,
        lastIndex = 0;
    
    scope.$on('$repeatFinished', function(event, data) {
        lastIndex = data;
    });
    
    scope.$on('$imageLoaded', function() {
        if (lastIndex && loadCount++ === lastIndex) {
            _initSlider(element); // this is defined where-ever
        }
    });
    

There, now our directive does not have to know about the model. But, it's a bit cumbersome, now we have to tie a directive and a controller.


Fold it (Demo)

Let's extract this whole shabang into a single directive:

app.directive('imageLoadWatcher', function($rootScope) {
    return {
        restrict: 'A',
        link: function(scope, element, attrs) {
            if (typeof $rootScope.loadCounter === 'undefined') {
                $rootScope.loadCounter = 0;
            }
            element.find('img').bind('load', function() {
                scope.$emit('$imageLoaded', $rootScope.loadCounter++);
            });
        },
        controller: function($scope) {
            $scope.$parent.$on('$imageLoaded', function(event, data) {
                if ($scope.$last && $scope.$index === $rootScope.loadCounter - 1) {
                    $scope.$emit('$allImagesLoaded');
                    delete $rootScope.loadCounter;
                }
            });
        }
    };
});

… which will be applied to the ng-repeated element:

<div ng-repeat="object in videos.objects" class="slide" image-load-watcher>

Now we can simply watch $allImagesLoaded e.g. in the slider:

scope.$on('$allImagesLoaded', function() {
    _initSlider(element);
});


Generalize it (if you want to) (Demo)

We can break it down again and apply this approach app-wide to use event dispatching for any ng-repeat finish or ng-src load, which is not always necessary (1), but can be quite useful. Let's see how:

  1. Decorate the ng-src directive, so it sends an event when an image is loaded:

    app.config(function($provide) {
        $provide.decorator('ngSrcDirective', function($delegate) {
            var directive = $delegate[0],
                link = directive.link;
            directive.compile = function() {
                return function(scope, element, attrs) {
                    link.apply(this, arguments);
                    element.bind('load', function() {
                        scope.$emit('$imageLoaded');
                    });
                };
            };
    
            return $delegate;
        });
        // ...
    });
    
  2. Decorate ng-repeat to notify when it finishes:

    app.config(function($provide) {
        // ...
        $provide.decorator('ngRepeatDirective', function($delegate) {
            var directive = $delegate[0],
                link = directive.link;
            directive.compile = function() {
                return function(scope, element, attrs) {
                    link.apply(this, arguments);
                    scope.$watch('$$childTail.$last', function(newVal, oldVal) {
                        newVal && scope.$emit('$repeatFinished');
                    });
                };
            };
    
            return $delegate;
        });
    });
    
  3. Now can catch the events anywhere, e.g. in your slider directive:

    var repeatFinished = false;
    var loadCount = 0;
    
    scope.$on('$repeatFinished', function() {
        repeatFinished = true;
    });
    
    scope.$on('$imageLoaded', function () {
        if (repeatFinished && 
                    loadCount++ === scope.videos.objects.length - 1) {
            _initSlider(); // this is defined where-ever
        }
    });
    

It seems to defeat the purpose, as we're back to square one, but it can be very powerful. And also — look, mommy, no new directives!

<div ng-repeat="object in videos.objects" class="slide">
    <img class="thumb-recipe" ng-src="{{ object.tile_url }}"/>
</div>


Put a sock in it (Demo)


     TL;DR, just gimme tha demo ! ! !     
 



1. Decoration has to be considered carefully, as the result will be sending an event on each image loaded across the application.


Overriding the link function will not be possible in version 1.3.x onwards.

Eliran Malka
  • 15,821
  • 6
  • 77
  • 100
  • 1
    Hey - I'll give that a try tomorrow :) thanks for the detailed answer – 30secondstosam Mar 13 '14 at 21:44
  • 1
    What an elaborate answer! Thank you – VitalyB Jul 10 '14 at 20:25
  • 2
    You sir, deserve a medal for javascript programming!! `newVal && scope.$emit('$repeatFinished');`!! Mind blown. –  Sep 26 '14 at 09:39
  • 1
    thanks, @nightgaunt, it's nice to be appreciated :) this works as almost all expressions in javascript returns a value, which can be treated as truthy or falsey (boolean). that form of writing is called functional-style coding. – Eliran Malka Nov 19 '14 at 13:13
  • Yup. It is not very often I see people using it and rarely in a case where it reduces a lengthy code. –  Nov 20 '14 at 03:53
  • A very nice answer but it seems that its not working with new version of Angular, e.g here in this plnkr : http://plnkr.co/edit/cIT1eKZ2P4K6TQZciA1N?p=preview Any idea anyone ? – Sohail Jan 18 '15 at 14:30
  • @EliranMalka - sorry, my bad I send you the wrong plnkr, But if you add the latest angular library (https://ajax.googleapis.com/ajax/libs/angularjs/1.3.9/angular.min.js) library instead of 1.2.14 , it will give you error – Sohail Jan 19 '15 at 06:03
  • Not to rain on your parade but IMO expressions such as `newVal &&` are to be avoided as it is not a common Javascript idiom (Perl yes, Javascript no) and is less clear than simply `if (newVal)`. Also `newVal &&` has absolutely *nothing* to do with functional programming – Dexygen Jan 06 '16 at 19:54
  • @GeorgeJempty - in the meanwhile, i got older and wiser, and true; such expressions have *nothing* to do with functional programming. thanx :) – Eliran Malka Dec 12 '18 at 14:53