Я предполагаю, что эта статья будет интересна тем, кто знает и умеет HTML&JavaScript, но не пробовал силы в разработке приложений для Win8. Для того, чтобы пройти эту статью и кодить в сласть необходимо иметь на борту VS 2013.
В статье будут рассмотрены ключевые аспекты разработки приложений для платформы Win 8.1. А именно: создание своих источников данных, темплейтов, контролов используя WinJS.
Что будет рассмотрено:
- Жизненный цикл работы приложения;
- Promise;
- Работа с DataSource;
- Создание собственных контролов;
- Работа с темплейтами;
- Tile-ы;
- Share;
Для тех кто не любит читать, как я, например, исходники я выложил на github.com/Sigura/HubraWin,
Для того, чтоб раскрыть все обозначенные темы я создал приложение, которое будет отображать список контактов.
Если вы уже смотрите исходники, то обратите внимание, что я немного изменил default.js, для того чтоб там не было общего кода по запуску приложения и вынес его в app.js. Оставив в default.js только настройки и непосредственно запуск. Так же я дополнил WinJs.Utilities скромным набором «удобств» и шиной сообщений.
Работа с объектами
В пространстве WinJS есть специальный набор способов создать класс, добавить ему методов, расширить и сделать доступным.
например, объявление класса — шины сообщений:
// делаем замыкание, так же для того чтоб описать зависимости
// и иметь возможность подменить их, если когда-нибудь мы захотим использовать
// этот код в другом приложении
(function (winJs) {
'use strict';
// создаём класс
var bus = winJs.Class.define(winJs.Utilities.defaultConstructor(), {
init: function (element, options) {
var me = this;
}
});
// добавляем в него возможность отправлять и принимать сообщения
winJs.Class.mix(bus, winJs.Utilities.eventMixin);
// добавляем шину в общий доступ
winJs.Namespace.define('HabraWin', {
MessageBus: bus
});
})(WinJS);
Приложение WinJS
Фактически это web приложение у которого есть свой
Я сделал «свой» класс для приложения, для того чтоб использовать его в других проектах. По мимо стандартного набора настроек этот класс создаёт события для обработки запуска (activated с информацией о запуске), прекращения работы и прочего (oncheckpoint, before-start, after-start).
var app = winJs.Class.define(winJs.Utilities.defaultConstructor(), {
init: function (options) {
var me = this;
var activatedEventType = 'activated';
var ui = options.ui;
var application = options.app;
var nav = options.nav;
var activation = options.activation;
var sched = options.sched;
application.addEventListener(activatedEventType, function (args) {
me.dispatchEvent(activatedEventType, {
kind: args.detail.kind,
isReactivated: args.detail.previousExecutionState === activation.ApplicationExecutionState.terminated,
parevEventDetails: args.detail
});
if (args.detail.kind !== activation.ActivationKind.launch)
return;
nav.history = application.sessionState.history || {};
nav.history.current.initialPlaceholder = true;
ui.disableAnimations();
var p = ui.processAll().then(function () {
return nav.navigate(nav.location || habraWin.navigator.home, nav.state);
}).then(function () {
return sched.requestDrain(sched.Priority.aboveNormal + 1);
}).then(function () {
ui.enableAnimations();
});
args.setPromise(p);
});
application.oncheckpoint = function (args) {
me.dispatchEvent('oncheckpoint', { prevEvent: args });
application.sessionState.history = nav.history;
};
},
start: function () {
var me = this;
me.dispatchEvent('before-start', me);
me.options.app.start();
me.dispatchEvent('after-start', me);
}
});
winJs.Class.mix(app, winJs.Utilities.eventMixin);
winJs.Namespace.define('Application', {
Instance: app
});
})(WinJS, HabraWin);
Тогда сам запуск приложения (default.js) будет выглядеть так:
; (function (application, winJs, habraWin, windows, window) {
'use strict';
winJs.Binding.optimizeBindingReferences = true;
// создаём наше приложение
var app = new application.Instance({
activation: windows.ApplicationModel.Activation,
app: winJs.Application,
nav: winJs.Navigation,
sched: winJs.Utilities.Scheduler,
ui: winJs.UI
});
// делаем доступной шину сообщений в пространстве WinJS
winJs.bus = new habraWin.MessageBus();
// делаем приложение
window.app = app;
// запускаем
app.start();
})(Application, WinJS, HabraWin, Windows, window);
Навигация по страницам
Я сторонник приложений на одной «странице». WinJS предлагает богатый набор возможностей для реализации современных сценариев взаимодействия с пользователем.
Web aka WinJS приложение нуждается в отдельном объекте для обслуживания истории переходов, по страницам, обслуживания жизненного цикла страницы.
Т.е. каждую страницу при переходе на неё нам необходимо будет рендерить в её элемент, обязательно избавляясь от предыдущей, а именно убирая слушателей событий, открытые ресурсы и т.д.
Как должен выглядеть жизненный цикл страницы:
- Применение ресурсов, байдингов (processed),
- Инициализация (ready), срабатывает тогда, когда готовы все контролы на странице и применены ресурсы, здесь можно:
- Подписаться на события контролов страницы, в том числе AppBar,
- Сделать какую-то работу, например первый поиск,
- Обработка обновления страницы, например, при изменении размеров,
- Выгрузка страницы (unload) для очистки всех обработчиков событий, и прочих ресурсов.
'use strict';
winJs.Namespace.define('HabraWin', {
PageNavigatorControl: winJs.Class.define(
function (element, options) {
var nav = winJs.Navigation;
this._element = element || document.createElement('div');
this._element.appendChild(this._createPageElement());
this.home = options.home;
this._eventHandlerRemover = [];
this.addRemovableEventListener(nav, 'navigating', this._navigating.bind(this), false);
this.addRemovableEventListener(nav, 'navigated', this._navigated.bind(this), false);
window.onresize = this._resized.bind(this);
habraWin.navigator = this;
}, {
addRemovableEventListener: function (e, eventName, handler, capture) {
var that = this;
e.addEventListener(eventName, handler, capture);
that._eventHandlerRemover.push(function () {
e.removeEventListener(eventName, handler);
});
},
home: '',
_element: null,
_lastNavigationPromise: winJs.Promise.as(),
_lastViewstate: 0,
pageControl: {
get: function () { return this.pageElement && this.pageElement.winControl; }
},
pageElement: {
get: function () { return this._element.firstElementChild; }
},
dispose: function () {
if (this._disposed) {
return;
}
this._disposed = true;
winJs.Utilities.disposeSubTree(this._element);
for (var i = 0; i < this._eventHandlerRemover.length; i++) {
this._eventHandlerRemover[i]();
}
this._eventHandlerRemover = null;
},
_createPageElement: function () {
var element = document.createElement('div');
element.setAttribute('dir', window.getComputedStyle(this._element, null).direction);
element.style.position = 'absolute';
element.style.visibility = 'hidden';
element.style.width = '100%';
element.style.height = '100%';
return element;
},
_getAnimationElements: function () {
if (this.pageControl && this.pageControl.getAnimationElements) {
return this.pageControl.getAnimationElements();
}
return this.pageElement;
},
_navigated: function () {
this.pageElement.style.visibility = '';
winJs.UI.Animation.enterPage(this._getAnimationElements()).done();
},
_navigating: function (args) {
var newElement = this._createPageElement();
this._element.appendChild(newElement);
this._lastNavigationPromise.cancel();
var me = this;
this._lastNavigationPromise = winJs.Promise.as().then(function () {
return winJs.UI.Pages.render(args.detail.location, newElement, args.detail.state);
}).then(function parentElement(control) {
var oldElement = me.pageElement;
if (oldElement.winControl) {
if (oldElement.winControl.unload) {
oldElement.winControl.unload();
}
oldElement.winControl.dispose();
}
oldElement.parentNode.removeChild(oldElement);
oldElement.innerText = '';
});
args.detail.setPromise(this._lastNavigationPromise);
},
_resized: function (args) {
if (this.pageControl && this.pageControl.updateLayout) {
this.pageControl.updateLayout.call(this.pageControl, this.pageElement);
}
}
}
)
});
})(WinJS, HabraWin);
'use strict';
winJs.UI.Pages.define('/pages/hub/hub.html', {
processed: function (element) {
return winJs.Resources.processAll(element);
},
className: 'client-search-hub',
ready: function (element, options) {
this.initEnv();
this.initAppBar(element);
this.subscribe(element);
this.setFormValues(options);
this.search();
},
initAppBar: function (element) {
var me = this;
me.appBar = element.querySelector('#appbar').winControl;
this.addRemovableEventListener(me.appBar.getCommandById('clear'), 'click', function () {
winJs.bus.dispatchEvent('clear-command');
}, false);
},
subscribe: function () {
var me = this;
var search = me.element.querySelector('#search');
search && me.addRemovableEventListener(search, 'click', me.search.bind(me), false);
me.addRemovableEventListener(winJs.bus, 'client-selected', function (item) {
me.currentClient = item.detail.data;
//me.editButton.disabled = false;
me.appBar.sticky = true;
me.appBar.show();
});
me.addRemovableEventListener(winJs.bus, 'client-unselected', function (item) {
me.currentClient = null;
//me.editButton.disabled = true;
me.appBar.hide();
me.appBar.sticky = false;
});
},
unload: function () {
this.element.classList.remove(this.className);
if (this._disposed) {
return;
}
this._disposed = true;
winJs.Utilities.disposeSubTree(this._element);
for (var i = 0; i < this._eventHandlerRemover.length; ++i) {
this._eventHandlerRemover[i]();
}
this._eventHandlerRemover = null;
},
addRemovableEventListener: function (e, eventName, handler, capture) {
capture = capture !== false ? false : true;
e.addEventListener(eventName, handler, capture);
this._eventHandlerRemover.push(function () {
e.removeEventListener(eventName, handler);
});
},
updateLayout: function (element) {
/// <param name="element" domElement="true" />
// TODO: Respond to changes in layout.
//debugger;
},
setFormValues: function (clinetInfo) {
this.searchForm = this.element.querySelector('#main-search-form');
this.searchForm && this.searchForm.setAttribute('data-win-options', JSON.stringify(clinetInfo));
this.searchForm && this.searchForm.winControl && this.searchForm.winControl.setValues(clinetInfo);
},
search: function () {
winJs.bus.dispatchEvent('search-command');
},
initEnv: function() {
this.element.classList.add(this.className);
this._eventHandlerRemover = [];
}
});
})(WinJS);
Локализация
Если сделать файл stringsru-RUresources.resjson
{
"pageHeader": "Habra WinJS 8.1"
// …
}
то в коде пользоваться ссылками:
<span class="pagetitle" data-win-res="{ textContent: 'pageHeader' }"></span>
Путь к наиболее подходящему языку будет автоматически подхвачен при запуске.
Любопытно, что можно встроить в ресурсы байдинг.
Так же интересно, что для ресурсов используется специальный тип контента в jsproj
<PRIResource Include="stringsru-RUresources.resjson" />
Т.е. необходимо создать ресурсный файл пользуясь интерфейсом VS, совсем нельзя переименовать существующий файл, напрмиер, txt в resjson, он будет в jsproj:
<Content Include=" stringsru-RUresources.resjson" />
что сделает невозможным использование т.к. он не будет подгружаться автоматически.
Темплейты, байдинг
Пример темплейта:
<div class="client-search-item-template" data-win-control="WinJS.Binding.Template" style="display: none;">
<div class="client-item">
<div class="client-info">
<div class="client-photo"><img data-win-bind="alt: name; src: this HabraWin.Converters.clientPhoto;" /></div>
<div class="client-name" data-win-bind="innerText: name"></div>
</div>
<div class="decoration-bottom-line"></div>
</div>
</div>
Это разметка для отображения пользователя в списке. Специальным атрибутом (data-win-bind) указываются привязка к тому или иному свойству элемента, а также выражение для доступа к данным.
А для того чтоб произвести некоторые преобразования, например, для того, чтоб показать фотографию клиента можно указать конвертер:
src: this HabraWin.Converters.clientPhoto
; (function (winJs) {
'use strict';
var converters = {
clientPhoto: winJs.Binding.converter(function (client) {
if (!client || !client.hasPhoto)
return '/images/empty-photo.png';
return converters.baseAddress + '/clients/photos/' + client.ID;
})
};
winJs.Namespace.define('HabraWin', { Converters: converters });
})(WinJS);
Для того чтоб применить к темплейту данные достаточно:
WinJS.Binding.processAll(element, data);
Контролы
Создание контрола в WinJS очень похоже на создание класса. Например, форма HabraWin.ClientSearchForm:
<form role="form" id="main-search-form" data-win-control="HabraWin.ClientSearchForm">
<div class="form-group"><label for="second-name"><span data-win-res="{textContent: 'serachFormSecondNameLabel'}"></span></label><input data-win-res="{attributes: { 'placeholder' : 'serachFormSecondNamePlaceholder' }}" spellcheck="true" type="text" name="secondName" id="second-name" /></div>
<div class="main-search-form-buttons form-group">
<button type="button" name="search" class="button" id="search"><span data-win-res="{textContent: 'serachFormSearchButton'}"></span></button>
<button type="reset" name="clear" class="button" id="clear"><span data-win-res="{textContent: 'serachFormClearButton'}"></span></button>
</div>
</form>
'use strict';
var searchForm = winJs.Class.derive(HabraWin.BaseForm, winJs.Utilities.defaultControlConstructor(), {
init: function (element, options) {
var me = this;
me.initProperies();
me.clearForm();
me.defineElements(element);
me.defineEvents();
me.subscribe();
me.setValues(options);
me.search();
},
defineElements: function (element) {
var me = this;
me.fields = {
secondName: element.querySelector('input[name=secondName]')
};
me.buttons = {
clear: element.querySelector('button[name=clear]')//,
};
var values = this.getValues();
this.oldValues = JSON.stringify(values);
},
defineEvents: function () {
var me = this;
me.buttons.clear.addEventListener('click', me.clearAndSearch.bind(me));
},
setValues: function (values) {
if (!values) {
return;
}
this.changedFields = [];
for (var lbl in values)
if (values.hasOwnProperty(lbl) && this.fields.hasOwnProperty(lbl)) {
var field = this.fields[lbl];
var value = values[lbl];
var valPropName = field && ('type' in field) && field.type === 'checkbox' ? 'checked' : (field && 'value' in field ? 'value' : 'current');
if (!field) {
continue;
}
field[valPropName] = value;
value && this.changedFields.push(lbl);
}
},
subscribe: function () {
var me = this;
for (var lbl in this.fields)
if (this.fields.hasOwnProperty(lbl)) {
var field = this.fields[lbl];
var isTextField = 'value' in field;
field.addEventListener(isTextField ? 'keydown' : 'change', me.fieldChanged.bind(me));
field.addEventListener(isTextField ? 'keydown' : 'change', isTextField ? me.search.bind(me).defer(1000) : me.search.bind(me));
if (isTextField) {
['cut', 'paste', 'change'].forEach(function (e) {
field.addEventListener(e, me.fieldChanged.bind(me));
});
}
}
winJs.bus.addEventListener('clear-command', me.clearAndSearch.bind(me));
},
clearAndSearch: function () {
this.clearForm();
this.search();
},
addNewClient: function () {
var values = this.getValues();
winJs.Navigation.navigate("/pages/section/section.html", values);
},
getValues: function () {
var me = this;
var values = {};
this.changedFields.forEach(function (lbl) {
values[lbl] = me.getValue(lbl);
});
return values;
},
search: function () {
var values = this.getValues();
winJs.bus.dispatchEvent('search-client', values);
this.oldValues = JSON.stringify(values);
},
clearForm: function () {
var me = this;
var fields = Array.prototype.slice.call(me.element.querySelectorAll('input[type=text],select'), 0);
fields.forEach(function (e) {
e.value = '';
});
var current = new Date();
me.fields && me.fields.birthday && (me.fields.birthday.current = new Date(current.setYear(current.getFullYear() - 24)));
this.changedFields = [];
},
fieldLabel: function (field) {
return field && (field.getAttribute('name') || field.id);
},
fieldChanged: function (e) {
var field = e && e.currentTarget;
var lbl = this.fieldLabel(field);
if (!(lbl in this.fields))
return;
var value = this.getValue(lbl);
if (!value) {
this.changedFields.remove(lbl);
return;
}
if (this.changedFields.indexOf(lbl) === -1) {
this.changedFields.push(lbl);
}
},
initProperies: function () {
}
});
winJs.Namespace.define('HabraWin', {
ClientSearchForm: searchForm
});
})(WinJS);
Promise на примере Share
Если вы пользовались api для асинхронных вызовов, например, XmlHttpRequest и вам надо было выполнить цепочку зависимых друг от друга вызовов, то вы обращали внимание на то что такую цепочку вызовов сложно поддерживать, т.е. читать и изменять в первую очередь из-за вложенности. Я знаю два паттерна, которые могут избавить от вложенности: события или promise.
Например, объединение последовательных вызовов:
share: function(e) {
var request = e.request;
var deferral = request.getDeferral();
var offering = this.offering;
var files = [];
var me = this;
var text = offering.description.replace(/<[^>]+>/gim, '').replace(/ [s]+/, ' ');
// запускаем асинхронную операцию:
this.fileListControl.selection.getItems()
.then(function (items) {
// собираем доступные файлы, тоже асинхронно
return items.map(function (item) {
var uri = new Windows.Foundation.Uri(item.data.uri);
return Windows.Storage.StorageFile.getFileFromApplicationUriAsync(uri)
.then(function (storageFile) {
files.push(storageFile);
});
});
}).then(function (promises) {
// соединяем все операции, чтоб работать с их результатами
return WinJS.Promise.join(promises);
}).done(function () {
// формируем пакет данных для того чтоб поделиться ими с другими приложениями
request.data.properties.title = offering.name;
request.data.properties.description = text;
if (files.length)
request.data.setStorageItems(files);
else
me.articlePackage(request.data);
deferral.complete();
});
},
Доступ к данным — DataSource
Для визуализации данных можно использовать WinJs.UI.ListView. Например, этот замечательный контрол умеет загружать данные не все сразу, а по мере необходимости отображать. Что бережет ресурсы при отображении более сотни записей. Но для этого необходимо реализовать свой DataSource с поддержкой загрузки данных постранично.
'use strict';
var clientSearchDataAdapter = winJs.Class.define(winJs.Utilities.defaultConstructor(), {
def: {
maxCount: 300,
maxPageSize: 50,
minPageSize: 50
},
init: function (options) {
this.cache = {};
this._filter = null;
this.dataSource = options.dataSource;
},
condition: {
get: function () {
return this._filter;
},
set: function (value) {
this._filter = value;
this.dataSource && this.dataSource.invalidateAll && this.dataSource.invalidateAll();
return value;
}
},
getQuery: function () {
var me = this;
return new HabraWin.ProxyBuilder('client').then(function (proxy) {
return proxy.search(me.condition);
});
},
getCount: function () {
var me = this;
var cacheKey = JSON.stringify(me.condition);
if (cacheKey in this.cache)
return WinJS.Promise.wrap(me.cache[cacheKey].length);
var query = me.getQuery();
var i = 0;
return query
.then(function (clients) {
me.cache[cacheKey] = clients.map(function (item) {
return {
key: '' + (i++),
data: item,
groupKey: item.secondName.length > 0 ? item.secondName.substring(0, 1).toUpperCase() : '-'
};
});
var filtered = me.applyFilters({ items: clients, offset: 0, totalCount: clients.length });
return filtered.items.length;
});
},
addFilter: function (filter) {
this.filters = this.filters || [];
this.filters.push(filter);
},
applyFilters: function (result) {
if (!this.filters || !this.filters.length)
return result;
var me = this;
this.filters.forEach(function (filter) {
result = filter(result, me.condition);
});
return result;
},
itemsFromIndex: function (requestIndex, countBefore, countAfter) {
var me = this;
if (requestIndex >= me.options.maxCount) {
return winJs.Promise.wrapError(new winJs.ErrorFromName(winJs.UI.FetchError.doesNotExist));
}
var fetchSize, fetchIndex;
if (countBefore > countAfter) {
countAfter = Math.min(countAfter, 10);
var fetchBefore = Math.max(Math.min(countBefore, me.options.maxPageSize - (countAfter + 1)), me.options.minPageSize - (countAfter + 1));
fetchSize = fetchBefore + countAfter + 1;
fetchIndex = requestIndex - fetchBefore;
} else {
countBefore = Math.min(countBefore, 10);
var fetchAfter = Math.max(Math.min(countAfter, me.options.maxPageSize - (countBefore + 1)), me.options.minPageSize - (countBefore + 1));
fetchSize = countBefore + fetchAfter + 1;
fetchIndex = requestIndex - countBefore;
}
var cacheKey = JSON.stringify(me.condition);
var result = function () {
var cache = me.cache[cacheKey];
var items = cache.slice(fetchIndex, fetchIndex + fetchSize);
var offset = requestIndex - fetchIndex;
var totalCount = Math.min(cache.length, me.options.maxCount);
var r = {
items: items,
offset: offset,
totalCount: totalCount,
};
var filtered = me.applyFilters(r);
return filtered;
};
if (cacheKey in me.cache) {
return WinJS.Promise.wrap(result());
}
var query = me.getQuery();
return query
.then(function (items) {
var i = 0;
me.cache[cacheKey] = items.map(function (item) {
return {
key: '' + (fetchIndex + i++),
data: item,
groupKey: item.secondName.length > 0 ? item.secondName.substring(0, 1).toUpperCase() : '-'
};
});
return result();
});
}
});
var clientsDataSource = winJs.Class.derive(winJs.UI.VirtualizedDataSource, function (condition) {
var dataAdapter = new clientSearchDataAdapter({
dataSource: this
});
this.setCondition = function (cond) {
dataAdapter.condition = cond;
};
this.addFilter = function (filter) {
dataAdapter.addFilter(filter);
};
this._baseDataSourceConstructor(dataAdapter);
this.setCondition(condition);
});
winJs.Namespace.define('HabraWin.DataSources', {
ClientSearch: clientsDataSource
});
})(WinJS, console);
Tile
В Win8 есть замечательная возможность для приложений, которые пользователь добавил себе на стартовую панель, показывать наиболее ценную информацию в тот или иной момент.
В примере ниже я использую темплейт TileWideSmallImageAndText03, все возможные варианты темплейтов можно увидеть на msdn
Пример кода для обновления tile-ов:
; (function(winJs, ui, dom) {
winJs.Namespace.define('HabraWin', {
Tile: {
// создаём xml для tile-а
wideSmallImageAndText03: function(img, text) {
var tileXmlString = '<tile><visual version="1" lang="ru-RU" branding="logo">'
+ '<binding template="TileWideSmallImageAndText03">'
+ '<image id="1" src="' + img + '" alt="logo" />'
+ '<text id="1">' + text + '</text>'
+ '</binding>'
+ '</visual></tile>';
var tileDom = new dom.XmlDocument();
tileDom.loadXml(tileXmlString);
// делаем из xml сообщение
return new ui.Notifications.TileNotification(tileDom);
},
baseUrl: '',
// обновление tile-ов для приложения
updateTile: function() {
var tileUpdateManager = ui.Notifications.TileUpdateManager.createTileUpdaterForApplication();
var me = this;
var mesageAccepted = WinJS.Resources.getString('tileMessageAccepted').value;
var mesageDenied = WinJS.Resources.getString('tileMessageDenied').value;
tileUpdateManager.clear();
tileUpdateManager.enableNotificationQueue(true);
[
{ Creator: { ID: '30BD3259-EF01-4ebb-ACEE-5065EB2885E1', Photo: true }, Description: mesageAccepted },
{ Creator: { ID: 'A2021DFE-1271-41d1-9A90-A64039A8A5E6', Photo: true }, Description: mesageDenied }
].forEach(function(comment) {
var img = (comment.Creator && comment.Creator.Photo && (me.baseUrl + '/clients/photos/' + comment.Creator.ID)) || 'appx:///images/empty.png';
var text = (comment.Description) || '...';
var tile = me.wideSmallImageAndText03(img, text);
tileUpdateManager.update(tile);
});
}
}
});
})(WinJS, Windows.UI, Windows.Data.Xml.Dom);
Автор: Sigura