Calling a function when ng-repeat has finished
Asked Answered
I

10

251

What I am trying to implement is basically a "on ng repeat finished rendering" handler. I am able to detect when it is done but I can't figure out how to trigger a function from it.

Check the fiddle:http://jsfiddle.net/paulocoelho/BsMqq/3/

JS

var module = angular.module('testApp', [])
    .directive('onFinishRender', function () {
    return {
        restrict: 'A',
        link: function (scope, element, attr) {
            if (scope.$last === true) {
                element.ready(function () {
                    console.log("calling:"+attr.onFinishRender);
                    // CALL TEST HERE!
                });
            }
        }
    }
});

function myC($scope) {
    $scope.ta = [1, 2, 3, 4, 5, 6];
    function test() {
        console.log("test executed");
    }
}

HTML

<div ng-app="testApp" ng-controller="myC">
    <p ng-repeat="t in ta" on-finish-render="test()">{{t}}</p>
</div>

Answer: Working fiddle from finishingmove: http://jsfiddle.net/paulocoelho/BsMqq/4/

Iden answered 4/3, 2013 at 17:51 Comment(4)
Out of curiosity, what is the purpose of the element.ready() snippet? I mean.. is it some sort of jQuery plugin that you have, or should it be triggered when the element is ready?Disparity
One could do it using built-in directives like ng-initConstellate
Possible duplicate of ng-repeat finish eventCyanotype
duplicate of #13471629, see my answer thereSixty
S
400
var module = angular.module('testApp', [])
    .directive('onFinishRender', function ($timeout) {
    return {
        restrict: 'A',
        link: function (scope, element, attr) {
            if (scope.$last === true) {
                $timeout(function () {
                    scope.$emit(attr.onFinishRender);
                });
            }
        }
    }
});

Notice that I didn't use .ready() but rather wrapped it in a $timeout. $timeout makes sure it's executed when the ng-repeated elements have REALLY finished rendering (because the $timeout will execute at the end of the current digest cycle -- and it will also call $apply internally, unlike setTimeout). So after the ng-repeat has finished, we use $emit to emit an event to outer scopes (sibling and parent scopes).

And then in your controller, you can catch it with $on:

$scope.$on('ngRepeatFinished', function(ngRepeatFinishedEvent) {
    //you also get the actual event object
    //do stuff, execute functions -- whatever...
});

With html that looks something like this:

<div ng-repeat="item in items" on-finish-render="ngRepeatFinished">
    <div>{{item.name}}}<div>
