Вступление
В ИТ-индустрии происходит сдвиг в сторону увеличения клиентской веб-разработки. Это позволяет разработчикам создавать более быстрые и удобные графические интерфейсы для своих конечных пользователей. Этот сдвиг стал возможен с появлением 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); } })();