Блог API Яндекс.Карт

Пользовательские кнопки в API Яндекс.Карт 2.0

В API Яндекс.Карт 2.0 есть набор стандартных элементов управления картой: 



Бывают случаи, когда разработчику не подходит макет того или иного элемента управления. Например, хочется добавить на карту красную кнопку или его не устраивает стандартная пробочная панель, которая занимает очень много места на карте. В таких случаях разработчику требуется заменить существующий элемент на свой собственный. При этом он может изменить только ту часть, которая отвечает за внешний вид элемента и оставить без изменений логическую часть элемента управления картой.

Марина Степанова (, @ya_mstepanova) разработчик API Яндекс.Карт, написала статью на Хабре, посвященную созданию собственных контролов. Это первый пост Марины на Хабре, поэтому мы будем рады вашей поддержке и комментариям.

Те, кому неудобно читать и комментировать статьи на Хабре, могут прочитать статью у нас в блоге.

 

Пользовательские кнопки в API Яндекс.Карт 2.0

Для того чтобы создать собственный макет элемента управления, нужно разобраться в части архитектуры API. В статье проводится краткий обзор понятий, с которыми должен ознакомиться разработчик перед выполнением этой задачи и объясняется общий принцип взаимодействия логической и визуальной части элементов управления. Также рассматриваются три примера по созданию макетов – от простого к сложному.


Статья рассчитана на разработчиков, которые уже имели опыт работы с API Яндекс.Карт 2.0. Для знакомства с основными концепциями рекомендую прочитать руководство разработчика.

 

Основные понятия

Что такое макет?

Макет – это визуальное представление элемента управления. По сути, макет – объект, который умеет на основе передаваемых ему данных генерировать html.

Макет получает на вход объект с полями:

— control — ссылка на элемент управления;

— options — менеджер опций элемента управления;

— data — менеджер данных элемента управления;

— state — менеджер состояния элемента управления.

 

Что такое менеджер опций (состояния, данных)?

Менеджер – это хранилище, которое позволяет устанавливать и получать значения по ключу. Менеджер опций также умеет получать опции от родительских элементов, если они не найдены у ребенка.

Получать и задавать опции или данные можно следующим образом:

smallZoomControl.options.get(‘layout’); // получаем опцию layout
smallZoomControl.data.set(‘publicId’, myId); // запись произвольного поля в менеджер данных
В чем разница между опциями, данными и состоянием?

Опции – это рекомендации ко внешнему виду элемента управления. Например, через опции задается класс макета ('layout'), минимальная ширина кнопки ('minWidth') и т.д.
Важная особенность опций – возможность наследования от родителей. То есть опции можно задавать как напрямую, так и через любой из родительских элементов. При задании опций через родительские элементы, как правило, используется префикс. Например, опцию ’layout’ для control.Button можно задать через карту как ‘buttonLayout’ (‘button’ + ‘layout’).

Данные – это набор полей, описывающих информационное содержимое элемента. Например, данными может являться заголовок списка ('title') или содержимое кнопки ('content'). Данные не наследуются от родительских элементов и задаются только напрямую в объект.


Состояние – это набор полей, описывающих текущее состояние элемента управления. Поля состояния могут изменяться в результате действий пользователя.
Примеры полей состояния:

— 'expanded' — признак, раскрыт или свернут выпадающий список;

— 'selected' — признак, нажата или отжата кнопка.

Поля состояния также не наследуются от родительских элементов и могут самостоятельно изменяться в результате действий над элементом управления (например, в результате вызова метода button.select() изменится поле состояния кнопки ‘selected’).

Пример 1. Формирование макета на основе данных, опций и состояния элемента управления

Чаще всего макеты создаются с помощью специальной фабрики templateLayoutFactory. Фабрика позволяет задавать текстовый шаблон, с помощью которого впоследствии будет сформировано dom-представление элемента.

Рассмотрим пример создания собственного макета для control.Button. Что мы хотим получить от созданного макета:

1. В кнопке должна быть какая-то надпись;

2. У кнопки есть 2 состояния – когда она нажата и когда она не нажата.

Надпись – это значение одного из полей данных кнопки. Стандартная реализация кнопки использует поле 'content':

myButton.data.get(‘content’);

Нам ничего не мешает использовать другое произвольное поле данных, если это необходимо. Например:

myButton.data.set(‘caption’, ‘Сохранить’);

В данном случае обойдемся стандартным полем данных.

В нашем примере dom-представлением кнопки будет являться элемент div:

<div class=’my-button’>Заголовок кнопки</div>

Итак, нужно, чтобы вместо фразы ‘Заголовок кнопки’ подставилось поле из данных элемента управления. Создание макета кнопки будет выглядеть следующим образом:

var ButtonLayout = ymaps.templateLayoutFactory.createClass("<div class='my-button'> $[data.content] </div>");

Вместо текста 'Заголовок кнопки' мы вставили шаблон '$[data.content]'.

Фабрика макетов умеет обращаться с менеджерами данных, опций или состояний. Поэтому мы обращаемся к полю ‘content’ через точку – фабрика самостоятельно сможет определить, что data является менеджером данных, и выполнить операцию data.get(‘content’).

Теперь если мы будем изменять значение этого поля через менеджер данных, наш макет будет автоматически перестраиваться.


С первым пунктом мы справились. Переходим к пункту два.

В обычном состоянии кнопка выглядит так:

<div class=’my-button’>Заголовок кнопки</div>

В нажатом состоянии она выглядит так:

<div class=’my-button my-button-selected’>Заголовок кнопки</div>

То есть элементу div дописывается специальный класс, который меняет внешний вид кнопки.

Модифицируем наш текстовый шаблон:

var ButtonLayout = ymaps.templateLayoutFactory.createClass("<div class='my-button 
[if state.selected]my-button-selected[endif]'>$[data.content]</div>");

Итак, на основе данных и состояния кнопки мы смогли создать ее макет. Макет кнопки автоматически реагирует на клики и транслирует их в элемент управления. Так что никакой дополнительной логики в данном примере не требуется.

Посмотреть пример.

Пример 2. Макет, который взаимодействует с элементом управления

Рассмотрим более сложный пример – создание элемента управления коэффициентом масштабирования карты, проще говоря — 'зум-контрола'.

Html-шаблон нашего элемента управления выглядит так:

<div>
     <div id='zoom-in'>+</div>
     <div id='zoom-out'>-</div>
</div>

От созданного макета мы хотим получить следущее – при клике на элемент с плюсом или минусом нужно увеличивать или уменьшать зум карты на 1.

В документации сказано, что макет control.SmallZoomControl реализует интерфейс IZoomControlLayout. Читаем описание интерфейса и находим описание события 'zoomchange':

Zoomchange — cобытие, инициирующее смену коэффициента масштабирования карты. Экземпляр класса Event. Имена полей, доступных через метод Event.get:

— newZoom — новое значение коэффициента масштабирования;

— oldZoom — старое значение коэффициента масштабирования.

Это означает следующее – когда макет посылает событие ‘zoomchange’, элемент управления ловит его и соответственно реагирует (то есть изменяет коэффициент масштабирования карты).

Нужно, чтобы после формирования html макета на определенные элементы были навешаны слушатели. В частности, нужно слушать события ‘click’ на элементах с id=’zoom-in’ и id=’zoom-out’. В обработчиках клика мы будем генерировать событие 'zoomchange', в поля которого будут передаваться старый и новый коэффициенты масштабирования карты.

Создание макета для зум-контрола будет выглядеть следующим образом:

// Создадим пользовательский макет ползунка масштаба.
var MyZoomLayout = ymaps.templateLayoutFactory.createClass("<div>" +
            "<div id='zoom-in'>+</div>" +
            "<div id='zoom-out'>-</div>" +
        "</div>", {

        // Переопределяем методы макета, чтобы выполнять дополнительные действия
        // при построении и очистке макета.
        build: function () {
            // Вызываем родительский метод build.
            MyZoomLayout.superclass.build.call(this);

            // Начинаем слушать клики на кнопках макета.
            $('#zoom-in').bind('click', ymaps.util.bind(this.zoomIn, this));
            $('#zoom-out').bind('click', ymaps.util.bind(this.zoomOut, this));
        },

        clear: function () {
            // Снимаем обработчики кликов.
            $('#zoom-in').unbind('click');
            $('#zoom-out').unbind('click');

            // Вызываем родительский метод clear.
            MyZoomLayout.superclass.clear.call(this);
        },

        zoomIn: function () {
            var map = this.getData().control.getMap();
            // Генерируем событие, в ответ на которое
            // элемент управления изменит коэффициент масштабирования карты.
            this.events.fire('zoomchange', {
                oldZoom: map.getZoom(),
                newZoom: map.getZoom() + 1
            });
        },

        zoomOut: function () {
            var map = this.getData().control.getMap();
            this.events.fire('zoomchange', {
                oldZoom: map.getZoom(),
                newZoom: map.getZoom() - 1
            });
        }
  });

Посмотреть пример

На примере control.SmallZoomControl мы рассмотрели, как взаимодействуют элемент управления и его макет. Обобщим полученные знания в схеме:

Макет элемента управления строится на основе полей 'state', 'data' или 'options' и следит за их изменениями. При изменении значений полей макет перестраивается.

Макет, в свою очередь, может менять состояние элемента управления с помощью событий, которые указаны в его интерфейсе. Элемент управления слушает указанный набор событий и выполняет (или не выполняет) соответствующие действия.

Пример 3. Создание макета группового элемента управления

Групповой элемент управления отличается тем, что в него можно добавлять дочерние элементы. Макет группового элемента, таким образом, должен иметь html-элемент, который будет являться корневым для html-макетов дочерних элементов.

Создадим макет для выпадающего списка.

Html-макет раскрытого списка выглядит следующим образом:

<div id='my-listbox-header'>Заголовок списка</div>
<div id='my-listbox’>
    Первый элемент списка<br/>
    Второй элемент списка<br/>
</div>

Выпишем, что мы хотим получить от готового макета:

1. Нужно, чтобы в макет подставлялся заголовок списка;
2. Нужно, чтобы дочерние элементы списка автоматически добавлялись в указанный dom-элемент родителя;
3. Наш список должен уметь сворачиваться и разворачиваться;
4. Нужно каким-то образом задать внешний вид элементам списка.

Подставлять в макет заголовок списка мы будем по аналогии с примером макета кнопки:

var MyListBoxLayout = ymaps.templateLayoutFactory.createClass(
        "<div id='my-listbox-header'>$[data.title]</div>” +
        “<div id='my-listbox’></div>"
    );

Теперь перейдем ко второму пункту.

Родительским dom-элементом в нашем выпадающем списке будет элемент <div id='my-listbox’>.

Макет группового элемента управления должен реализовывать интерфейс IGroupControlLayout. Особенность этого интерфейса – наличие метода getChildContainerElement. Через этот метод элемент управления получает dom-элемент, к которому ему нужно прикреплять html-макеты дочерних элементов.

var MyListBoxLayout = ymaps.templateLayoutFactory.createClass(
        "<div id='my-listbox-header'>$[data.title]</div>” +
        “<div id='my-listbox’ ></div>", {
        build: function() {
            MyListBoxLayout.superclass.build.call(this);
            this.childContainerElement = $('#my-list-box')[0];
        },

        getChildContainerElement: function () {
            return this.childContainerElement;
        }
    });


При построении макета будет найден нужный dom-элемент, и групповой элемент управления сможет добавить дочерние элементы к родительскому.

Поскольку наш макет содержит подстановку '$[data.title]', он может перестраиваться. Как только это поле будет изменено, макет вызовет пару методов clear и build, чтобы обновить свое html-отображение. В таком случае dom-элемент, который служит контейнером для дочерних элементов, изменится (да, он будет точно такой же, как предыдущий, но это физически будет другой dom-элемент).

Элемент управления не следит за изменениями макета, и не узнает о том, что контейнер дочерних элементов сменился. Поэтому нам необходимо самостоятельно оповестить его об этом.

Каждый раз при перестроении будем генерировать событие интерфейса IGroupControlLayout 'childcontainerchange'.

var MyListBoxLayout = ymaps.templateLayoutFactory.createClass(
        "<div id='my-listbox-header'>$[data.title]</div>” +
        “<div id='my-listbox’></div>", {
        build: function() {
            MyListBoxLayout.superclass.build.call(this);
            this.childContainerElement = $('#my-list-box')[0];
            // Генерируем специальное событие, оповещающее элемент управления
            // о смене контейнера дочерних элементов.
            this.events.fire('childcontainerchange', {
                newChildContainerElement: this.childContainerElement,
                oldChildContainerElement: null
            });
        },

        clear: function () {
            // Заставим элемент управления перед очисткой макета
            // откреплять дочерние элементы от родительского.
            // Это защитит нас от неожиданных ошибок,
            // связанных с уничтожением dom-элементов в ранних версиях ie.
            this.events.fire('childcontainerchange', {
                newChildContainerElement: null,
                oldChildContainerElement: this.childContainerElement
            });
            this.childContainerChange = null;
            MyListBoxLayout.superclass.clear.call(this);
        },

        getChildContainerElement: function () {
            return this.childContainerElement;
        }
    });

Теперь при перестроении макета элемент управления будет знать о смене контейнера и переносить дочерние элементы в новый dom-элемент.


Нужно сделать так, чтобы наш список умел сворачиваться и разворачиваться.

Внешний вид элемента управления в свернутом состоянии:

<div id='my-listbox-header'>Заголовок списка</div>

Внешний вид элемента управления в развернутом состоянии:

<div id='my-listbox-header'>Заголовок списка</div>
<div id='my-listbox’>
    Первый элемент списка<br/>
    Второй элемент списка<br/>
</div>

Получается, что в данном примере (в зависимости от состояния элемента управления) нужно показывать или скрывать контейнер с дочерними элементами.

var MyListBoxLayout = ymaps.templateLayoutFactory.createClass(
        "<div id='my-listbox-header'>$[data.title]</div>” +
        “<div id='my-listbox’ style='display: [if state.expanded]block[else] none[endif];’></div>", {
        build: function() {
            MyListBoxLayout.superclass.build.call(this);
            this.childContainerElement = $('#my-list-box')[0];
            // Генерируем специальное событие, оповещающее элемент управления
            // о смене контейнера дочерних элементов.
            this.events.fire('childcontainerchange', {
                newChildContainerElement: this.childContainerElement,
                oldChildContainerElement: null
            });
        },

        clear: function () {
            // Заставим элемент управления перед очисткой макета
            // откреплять дочерние элементы от родительского.
            // Это защитит нас от неожиданных ошибок,
            // связанных с уничтожением dom-элементов в ранних версиях ie.
            this.events.fire('childcontainerchange', {
                newChildContainerElement: null,
                oldChildContainerElement: this.childContainerElement
            });
            this.childContainerChange = null;
            MyListBoxLayout.superclass.clear.call(this);
       },

      getChildContainerElement: function () {
          return this.childContainerElement;
     }
});


Мы настроили макет, который реагирует на изменение состояния элемента управления. Теперь нужно настроить обратную связь – в зависимости от действий пользователя посылать элементу управления команды «свернуться» или «развернуться». К счастью, control.ListBox по умолчанию сворачивается или разворачивается по клику на макете. Так что в данном примере никаких дополнительных действий для этого совершать не надо. Если бы нас не устроило поведение по умолчанию, мы могли бы самостоятельно отправлять команды элементу управления через макет, как мы это делали с control.SmallZoomControl.

Осталось создать макет для элементов выпадающего списка. Это делается довольно просто.

ymaps.templateLayoutFactory.createClass("$[data.content]<br/>");

Посмотреть пример

Еще один пример реализации пользовательских макетов





Для демонстрации возможностей макетов мы отобрали шесть самых популярных элементов управления картой в API (Кнопка, Раскрывающийся список, Поиск по карте, Простой элемент управления масштабом карты, Панель управления пробками, и Переключатель типа карты) и изменили их дизайн с помощью популяного css-фреймворка Twitter Bootstrap.

Посмотреть пример

Код на гитхабе