</div>
Sexology answered 4/3, 2013 at 18:23 Comment(27)
+1, but I'd use $eval before using an event -- less coupling. See my answer for more details.Cooky
@PigalevPavel I think you're confusing $timeout (which is basically setTimeout + Angular's $scope.$apply()) with setInterval. $timeout will execute only once per the if condition, and that will be at the beginning of the next $digest cycle. For more info on JavaScript timeouts, see: ejohn.org/blog/how-javascript-timers-workSexology
Thanks for your answer, works great! I just have one question: you didn't set any delay in $timeout, so when timeout stops?Chinchilla
@finishingmove Maybe I don't understand something :) But see I have ng-repeat in another ng-repeat. And I want to know for sure when all of them are finished. When I use your script with $timeout just for parent ng-repeat it all works fine. But if I don't use $timeout, I get a response before children ng-repeats are finished. I want to know why? And can I be sure that if I use your script with $timeout for parent ng-repeat I'll allways get a response when all ng-repeats are finished?Chinchilla
Yeah, I now understand that I just didn't ask my question right. I meant this: you didn't set any delay in $timeout, so when the function in $timeout starts working?Chinchilla
The delay parameter of $timeout is optional; the default is 0. docs.angularjs.org/api/ng.$timeoutSidestroke
Why you would use something like setTimeout() with 0 delay is another question though I must say I've never come across an actual specification for the browser's event queue anywhere, just what's implied by single-threadedness and the existence of setTimeout().Sidestroke
This approach works very well, thank you. It needs to be built into Angular natively, with no fancy workarounds.Dying
scope.$last is true only for the last element. If I add elements to the beginning of the list, the 'ngRepeatFinished' event will never happen.Kamasutra
@Kamasutra it is triggered whenever ng-repeat is triggered. Make sure you're not adding your elements directly into the DOM, but using the controller.Sexology
@finishingmove I've tested it more than once. If I add elements to the beginning of the list, the callback will not called. This is because scope.$last is never true (none of the rendered elements is not the last).Kamasutra
@Kamasutra just tested it myself, and you were right. scope.$first would work perfectly for that case.Sexology
@finishingmove In this case this is not ngRepeatFinished :)Kamasutra
Note that for a nested repeat, the directive must be on the inner-most repeat. It appears to only execute once as needed, vs once per execution of top level repeat.Economical
I found this very useful when using canvas.loadFromJSON(...) on canvas elements that were dynamically added to the view. ThanksKennith
nothing executed for me. I don't see how the event handler in the view is passed to anything.Puffery
Thanks for this answer. I have a question, why do we need to assign on-finish-render="ngRepeatFinished"? When I assign on-finish-render="helloworld", it works the same.Digit
how would you go about running this on a initially hidden element? doesn't work atm.Aristate
This works only if the last item changed. The problem is that with filters (and with track by) sometimes the last item is the same but some of the items in the middle are removed/added. In that case the event will not be called.Vaules
If I have multiple tabs that have ng-repeat (i.e., nested), it only works on the first one. How can I make it work on the other ones?Theressathereto
this code not work when add or remove in middle arrayHoney
It should be scope.$emit(attr.ngRepeatFinished);Unutterable
scope.$emit(attr.onFinishRender);Blowhard
thanks, my hero, that literally fixed all my window print issues in 1min :)Drillstock
As @Vaules pointed out, the problem remains that when filters and track by is applied, the event will not be called. And in my case, without the use of track by, ng-repeat causes flickering.Carree
How do I make this work with controller as syntax? plzGujranwala
i love u. i rly do.Patience
C
89

Use $evalAsync if you want your callback (i.e., test()) to be executed after the DOM is constructed, but before the browser renders. This will prevent flicker -- ref.

if (scope.$last) {
   scope.$evalAsync(attr.onFinishRender);
}

Fiddle.

If you really want to call your callback after rendering, use $timeout:

if (scope.$last) {
   $timeout(function() { 
      scope.$eval(attr.onFinishRender);
   });
}

I prefer $eval instead of an event. With an event, we need to know the name of the event and add code to our controller for that event. With $eval, there is less coupling between the controller and the directive.

Cooky answered 4/3, 2013 at 19:19 Comment(4)
What does the check for $last do? Can't find it in the docs. Was it removed?Olgaolguin
@ErikAigner, $last is defined on the scope if an ng-repeat is active on the element.Cooky
Looks like your Fiddle is taking in an unnecessary $timeout.Childbearing
Great. This should be the accepted answer, seeing that emit/broadcast costs more from performance point of view.Houk
A
28

The answers that have been given so far will only work the first time that the ng-repeat gets rendered, but if you have a dynamic ng-repeat, meaning that you are going to be adding/deleting/filtering items, and you need to be notified every time that the ng-repeat gets rendered, those solutions won't work for you.

So, if you need to be notified EVERY TIME that the ng-repeat gets re-rendered and not just the first time, I've found a way to do that, it's quite 'hacky', but it will work fine if you know what you are doing. Use this $filter in your ng-repeat before you use any other $filter:

.filter('ngRepeatFinish', function($timeout){
    return function(data){
        var me = this;
        var flagProperty = '__finishedRendering__';
        if(!data[flagProperty]){
            Object.defineProperty(
                data, 
                flagProperty, 
                {enumerable:false, configurable:true, writable: false, value:{}});
            $timeout(function(){
                    delete data[flagProperty];                        
                    me.$emit('ngRepeatFinished');
                },0,false);                
        }
        return data;
    };
})

