Hooking Into The Complete Event Of An ngRepeat Loop In AngularJS
Earlier today, I was talking to Brian Feister on Twitter about invoking a callback when an ngRepeat loop had finished rendering for the first time. This isn't something that the ngRepeat directives exposes naturally; but, I had an idea of how you might be able to accomplish this with a sibling directive, repeatComplete.
Run this demo in my JavaScript Demos project on GitHub.
This directive - repeatComplete - is intended to be included on the same DOM (Document Object Model) element that included the ngRepeat directive. Its attribute value defines the expression that will be evaluated once the ngRepeat has finished rendering for the first time. But, before I show you the code, let me point out two large caveats of this approach / experiment:
One - It doesn't handle an initial rendering of zero elements. Meaning, it will keep watching the DOM until at least one element has been rendered. Of course, you could offset this by limiting the number of $digest cycles that the directive will watch before unbinding its own $watch() handler.
Two - It treats the DOM tree as the source of truth. Normally, you wouldn't want to do this; but, in this case, it makes sense since it's the state of the DOM, not the view-model, that we're interested in watching.
That said, here's the code. There are two instances of the repeatComplete directive - one on a list that starts out populated and one on a list that starts out empty. The second instance is there to demonstrate that this directive will continue to watch the DOM until at least one item is rendered.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Hooking Into The ngRepeat Completion Event In AngularJS
</title>
<style type="text/css">
a[ ng-click ] {
cursor: pointer ;
text-decoration: underline ;
}
</style>
</head>
<body ng-controller="AppController">
<h1>
Hooking Into The ngRepeat Completion Event In AngularJS
</h1>
<h3>
Friends
</h3>
<ul>
<!--
When the ngRepeat finishes its first round of rendering,
we're going to invoke the doSomething() callback.
-->
<li
ng-repeat="friend in friends"
repeat-complete="doSomething( $index )">
{{ friend.name }}
</li>
</ul>
<p>
<a ng-click="addFriend()">Add Friend</a>
-
<em>This will NOT trigger any more "complete" events</em>.
</p>
<h3>
Enemies
</h3>
<ul>
<!--
When the ngRepeat finishes its first round of rendering,
we're going to invoke the doSomething() callback.
-->
<li
ng-repeat="enemy in enemies"
repeat-complete="doSomething( $index )">
{{ enemy.name }}
</li>
</ul>
<p>
<a ng-click="addEnemy()">Add Enemy</a>
-
<em>This WILL trigger one more "complete" event</em>.
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.0.3.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.2.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $scope ) {
// Define the collection of friends to render.
$scope.friends = [
{
name: "Sarah"
},
{
name: "Joanna"
},
{
name: "Tricia"
}
];
// Define the collection of enemies to render. This
// collection will start out empty in order to demonstrate
// that this approach requires at least ONE element to
// render.
$scope.enemies = [];
// ---
// PUBLIC METHODS.
// ---
// I add a new enemty to the collection.
$scope.addEnemy = function() {
$scope.enemies.push({
name: "Banana"
});
}
// I add a new friend to the collection.
$scope.addFriend = function() {
$scope.friends.push({
name: "Anna"
});
}
// I am the callback handler for the ngRepeat completion.
$scope.doSomething = function( index ) {
console.log( "ngRepeat completed (" + index + ")!" );
};
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I invoke the given expression when associated ngRepeat loop
// has finished its first round of rendering.
app.directive(
"repeatComplete",
function( $rootScope ) {
// Because we can have multiple ng-repeat directives in
// the same container, we need a way to differentiate
// the different sets of elements. We'll add a unique ID
// to each set.
var uuid = 0;
// I compile the DOM node before it is linked by the
// ng-repeat directive.
function compile( tElement, tAttributes ) {
// Get the unique ID that we'll be using for this
// particular instance of the directive.
var id = ++uuid;
// Add the unique ID so we know how to query for
// DOM elements during the digests.
tElement.attr( "repeat-complete-id", id );
// Since this directive doesn't have a linking phase,
// remove it from the DOM node.
tElement.removeAttr( "repeat-complete" );
// Keep track of the expression we're going to
// invoke once the ng-repeat has finished
// rendering.
var completeExpression = tAttributes.repeatComplete;
// Get the element that contains the list. We'll
// use this element as the launch point for our
// DOM search query.
var parent = tElement.parent();
// Get the scope associated with the parent - we
// want to get as close to the ngRepeat so that our
// watcher will automatically unbind as soon as the
// parent scope is destroyed.
var parentScope = ( parent.scope() || $rootScope );
// Since we are outside of the ng-repeat directive,
// we'll have to check the state of the DOM during
// each $digest phase; BUT, we only need to do this
// once, so save a referene to the un-watcher.
var unbindWatcher = parentScope.$watch(
function() {
console.info( "Digest running." );
// Now that we're in a digest, check to see
// if there are any ngRepeat items being
// rendered. Since we want to know when the
// list has completed, we only need the last
// one we can find.
var lastItem = parent.children( "*[ repeat-complete-id = '" + id + "' ]:last" );
// If no items have been rendered yet, stop.
if ( ! lastItem.length ) {
return;
}
// Get the local ng-repeat scope for the item.
var itemScope = lastItem.scope();
// If the item is the "last" item as defined
// by the ng-repeat directive, then we know
// that the ng-repeat directive has finished
// rendering its list (for the first time).
if ( itemScope.$last ) {
// Stop watching for changes - we only
// care about the first complete rendering.
unbindWatcher();
// Invoke the callback.
itemScope.$eval( completeExpression );
}
}
);
}
// Return the directive configuration. It's important
// that this compiles before the ngRepeat directive
// compiles the DOM node.
return({
compile: compile,
priority: 1001,
restrict: "A"
});
}
);
</script>
</body>
</html>
You'll have to look at the GitHub demo console; but, you'll see that the first list invokes the doSomething() callback right away while the second list waits until you add an "enemy" before invoking doSomething().
If nothing else, this was a fun exploration of the $compile() service. It's not one that I have used all that much; but it clearly holds a lot of power.
Want to use code from this post? Check out the license.
Reader Comments
Holy! This is brilliant, great post!
Thanks for this write up; Cant believe I've been using angular for a bit more than a year now and I keep learning crazy stuff like this.
@Olivier,
Ha ha, thanks! I'm thrilled that you got something out of this. AngularJS is awesome, but it has a lot of nooks and crannies. I'm learning new stuff all the time!
Goodness, my Angular knowledge just shot up a notch. Thanks for taking the time to write this up!
if you chenge ng-repeat source your directive not updated because it was unbind unbindWatcher.
Hey Ben, thanks for cool directive :-), but however as Ivan mentioned when u change ng-repeat source, it doesn't work anymore.
I am new to angularJS, but i tried to bend it to make it work, but with my level of skills and knowledge its probably impossible. So if u could show the right direction how to accomplish it .. i will write a song bout u:-))
@Ivan, @Jakub,
Hmm, that's a tough problem. The event directive doesn't actually know which collection is being rendered - it only knows that it's on an ng-repeat that will eventually have an item that is "$last". In order to retrigger the event when the rendered collection changes source, you'd have to tell the directive which collection to watch. Something like:
... repeat-complete="doSomething( $index )" watch="friends" ...
In this way, you'd give the directive a handle on the "friends" collection. And, you could add logic that if the watch target changes source, it would re-wire the watcher on the DOM.
It would definitely increase the complexity of the directive; but, I think it would be possible. If I can carve out some time, I can try to play around with it.
Another way I thought of (this is the linking function of the directive):
link: function(scope) {
if(scope.$last) {
// execute something
}
}
@Ben,
Thanks for reply, ...in the end for my scenario the solution was pretty easy, because i have always same source scope for repeat only its contents changes (i use repeat-complete just to display preloader animation before server returns new data and ng-repeat renders it) so i just had to remove unbindWatcher, well definately not good nor clean solution but for now it works:-))... anyway your blog is great source of information thanks!!:-)
Hey, so I might be missing something obvious but the same behaviour could be implemented by defining a sibling directive that checks for $last on scope and if its true, evaluates the expression. Of course, this means the expression is evaluated multiple times if the collection changes but it doesn't rely on DOM structure and checking for multiple invocation is trivial.
@Shaneeb,
Ahh, it fires before the DOM renders which is what you are trying to achieve here. My bad!
What do you think about using $timeout to detect the end of the ng-repeat render?
As suggested in SO: http://stackoverflow.com/questions/15207788/calling-a-function-when-ng-repeat-has-finished
Looks easier or am I missing something?
inside the repeatComplete directive, before you return the directive configuration, you define the compile() function; the meat of your compile function seems to happen in the unbindWatcher function; you define the lastItem as "var lastItem = parent.children( "*[ repeat-complete-id = '" + id + "' ]:last" );" i see that you included jquery, and this is why the child method supports selectors; i would have to use the angular.element's version of jqLite, which doesn't support child selectors. any idea how I would be able to target elements that have the newly added repeat-complete-id attribute using angular.element's methods?
I was just reading through this thread and remembered that I wrote this kind of directive around June 2014, when the discussion was still continuing here. Indeed, using $timeout is what allows you to trigger your callback after the last ng-repeat has been rendered. To test this assumption try this:
HTML:
<div ng-repeat="i in [1,2,3]" ng-repeat-complete="">Line {{ i }}</div>
JS:
app.directive('ngRepeatComplete', function($timeout) {
return function(scope, el, attr) {
if (scope.$last) {
// this will show the text and bindings: "Line {{ i }}"
console.log('before render:', el.text());
$timeout(function() {
// ... and this will show just the rendered text: "Line 3"
console.log('after render:', el.text());
})
}
}
});
I want to start a loader at the beginning of ng-repeat and hide it at the end,when I use your method I am unable to fire an event in-case if there is an absence of list items in the ng-repeat. Any clue how to solve it.Thank you.