Вступление
В ИТ-индустрии происходит сдвиг в сторону увеличения клиентской веб-разработки. Это позволяет разработчикам создавать более быстрые и удобные графические интерфейсы для своих конечных пользователей. Этот сдвиг стал возможен с появлением HTML5 и более мощных JavaScript-фреймворков. Такие функции, как доступ к камере, локальное хранилище, перетаскивание и т.д., ранее доступные только в собственных приложениях, теперь также возможны в веб-приложениях.
В прошлом я уже писал о jQuery Mobile. Это превосходный JavaScript-фреймворк для разработки мобильных веб-приложений или гибридных приложений. Другой полезной основой для разработки современных веб-приложений является Knockout. Эта структура делает возможным дизайн шаблона MVVM в веб-приложениях. MVVM расшифровывается как Model-View-ViewModel. Это программный паттерн Microsoft, обычно используемый в приложениях WPF и Silverlight.
В этой статье я объясню, как создать собственную привязку Knockout для jQuery Mobile ListView.
контекст
Зачем нам нужна пользовательская привязка для jQuery Mobile ListView? С тем, что предоставляется из коробки, уже можно привязать к ListView. К сожалению, мы тогда довольно ограничены в функциональности. Представьте себе следующий пример: у меня есть массив продуктов JavaScript. Пищу можно разделить на несколько категорий (фрукты, овощи и закуски). Я хочу показать еду в jQuery Mobile ListView и показать разделитель по категориям.
С привязкой foreach, которая предоставляется из коробки в Knockout, мне пришлось бы разделить свою еду на 3 массива. В противном случае было бы невозможно добавить разделитель для каждой категории.
function FoodViewModel(name, category, image) {
var self = this;
self.name = name;
self.category = category;
self.image = image;
};
function MainViewModel() {
var self = this;
self.fruit = ko.observableArray([
new FoodViewModel("Apple", "Fruit", "/Images/apple.jpg"),
new FoodViewModel("Banana", "Fruit", "/Images/banana.jpg"),
new FoodViewModel("Pear", "Fruit", "/Images/pear.jpg")
]);
self.vegetables = ko.observableArray([
new FoodViewModel("Carrot", "Vegetables", "/Images/carrot.jpg"),
new FoodViewModel("Tomato", "Vegetables", "/Images/tomato.jpg"),
]);
self.snacks = ko.observableArray([
new FoodViewModel("Cookie", "Snacks", "/Images/cookie.jpg")
]);
};
<ul data-role="listview" data-divider-theme="b">
<li data-role="list-divider">Vegetables</li>
<!-- ko foreach: vegetables -->
<li>
<a href="#">
<img data-bind="attr: { src: image }" />
<h3 data-bind="text: name"></h3>
</a>
</li>
<!-- /ko -->
<li data-role="list-divider">Fruit</li>
<!-- ko foreach: fruit -->
<li>
<a href="#">
<img data-bind="attr: { src: image }" />
<h3 data-bind="text: name"></h3>
</a>
</li>
<!-- /ko -->
<li data-role="list-divider">Snacks</li>
<!-- ko foreach: snacks -->
<li>
<a href="#">
<img data-bind="attr: { src: image }" />
<h3 data-bind="text: name"></h3>
</a>
</li>
<!-- /ko -->
</ul>
Я нашел это довольно ограничивающим, потому что я предпочел иметь один список, содержащий всю мою еду.
Пользовательская привязка jqmListView
Чтобы решить эту проблему, я решил разработать пользовательскую привязку для jQuery Mobile ListView. Я основывался на источнике привязки foreach и настраивал его так, чтобы он поддерживал функции ListView.
Это может быть применено двумя способами:
- <ul data-role = ”listview” data-divider-theme = ”b” data-bind = ”jqmListView: food”>
- <ul data-role = ”listview” data-divider-theme = ”b” data-bind = ”jqmListView: {data: food, divider: generateDivider, dividerCompareFunction: sortFood, itemCompareFunction: sortItems}»>
Свойства «divider», «dividerCompareFunction» и «itemCompareFunction» являются необязательными.
делитель
Свойство «делитель» позволяет настроить генерацию делителей для ListView. Ему может быть назначена функция, которая будет вызываться для каждого элемента в списке, привязанного к ListView. Функция должна возвращать имя категории для каждого элемента.
Пример:
self.generateDivider = function (data) {
return data.category;
};
dividerCompareFunction
Свойство «dividerCompareFunction» позволяет настроить сортировку разделителей. По умолчанию они отсортированы по алфавиту.
Пример:
self.sortFood = function (divider1, divider2) {
var weights = new Object;
weights["Vegetables"] = 1;
weights["Fruit"] = 2;
weights["Snacks"] = 3;
return weights[divider1] - weights[divider2];
};
itemCompareFunction
The property “itemCompareFunction” allows to customize the sorting of the items of a given category. By default they are not sorted in order of appearance in the JavaScript list.
An example:
self.sortItems = function (item1, item2) {
return item1.name.localeCompare(item2.name);
}
Sample Code
If we use the jqmListView binding, we can rewrite our sample application:
function FoodViewModel(name, category, image) {
var self = this;
self.name = name;
self.category = category;
self.image = image;
};
function MainViewModel() {
var self = this;
self.food = ko.observableArray([
new FoodViewModel("Carrot", "Vegetables", "/Images/carrot.jpg"),
new FoodViewModel("Apple", "Fruit", "/Images/apple.jpg"),
new FoodViewModel("Pear", "Fruit", "/Images/pear.jpg"),
new FoodViewModel("Tomato", "Vegetables", "/Images/tomato.jpg"),
new FoodViewModel("Banana", "Fruit", "/Images/banana.jpg"),
new FoodViewModel("Cookie", "Snacks", "/Images/cookie.jpg")
]);
self.generateDivider = function (data) {
return data.category;
};
self.sortFood = function (divider1, divider2) {
var weights = new Object;
weights["Vegetables"] = 1;
weights["Fruit"] = 2;
weights["Snacks"] = 3;
return weights[divider1] - weights[divider2];
};
self.sortItems = function (item1, item2) {
return item1.name.localeCompare(item2.name);
}
};
<ul data-role="listview" data-divider-theme="b" data-bind="jqmListView: { data: food, divider: generateDivider, dividerCompareFunction: sortFood, itemCompareFunction: sortItems }">
<li>
<a href="#">
<img data-bind="attr: { src: image }" />
<h3 data-bind="text: name"></h3>
</a>
</li>
</ul>
The food sample application is a very simple application were the differences between the foreach and the jqmListView bindings are minor. But the jqmListView is a lot more flexible then the foreach binding in more complex cases. For example: if the data is coming from the server using a REST call and we don’t know beforehand all the available categories that will send to the browser.
You can download the sample from: http://sdrv.ms/Ol0LRY
jqmListView Code
Below you can find the source code of my jqmListView binding. I will also upload this to GitHub.
(function () {
ko.bindingHandlers.jqmListView = {
init: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
// Support anonymous templates
var bindingValue = ko.utils.unwrapObservable(convertToBindingValue(valueAccessor));
if ((element.nodeType == 1 || element.nodeType == 8)) {
// It's an anonymous template - store the element contents, then clear the element
var templateNodes = element.nodeType == 1 ? element.childNodes : ko.virtualElements.childNodes(element),
container = ko.utils.moveCleanedNodesToContainerElement(templateNodes); // This also removes the nodes from their current parent
new ko.templateSources.anonymousTemplate(element)['nodes'](container);
}
return { 'controlsDescendantBindings': true };
},
update: function (element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
var bindingValue = ko.utils.unwrapObservable(convertToBindingValue(valueAccessor));
// Clean the current children
$(element).empty();
var dataArray = (bindingValue['data']) || [];
var dividerFor = bindingValue['divider'];
var dividerDictionary = new Object();
for (var i = 0; i < dataArray.length; i++) {
var dividierName = dividerFor(dataArray[i]);
dividerDictionary[dividierName] = (dividerDictionary[dividierName]) || [];
dividerDictionary[dividierName].push(dataArray[i]);
}
$.each(sortKeys(dividerDictionary, bindingValue.dividerCompareFunction), function (index, key) {
if (key !== "") {
$(element).append('<li data-role="list-divider">' + key + '</li>');
}
var tempElement = document.createElement("div"); // Create temp DOM element to render templating
ko.renderTemplateForEach(element, dividerDictionary[key].sort(bindingValue.itemCompareFunction), /* options: */bindingValue, tempElement, bindingContext);
$(element).append($(tempElement).children()); // Add data to listview
});
$(element).listview('refresh');
}
};
function convertToBindingValue(valueAccessor) {
/// <summary>Standardizes the properties of the of the valueAccessor object.</summary>
/// <returns>An object containing standardized binding properties.</returns>
var bindingValue = ko.utils.unwrapObservable(valueAccessor());
// If bindingValue is the array, just pass it on its own
if ((!bindingValue) || typeof bindingValue.length == 'number')
return {
'data': bindingValue,
'divider': function () { return ""; },
'templateEngine': ko.nativeTemplateEngine.instance
};
// If bindingValue.data is the array, preserve all relevant options
return {
'data': ko.utils.unwrapObservable(bindingValue['data']),
'divider': bindingValue['divider'] || function () { return ''; },
'dividerCompareFunction': bindingValue['dividerCompareFunction'],
'itemCompareFunction': bindingValue['itemCompareFunction'],
'includeDestroyed': bindingValue['includeDestroyed'],
'afterAdd': bindingValue['afterAdd'],
'beforeRemove': bindingValue['beforeRemove'],
'afterRender': bindingValue['afterRender'],
'templateEngine': ko.nativeTemplateEngine.instance
};
};
function sortKeys(data, compareFunction) {
/// <summary>Convert the properties of a given object to an array and return them in sorted order.</summary>
/// <param name="data">The object who's properties must be sorted.</param>
/// <param name="compareFunction">The compare function that must be used during sorting.</param>
/// <returns type="Array">The sorted properties of the object.</returns>
var keys = Array();
for (var key in data) {
keys.push(key);
}
return keys.sort(compareFunction);
}
})();