This will $emit an event called ngRepeatFinished every time that the ng-repeat gets rendered.

How to use it:

<li ng-repeat="item in (items|ngRepeatFinish) | filter:{name:namedFiltered}" >

The ngRepeatFinish filter needs to be applied directly to an Array or an Object defined in your $scope, you can apply other filters after.

How NOT to use it:

<li ng-repeat="item in (items | filter:{name:namedFiltered}) | ngRepeatFinish" >

Do not apply other filters first and then apply the ngRepeatFinish filter.

When should I use this?

If you want to apply certain css styles into the DOM after the list has finished rendering, because you need to have into account the new dimensions of the DOM elements that have been re-rendered by the ng-repeat. (BTW: those kind of operations should be done inside a directive)

What NOT TO DO in the function that handles the ngRepeatFinished event:

  • Do not perform a $scope.$apply in that function or you will put Angular in an endless loop that Angular won't be able to detect.

  • Do not use it for making changes in the $scope properties, because those changes won't be reflected in your view until the next $digest loop, and since you can't perform an $scope.$apply they won't be of any use.

"But filters are not meant to be used like that!!"

No, they are not, this is a hack, if you don't like it don't use it. If you know a better way to accomplish the same thing please let me know it.

Summarizing

This is a hack, and using it in the wrong way is dangerous, use it only for applying styles after the ng-repeat has finished rendering and you shouldn't have any issues.

Administrative answered 29/9, 2014 at 3:4 Comment(5)
Thx 4 the tip. Anyway, a plunkr with a working example would be much appreciated.Gumma
Unfortunately this doesn't worked for me, at least for latest AngularJS 1.3.7. So i discovered another workaround, not the nicest one, but if this is essential for you it will work. What i did is whenever i add/change/delete elements i always add another dummy element to the end of list, therefore since $last element is also changed, simple ngRepeatFinish directive by checking $last now will work. As soon as ngRepeatFinish is called i remove dummy item. (I also hide it with CSS so it dooesn't appear briefly)Untutored
This hack is nice but not perfect; every time the filter kicks in the event is dispatched five times :-/Corruptible
The one below by Mark Rajcok works perfectly every re-render of the ng-repeat (e.g. because of model change). So this hacky solution isn't required.Swiftlet
@Administrative you're right - when items are spliced/unshifted (i.e. the array being mutated), it doesn't work. My previous testing probably was with array that have been reassigned (using sugarjs) therefore it worked every time... Thanks for pointing this outSwiftlet
G
10

If you need to call different functions for different ng-repeats on the same controller you can try something like this:

The directive:

var module = angular.module('testApp', [])
    .directive('onFinishRender', function ($timeout) {
    return {
        restrict: 'A',
        link: function (scope, element, attr) {
            if (scope.$last === true) {
            $timeout(function () {
                scope.$emit(attr.broadcasteventname ? attr.broadcasteventname : 'ngRepeatFinished');
            });
            }
        }
    }
});

In your controller, catch events with $on:

$scope.$on('ngRepeatBroadcast1', function(ngRepeatFinishedEvent) {
// Do something
});

$scope.$on('ngRepeatBroadcast2', function(ngRepeatFinishedEvent) {
// Do something
});

In your template with multiple ng-repeat

<div ng-repeat="item in collection1" on-finish-render broadcasteventname="ngRepeatBroadcast1">
    <div>{{item.name}}}<div>
</div>

<div ng-repeat="item in collection2" on-finish-render broadcasteventname="ngRepeatBroadcast2">
    <div>{{item.name}}}<div>
</div>
Gerstner answered 5/6, 2015 at 15:10 Comment(0)
D
5

The other solutions will work fine on initial page load, but calling $timeout from the controller is the only way to ensure that your function is called when the model changes. Here is a working fiddle that uses $timeout. For your example it would be:

