19

I have a simple observableArray which contains a lot of user-models. In the markup, there is a template with a foreach loop which loops the users and outputs them in a simple table. I additionally style the table with a custom scrollbar and some other javascript. So now I have to know when the foreach loop is finished and all the models are added to the DOM.

The problem with the afterRender callback is that it gets called every time something is added, but I need kind of a callback which fires only once.

Sidney Widmer
  • 389
  • 1
  • 2
  • 10
  • Why do you need to know when they are rendered into the DOM? If you are applying styles, CSS should just match the rule and apply as the items are added. – arb Apr 19 '12 at 15:38

8 Answers8

24

Your best bet is to use a custom binding. You can either place your custom binding after foreach in the list of bindings in your data-bind or you could execute your code in a setTimeout to allow foreach to generate the content before your code is executed.

Here is a sample that shows running code a single time and running code each time that your observableArray updates: http://jsfiddle.net/rniemeyer/Ampng/

HTML:

<table data-bind="foreach: items, updateTableOnce: true">
    <tr>
        <td data-bind="text: id"></td>
        <td data-bind="text: name"></td>
    </tr>
</table>

<hr/>

<table data-bind="foreach: items, updateTableEachTimeItChanges: true">
    <tr>
        <td data-bind="text: id"></td>
        <td data-bind="text: name"></td>
    </tr>
</table>

<button data-bind="click: addItem">Add Item</button>

JS:

var getRandomColor = function() {
   return 'rgb(' + (Math.floor(Math.random() * 256)) + ',' + (Math.floor(Math.random() * 256)) + ',' + (Math.floor(Math.random() * 256)) + ')';  
};

ko.bindingHandlers.updateTableOnce = {
    init: function(element) {
        $(element).css("color", getRandomColor());            
    }    
};

//this binding currently takes advantage of the fact that all bindings in a data-bind will be triggered together, so it can use the "foreach" dependencies
ko.bindingHandlers.updateTableEachTimeItChanges = {
    update: function(element) {    
        $(element).css("color", getRandomColor());  
    }    
};


var viewModel = {
    items: ko.observableArray([
        { id: 1, name: "one" },
        { id: 1, name: "one" },
        { id: 1, name: "one" }
    ]),
    addItem: function() {
        this.items.push({ id: 0, name: "new" });   
    }
};

ko.applyBindings(viewModel);
RP Niemeyer
  • 114,592
  • 18
  • 291
  • 211
10

I came up with an elegent cheat. Immediately after your template / foreach block, add this code:

<!--ko foreach: { data: ['1'], afterRender: YourAfterRenderFunction } -->
<!--/ko-->
user2916183
  • 101
  • 1
  • 2
8

A quick and simple way is to, in your afterRender handler, compare the current item with the last item in your list. If it matches, then this is the last time afterRender is run.

Matt Burland
  • 44,552
  • 18
  • 99
  • 171
3

Jaffa's answer has contains an error so I decided to create a new answer instead of commenting. It is not possible to use with and template at the same time. So just move your model to template's data tag

Html

<div data-bind="template: {data: myModel, afterRender: onAfterRenderFunc }" >
   <div data-bind="foreach: observableArrayItems">
       ...
   </div>
</div>

Javascript

var myModel = {      
  observableArrayItems : ko.observableArray(),

  onAfterRenderFunc: function(){
    console.log('onAfterRenderFunc')
  }

}
ko.applyBinding(myModel);
Cheburek
  • 2,103
  • 21
  • 32
1

I am not sure if the accepted answer will work in knockout-3.x (as data-bindings no longer are run in the order you declare them).

Here is another option, it will only fire exactly once.

ko.bindingHandlers.mybinding {
        init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
            var foreach = allBindings().foreach,
                subscription = foreach.subscribe(function (newValue) {
                // do something here 
                subscription.dispose(); // dispose it if you want this to happen exactly once and never again.
            });
        }
}
j-a
  • 1,780
  • 1
  • 21
  • 19
  • Just to point out, if foreach is declared using "as" syntax `foreach: { data: array, as: 'item' }`, then subscribe function is on the prototype of data property. So, one should use `allBindings.get("foreach").data.subscribe`. – dizarter Aug 12 '18 at 01:40
0

Just off the top of my head you could either:

  • Instead of hooking into the afterRender event, simply call your function after you have push/popped an item on the array.
  • Or possibly wrap the observableArray in an observable which in itself has the child items underneath with its own afterRender event. The foreach loop would need to refer to the parent observable like so:

Example:

 <div>
     <div data-bind="with: parentItem(), template: { afterRender: myRenderFunc }" >
      <div data-bind="foreach: observableArrayItems">
          ...
      </div>
    </div>
   </div>

Not tested this so just guessing...

Wouter de Kort
  • 39,090
  • 12
  • 84
  • 103
jaffa
  • 26,770
  • 50
  • 178
  • 289
  • 1
    Getting an error: Message: `Multiple bindings (with and template) are trying to control descendant bindings of the same element. You cannot use these bindings together on the same element.` – Airn5475 Jan 15 '14 at 16:22
0

In order to find out when the foreach template has completed rendering, you can make a "dummy" binding and pass the current rendered item index to your handler and check if its match the array length.

HTML:

<ul data-bind="foreach: list">
   <li>
   \\ <!-- Your foreach template here -->
   <div data-bind="if: $root.listLoaded($index())"></div> 
   </li>
</ul>

ViewModel - Handler:

this.listLoaded = function(index){
    if(index === list().length - 1)
       console.log("this is the last item");
}

Hold extra flag if you intend to add more items to the list.

MichaelS
  • 7,023
  • 10
  • 51
  • 75
0

How about using a debouncer?

var afterRender = _.debounce(function(){    

    // code that should only fire 50ms after all the calls to it have stopped

}, 50, { leading: false, trailing: true})
Marcelo Mason
  • 6,750
  • 2
  • 34
  • 43