Статьи

Создание пользовательской привязки для нокаута для jQuery Mobile LisView

Вступление

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

foodapp

С привязкой 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.

Это может быть применено двумя способами:

  1. <ul data-role = ”listview” data-divider-theme = ”b” data-bind = ”jqmListView: food”>
  2. <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);
	    }
	})();