.controller('myC', function ($scope, $timeout) {
$scope.$watch("ta", function (newValue, oldValue) {
    $timeout(function () {
       test();
    });
});

ngRepeat will only evaluate a directive when the row content is new, so if you remove items from your list, onFinishRender will not fire. For example, try entering filter values in these fiddles emit.

Dryclean answered 31/1, 2014 at 1:44 Comment(2)
Similarly, test() is not always called in the evalAsynch solution when the model changes fiddleDryclean
what is ta in $scope.$watch("ta",... ?Russel
C
4

If you’re not averse to using double-dollar scope props and you’re writing a directive whose only content is a repeat, there is a pretty simple solution (assuming you only care about the initial render). In the link function:

const dereg = scope.$watch('$$childTail.$last', last => {
    if (last) {
        dereg();
        // do yr stuff -- you may still need a $timeout here
    }
});

This is useful for cases where you have a directive that needs to do DOM manip based on the widths or heights of the members of a rendered list (which I think is the most likely reason one would ask this question), but it’s not as generic as the other solutions that have been proposed.

Courageous answered 26/9, 2016 at 3:53 Comment(0)
N
3

I'm very surprised not to see the most simple solution among the answers to this question. What you want to do is add an ngInit directive on your repeated element (the element with the ngRepeat directive) checking for $last (a special variable set in scope by ngRepeat which indicates that the repeated element is the last in the list). If $last is true, we're rendering the last element and we can call the function we want.

ng-init="$last && test()"

The complete code for your HTML markup would be:

<div ng-app="testApp" ng-controller="myC">
    <p ng-repeat="t in ta" ng-init="$last && test()">{{t}}</p>
</div>

You don't need any extra JS code in your app besides the scope function you want to call (in this case, test) since ngInit is provided by Angular.js. Just make sure to have your test function in the scope so that it can be accessed from the template:

$scope.test = function test() {
    console.log("test executed");
}
Nobility answered 30/11, 2018 at 16:4 Comment(0)
C
1

A solution for this problem with a filtered ngRepeat could have been with Mutation events, but they are deprecated (without immediate replacement).

Then I thought of another easy one:

app.directive('filtered',function($timeout) {
    return {
        restrict: 'A',link: function (scope,element,attr) {
            var elm = element[0]
                ,nodePrototype = Node.prototype
                ,timeout
                ,slice = Array.prototype.slice
            ;

            elm.insertBefore = alt.bind(null,nodePrototype.insertBefore);
            elm.removeChild = alt.bind(null,nodePrototype.removeChild);

            function alt(fn){
                fn.apply(elm,slice.call(arguments,1));
                timeout&&$timeout.cancel(timeout);
                timeout = $timeout(altDone);
            }

            function altDone(){
                timeout = null;
                console.log('Filtered! ...fire an event or something');
            }
        }
    };
});

This hooks into the Node.prototype methods of the parent element with a one-tick $timeout to watch for successive modifications.

It works mostly correct but I did get some cases where the altDone would be called twice.

Again... add this directive to the parent of the ngRepeat.

Corruptible answered 22/4, 2015 at 8:57 Comment(2)
This works the best so far, but it's not perfect. For example I go from 70 items to 68, but it doesn't fire.Moira
What could work is adding appendChild as well (since I only used insertBefore and removeChild) in the above code.Corruptible
W
1

Very easy, this is how I did it.

.directive('blockOnRender', function ($blockUI) {
    return {
        restrict: 'A',
        link: function (scope, element, attrs) {
            
            if (scope.$first) {
                $blockUI.blockElement($(element).parent());
            }
            if (scope.$last) {
                $blockUI.unblockElement($(element).parent());
            }
        }
    };
})
Wilton answered 28/1, 2016 at 16:26 Comment(1)
This code works of course if you have your own $blockUI service.Wilton
I
0

Please have a look at the fiddle, http://jsfiddle.net/yNXS2/. Since the directive you created didn't created a new scope i continued in the way.

$scope.test = function(){... made that happen.

Interdependent answered 4/3, 2013 at 18:10 Comment(2)
Wrong fiddle? Same as the one in the question.Featly
humm, did you update the fiddle? Its currently showing a copy of my original code.Iden

© 2022 - 2024 — McMap. All rights reserved.