What is Knockout?
Knockout is a JavaScript library that helps you apply the MVVM pattern when designing HTML/JavaScript UIs. It aids in making your view model and UI controls observable so that changes can be propagated back and forth between them. Leveraging this pattern should help to keep your UI more declarative, decoupled and data-driven. For more info on Knockout, and its uses, please see their website.
What is a custom binding?
Knockout includes support for binding data and events between many of the built in html elements and your view model. But what about a UI element that is not built into browsers, such as our IgniteUI igDataChart? In order for users to be able to extend the behavior of knockout to accommodate this sort of scenario, Knockout has the concept of custom bindings.
Creating a custom binding for igDataChart
Our IgniteUI igDataChart is a high performance chart control that is designed to quickly and flexibly display your data visually. The chart already has a concept of being notified when the data it is bound to is changed, and thus updating its visuals. In fact, almost all the series types in the chart support a property called transitionDuration, so that when you set this to a non-zero value, and notify the chart of a data change, it will actually animate the changes to that series over the duration you specified.
Since the chart already reacts to data changes, if notified, most of our work, in terms of hooking it up to Knockout, will involve having Knockout notify the chart about changes that are made to the array that we have used Knockout to bind against.
First, we will add a new handler for binding data against an igDataChart.
ko.bindingHandlers.igDataChartDataSource = { update: function(element, valueAccessor, allBindingsAccessor) { var value = valueAccessor(); if ($.isArray(value)) { for (var i = 0; i < value.length; i++) { updateBindingHelper(element, value[i].target, value[i].targetType, value[i].data); } } else { updateBindingHelper(element, "root", null, value); } } };
The update method is going to get called the first time and every time that knockout is passing data to our handler. The way we would use this extension from the html markup is like this:
<div id="chart" data-bind="igDataChartDataSource: data"></div>
Or like this:
<div id="chart" data-bind="igDataChartDataSource: [ { data: data, target: 'series1', targetType: 'columnSeries' }, { data: data, target: 'xAxis', targetType: 'categoryX' } ]"></div>
The former method binds the data against the chart as a whole, while the latter method binds the data against specific targets within the chart. This is in case you want to bind data from different portions of your view model to different series or axes within the chart.
As you may have guessed, most of the magic of how we will put our extension together will be contained in the updateBindingHelper method, but before we get to that, we still need to actually create and configure the igDataChart we will be binding data against:
$("#chart").igDataChart({ axes: [{ name: "xAxis", type: "categoryX", label: "label", strip: "rgba(20,20,20,.1)" }, { name: "yAxis", type: "numericY", minimumValue: 0, maximumValue: 8 }], series: [{ name: "series1", type: "column", thickness: 1, markerType: "none", xAxis: "xAxis", yAxis: "yAxis", valueMemberPath: "value", transitionDuration: 500 },{ name: "series2", type: "column", thickness: 1, markerType: "none", xAxis: "xAxis", yAxis: "yAxis", valueMemberPath: "value2", transitionDuration: 500 }] });
I won’t go into that with too much detail as I have discussed chart setup at length in previous blog posts, but here are the interested aspects:
- There are no dataSource bindings being specified. This is because knockout will provide the data to the chart, and notify the chart when the data has been changed.
- We have set a transitionDuration of 500 milliseconds for both series. This means that changes to the data bound to those series will be animated over that duration.
- Notice we are targeting the same div with the chart init that we have annotated with the Knockout data binding expression.
Now, that we have the chart configured, and the Knockout custom binding registered (pretend we have implemented ‘updateBindingHelper’) all we need now is to define a view model to bind against:
function DataItem(label, value, value2) { this.label = ko.observable(label); this.value = ko.observable(value); this.value2 = ko.observable(value2); this.editValue = ko.computed({ read: function() { return this.value(); }, write: function(newValue) { this.value(parseInt(newValue)); }, owner: this }); this.editValue2 = ko.computed({ read: function() { return this.value2(); }, write: function(newValue) { this.value2(parseInt(newValue)); }, owner: this }); return this; } var data = [ new DataItem("A", 1, 3), new DataItem("B", 2, 4), new DataItem("C", 3, 2), new DataItem("D", 4, 2), new DataItem("E", 5, 3), ]; var data2 = [ new DataItem("A", 5, 3), new DataItem("B", 4, 2), new DataItem("C", 3, 2), new DataItem("D", 2, 4), new DataItem("E", 1, 3), ]; var vm = { data: ko.observableArray(data) };
So, here we have created a data item which has a label and two value properties defined as observables so that we can listen for them changing. The two computed editValue properties are for binding to text boxes so that and string values entered will be coerced to integer values before sending back to the view model. And then we are creating two data sets of these observable objects. Then we create our viewmodel with one proeprty called 'data' which is an observable array of our data items. Because this array is observable we should be able to listen to changes to it, and propagate those changes to the chart.
Finally we will cause knockout to apply any bindings to our view, and we will add some button event handlers so that when we click some buttons we can test various data manipulations and see that the chart updates:
ko.applyBindings(vm); $("#addItem").click(function () { var val1 = Math.round(Math.random() * 8.0), val2 = Math.round(Math.random() * 8.0); vm.data.push(new DataItem("F", val1, val2)); }); $("#removeItem").click(function () { vm.data.shift(); }); $("#moveItem").click(function () { var old = vm.data.shift(); vm.data.push(old); }); $("#changeData").click(function () { if (vm.data() == data) { vm.data(data2); } else { vm.data(data); } });
Then we can define the markup for these buttons and some markup to visualize and edit the data items in our collection:
- label: value: value2:
<input id="addItem" type="button" value="addItem" /><input id="removeItem" type="button" value="removeItem" /><input id="moveItem" type="button" value="moveItem" /><input id="changeData" type="button" value="changeData" />
Notice how we are using a Knockout foreach binding to iterate over the data collection from the view model to create editable visualizations of the contents:
You can see/modify the result and the full code here.
Now all the remains to be discussed is how do we actually implement the knockout binding handler?
Listening for data changes in Knockout
Our goal will be to call a set of notify methods on the chart that will notify it (after the fact) that one of the data sources that it is bound to has been altered. The methods we will be calling are notifyAddItem, notifyRemoveItem and notifyClearItems. These notify the chart, respectively, that an item has been added to a certain collection, or removed, or the entire collection contents have been altered. When calling these methods we pass in the actual collection that was altered (internally the chart figures out which parts were bound to this collection and passes the notifications along), the altered index, if appropriate, and the item affected. This is the information that we need to extract from knockout and pass to the chart.
Ideally that should have been no more than about 5 to 10 lines of code, but Knockout, being a rather young library, could use a bit of polishing in this area. Let’s break down the implementation of ‘updateBindingHelper’ bit by bit. Our general goal will be to:
- Listen for changes to the observable array being passed to the binding. We need to be notified of changes to the array in order to know when to tell the chart to updated.
- Listen for changes to every property of every item in the array, and when new items are added, similarly listed to changes on them.
- When the array or any item changes, notify the chart about the data change.
So, here’s the first section:
updateBindingHelper = function(element, targetName, type, data) { var value = data, valueUnwrapped = ko.utils.unwrapObservable(data), currValue, currProxy, makeProxy, proxies; proxies = $(element).data("koDataChartProxies-" + targetName); if (!proxies) { proxies = {}; proxies.itemProxies = []; proxies.arrayProxy = []; proxies.cachedArray = []; $(element).data("koDataChartProxies-" + targetName, proxies); } if (proxies.dataSourceInstance == value) { return; }
- First, we unwrap the observable array, so that we can access the source array directly.
- Next we ensure that we have an object stored in the data field for the target element, to store information we will use in order to listen for changes on the source collection.
- Next we check to see if we are being provided a new array. We don’t want to resubscribe to the array unless the instance has changed.
proxies.dataSourceInstance = value; if (proxies.arrayHandler) { proxies.arrayHandler.dispose(); proxies.beforeHandler.dispose(); } makeProxy = function(item) { var proxy = {}, key, newInstance; proxy.instance = {}; proxy.instance.owner = proxy; proxy.item = ko.computed(function() { newInstance = ko.toJS(item); for (key in newInstance) { if (newInstance.hasOwnProperty(key)) { proxy.instance[key] = newInstance[key]; } } return proxy.instance; }); proxy.handler = null; proxy.dispose = function() { proxy.handler.dispose(); }; proxy.handler = proxy.item.subscribe(function () { $(element).igDataChart("notifySetItem", proxies.arrayProxy, proxies.arrayProxy.indexOf(proxy.instance), proxy.instance, proxy.instance); }); return proxy; };
Here we:
- We are going to resubscribe to the array so:
- We store the new array instance in the element data so that we can refer to it later.
- We dispose of any existing listeners that we attached for the previous array.
- Next we define a function that should help us listen to all the property changes on a data item.
- The way that this is done is very interesting:
- We create a computed property that
- Converts the observable object into a flat JavaScript object
- Copies the resulting properties onto an object associated with the listening proxy.
- Due to the way that some of the chart series work, its better for us to not have a new instance of a data item every time one of its properties changes, because some of the series correlate parts of the animation via instance of the source items.
- Because this computed property touches all of the observable properties on the source item, Knockout will remember that this computed property needs to be recomputed (and will fire a changed notification) whenever any of the source properties change.
- In this way, if we listen to this computed property, we in effect listen to all properties on the source object, and we get a flattened version of the object to pass to the chart as part of the deal.
- The way that this is done is very interesting:
- We then listen to this computed property for changes, and when they occur we call “notifySetItem” on the chart and provide source array and the index of the modified item.
- It would be a performance improvement here to store the index somewhere rather than to call indexOf here, but we’d have to make sure that the index stayed in sync.
if (ko.isObservable(value)) { proxies.arrayHandler = value.subscribe(function (newArray) { var comp = ko.utils.compareArrays(proxies.cachedArray, newArray), moved = [], proxy, instance; for (var i = comp.length - 1; i >= 0; i--) { switch (comp[i].status) { case "deleted": var oldValue = proxies.arrayProxy.splice(comp[i].index, 1)[0]; proxies.cachedArray.splice(comp[i].index, 1)[0]; $(element).igDataChart("notifyRemoveItem", proxies.arrayProxy, comp[i].index, oldValue) if (typeof comp[i].moved != "undefined") { moved[comp[i].index] = oldValue; } else { oldValue.owner.dispose(); } break; } } for (var i = 0; i < comp.length; i++) { switch (comp[i].status) { case "added": if (typeof comp[i].moved != "undefined") { instance = moved[comp[i].moved]; } else { proxy = makeProxy(comp[i].value); instance = proxy.instance; } proxies.arrayProxy.splice(comp[i].index, 0, instance); proxies.cachedArray.splice(comp[i].index, 0, comp[i].value); $(element).igDataChart("notifyInsertItem", proxies.arrayProxy, comp[i].index, instance); break; } } }); }
Here we:
- Check if the provided array is observable
- Subscribe to be notified of changes to the array.
- On a change we:
- Compare the before state of the array to the after state of the array.
- This is based on a utility function that is part of Knockout
- This code actually wont work with earlier versions of Knockout as they did not even include the indexes of the items that were modified!
- To get this logic to work with earlier versions you’d have to discover the indexes of the altered items another way.
- We maintain a proxy array of flattened versions of the array items.
- We maintain a cached array of the unflattened data items in order to compare with the next altered array.
- Based on additions and deletions we update the proxy and cached array, and we notify the chart of the indexes that items were added or removed from.
Next:
if (proxies.arrayProxy.length > 0) { for (var i = 0; i < proxies.arrayProxy.length; i++) { proxies.arrayProxy[i].owner.dispose(); } } proxies.arrayProxy.length = 0; proxies.cachedArray.length = 0; for (var i = 0; i < valueUnwrapped.length; i++) { currValue = valueUnwrapped[i]; currProxy = makeProxy(currValue); proxies.arrayProxy[i] = currProxy.instance; proxies.cachedArray[i] = currValue; } if (!targetName || targetName == 'root' || !type) { $(element).igDataChart("option", "dataSource", proxies.arrayProxy); } else { if (/X|Y|Radius|Angle$/.test(type)) { $(element).igDataChart("option", "axes", [{ name: targetName, type: type, dataSource: proxies.arrayProxy }]); } else { $(element).igDataChart("option", "series", [{ name: targetName, type: type, dataSource: proxies.arrayProxy }]); } }
Here we:
- Unregister our old listeners
- Destroy our proxies and caches
- Create new proxies for all the new array items
- Set the array of flattened items on the appropriate target, which is either the chart root, or the axis or series that has been targeted.
And with that, we have a custom knockout binding! We can make changes to the data in our view model and the chart will automagically animate to the new data values.
Visit this jsFiddle to play with the live sample. You can also fork it and run your own experiments: