Home > Uncategorized > Virtualized scrolling in knockout.js

Virtualized scrolling in knockout.js

If you have an observableArray of things and you want to display it, of course the first thing to go for is the foreach binding. But what if the array is sometimes pretty long? It can take a long time for knockout to build the elements for each item. Once it’s in the thousands, it can freeze the UI for a while.

The classic-and-classy solution is to only ever create the handful of elements that are currently visible within the scrollable area. For example, here’s a plain old foreach binding to an array called all:

<div class="dataDisplay" data-bind="foreach: all">
    <div class="dataField" data-bind="text: name, drag: pick"></div>
    <div class="dataValue"><pre data-bind="text: value"></pre></div>
</div>

The first change I had to make was to put the list into a nested div. Why? Because I want to keep restricting the size of the dataDisplay using absolute positioning, but I’m also going to need to programmatically set the height of the scrollable content (which, as you’d expect, is going to be much bigger than the fixed height of the outer div):

<div class="dataDisplay">
    <div class="dataDisplayContent" data-bind="virtualScroll: { rows: all, rowHeight: 26 }">
        <div class="dataField" data-bind="text: name, drag: pick"></div>
        <div class="dataValue"><pre data-bind="text: value"></pre></div>
    </div>
</div>

You may have noticed another change – the binding is now something called virtualScroll, and it takes an object with two properties: rows is just the same observableArray as before, and rowHeight is the pixel height of each row. This is the fiddly part of virtual scrolling: by far the easiest technique is to hold the height of the rows constant, as we’ll see.

So, how does the virtualScroll binding work? Like many effective bindings, it’s like a swan; on the surface the motion is graceful, but below the water things don’t look so tidy. In fact it’s a mess so I’ll tackle it one piece at a time. It starts in the usual way:

ko.bindingHandlers.virtualScroll = {
    init: function(element, valueAccessor, allBindingsAccessor, 
                   viewModel, context) {

The very first thing we have to do steal the contents of the target element. This will serve as our template for each item in the list, so we clone our own copy so we can use it later:

        var clone = $(element).clone();
        $(element).empty();

Then we grab our settings. I name the whole object config, and I immediately unwrap the rowHeight so I can conveniently use it in several places:

        var config = ko.utils.unwrapObservable(valueAccessor());
        var rowHeight = ko.utils.unwrapObservable(config.rowHeight);

Now, we get the length of the rows array, multiply by the fixed rowHeight, and so set the height of our target element, which typically makes it bigger than the container div, which should then get scrollbars (because it should have overflow: auto set in CSS).

But thanks to the magic of ko.computed, knockout notices that we access the rows() observable and so arranges for the function to run again when the observable’s value changes. And so when the length of the array changes, so does the height of our scrollable content. (This would be a little more effort if the rows could vary in height).

        ko.computed(function() {
            $(element).css({
                height: config.rows().length * rowHeight
            });
        });

Now, ko.computed is wonderful, but it can only work with existing observables. Sadly, the browser DOM is full of useful information that doesn’t tell us when it changes. As a hack-around, I use a little helper function called simulatedObservable, which you can read about here. For now, just accept that offset and windowHeight are observables that track the y-offset of the target element, and the height of the whole window.

        var offset = simulatedObservable(element, function() {
            return $(element).offset().top;
        });

        var windowHeight = simulatedObservable(element, function() {
            return window.innerHeight;
        });

We have all the external info we need. Next we have to maintain our own state, which is a record of all the rows that we have “materialised” into real DOM elements. The others simply don’t exist.

        var created = {};

And now we begin a rather large nested function called refresh, responsible for materialising any rows that are currently visible.

        var refresh = function() {
            var o = offset();
            var data = config.rows();
            var top = Math.max(0, Math.floor(-o / rowHeight) - 10);
            var bottom = Math.min(data.length, Math.ceil((-o + windowHeight()) / rowHeight));

The top and bottom variables are the indexes of the first and one-after-last rows that we consider visible (actually we’re being quite generous because we use the height of the whole window as the bottom bound). This would be a lot more troublesome if the rows could vary in height.

So we can loop through just these rows, clone our “template” and ask knockout to bind to it, using the row’s item from the array as the data model.

            for (var row = top; row < bottom; row++) {
                if (!created[row]) {
                    var rowDiv = $('<div></div>');
                    rowDiv.css({
                        position: 'absolute',
                        height: config.rowHeight,
                        left: 0,
                        right: 0,
                        top: row * config.rowHeight
                    });
                    rowDiv.append(clone.clone().children());
                    ko.applyBindingsToDescendants(
                        context.createChildContext(data[row]), rowDiv[0]);
                    created[row] = rowDiv;
                    $(element).append(rowDiv);
                }
            }

Finally, we can destroy any previously materialised row that is not in the currently visible range:

            Object.keys(created).forEach(function(rowNum) {
                if (rowNum < top || rowNum >= bottom) {
                    created[rowNum].remove();
                    delete created[rowNum];
                }
            });
        };

And that concludes the refresh function. There are two places we call it. First, when the rows() observable changes, we clear out all materialised rows, and then call refresh.

        config.rows.subscribe(function() {
            Object.keys(created).forEach(function(rowNum) {
                created[rowNum].remove();
                delete created[rowNum];
            });
            refresh();
        });

The second place is a little simpler: we just give it to ko.computed so it can do its usual magic and ensure that refresh runs whenever there is a change in any of the values it depends on.

        ko.computed(refresh);

Finally, we tell Knockout not to automatically perform binding on the child elements, as we take care of that ourselves:

        return { controlsDescendantBindings: true };
    }
};

You can see this in action in my Nimbah project (source code here), where I use it to display the input or output data for the selected node, allowing it to scale to thousands of rows without slowing down the UI.

Advertisements
  1. No comments yet.
  1. No trackbacks yet.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: