Эта проблема стара, как само веб-программирование — даже на самом простом
сайте нам нужна панель навигации (или меню). Ну да, та самая, где
написано: «О компании», «Услуги», «Прайс-лист», «Сервис» и «Контакты».
Давным-давно я писал её на Perl и SSI, потом на PHP, потом на ASP, и конца этому
не было, пока не вышла 2-ая версия ASP.NET.
1. Введение
Эта проблема стара, как само веб-программирование — даже на самом простом
сайте нам нужна панель навигации (или меню). Ну да, та самая, где
написано: «О компании», «Услуги», «Прайс-лист», «Сервис» и «Контакты».
Давным-давно я писал её на Perl и SSI, потом на PHP, потом на ASP, и конца этому
не было, пока не вышла 2-ая версия ASP.NET.
Разработчики Microsoft предложили удобное расширяемое решение, которое
позволило описывать иерархию страниц в несложном XML-файле, а при желании —
увязывать между собой структуры из разных источников (например, из базы данных,
из дерева каталогов на диске, из нескольких XML-файлов).
Кроме того, мы получили компоненты для отображения структуры сайта:
TreeView, Menu и SiteMapPath. Казалось бы — вот
оно, счастье!
Однако нет. Я обнаружил, что простым сайтам нужна лёгкая линейная структура,
и встроенные компоненты оказываются для этого случая слишком «громоздкими».
Вроде всё хорошо, но не совсем понятно, стоит ли для пары-тройки ссылок
подгружать столько кода на JavaScript.
С другой стороны, дизайнеры тоже не дремлют — иногда с ними можно
договориться о цвете ссылок, но если речь идёт о навигации, они непреклонны.
Хорошо, если ребята не настаивают на ручной отрисовке каждого пункта меню, но
вот подложку и roll-over им надо сделать обязательно. И объяснить, что TreeView
или Menu для этого не предназначены, чертовски сложно.
Единственное, что нам остаётся — написать подходящий компонент
самостоятельно. Этим мы и займёмся.
2. Постановка задачи
Для примера возьмём самую простую структуру сайта:
<?xml
version="1.0"
encoding="utf-8" ?>
<siteMap
xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0"
>
<siteMapNode
url="~/Default.aspx"
title="Главная страница">
<siteMapNode
url="~/Services.aspx"
title="Услуги и цены"/>
<siteMapNode
url="~/Contacts.aspx"
title="Контакты"/>
</siteMapNode>
</siteMap>
Нам бы хотелось получить тривиальную панель навигации, например, вот такого
вида:
| Главная страница | Услуги и цены | Контакты |
Текущая страница должна выводиться простым текстом, а все остальные —
ссылками.
В коде страницы мы хотим задействовать шаблоны (такие же, как и в компоненте
Repeater):
<binateq:NavigationPanel
ID="NavigationPanel1"
runat="server"
DataSourceID="SiteMapDataSource1">
<HeaderTemplate>|
</HeaderTemplate>
<ItemTemplate>
<a
href='<%#Container.Url%>'><%#Container.Title%></a> |
</ItemTemplate>
<SelectedItemTemplate>
<span><%#Container.Title%></span> |
</SelectedItemTemplate>
</binateq:NavigationPanel>
<asp:SiteMapDataSource
ID="SiteMapDataSource1"
runat="server"
/>
Если свести все требования воедино, нужно, чтобы компонент:
- Умел подключаться к
SiteMapDataSource и разворачивал
структуру сайта в линейный список.
- Использовал шаблоны, разные для текущей страницы и для всех остальных.
3. Реализация
Нам «всего лишь» осталось реализовать два перечисленных выше пункта, и затем
сделать так, чтобы у нас получился компонент, который можно подвесить на панель
инструментов (Toolbox).
3.1. Подключение к SiteMapDataSource
Для того, чтобы наш компонент мог подключиться к SiteMapDataSource,
мы должны унаследовать его от класса
System.Web.UI.WebControls.HierarchicalDataBoundControl и переопределить
виртуальный метод PerformDataBinding:
public
class NavigationPanel:
HierarchicalDataBoundControl
{
protected override
void PerformDataBinding()
{
base.PerformDataBinding();
nodes.Clear();
if(!IsBoundUsingDataSourceID && (DataSource
== null))
return;
HierarchicalDataSourceView view = GetData(string.Empty);
if(view != null)
{
IHierarchicalEnumerable enumerable = view.Select();
RecurseDataBind(enumerable, 1);
}
}
}
Для начала мы должны убедиться, что программист установил одно из свойств
DataSource или DataSourceID. Если панель навигации не
подключена к SiteMapDataSource (то есть ни одно из свойств не
установлено), посетитель сайта увидит содержимое шаблона EmptyTemplate.
Обратите внимание, что DataSource мы проверяем сами, а вот
для ревизии DataSourceID необходимо обратиться к свойству
IsBoundUsingDataSourceID.
Непосредственный доступ к данным возможен через представление (класс
HierarchicalDataSourceView), то есть тем же способом, каким мы работаем с
любым источником данных в .NET, будь то SqlDataSource или
XmlDataSource.
Метод GetData позволяет получить любое поддерево структуры сайта, но
поскольку нам нужна вся иерархия, в качестве пути мы указываем пустую строку.
Всю работу по извлечению данных выполняет наш метод RecurseDataBind,
который рекурсивно вызывает сам себя:
private void
RecurseDataBind(IHierarchicalEnumerable enumerable,
int level)
{
foreach(object
item in enumerable)
{
IHierarchyData hierarchyData = enumerable.GetHierarchyData(item);
SiteMapNode siteMapNode = hierarchyData as
SiteMapNode;
if(siteMapNode !=
null)
{
if(HttpContext.Current
== null ||
siteMapNode.IsAccessibleToUser(HttpContext.Current))
{
bool isSelected =
(siteMapNode.Provider == null) ?
false :
(siteMapNode.Provider.CurrentNode == siteMapNode);
nodes.Add(
new Node(
ToAbsolute(siteMapNode.Url),
siteMapNode.Title,
siteMapNode.Description,
level,
isSelected
)
);
}
if(hierarchyData.HasChildren)
{
IHierarchicalEnumerable recurseEnumerable =
hierarchyData.GetChildren();
if(recurseEnumerable !=
null)
RecurseDataBind(recurseEnumerable, level + 1);
}
}
}
}
Метод проверяет, что он работает с объектами класса SiteMapNode
(программист может передать нашему компоненту любую иерархию, и эту ситуацию
надо обрабатывать).
С помощью вызова IsAccessibleToUser мы прячем от
неавторизованного посетителя недоступные страницы. Авторизация работает только
во время исполнения, когда установлено свойство HttpContext.Current,
поэтому при настройке компонента в Visual Studio (design mode) все страницы
видимы.
Низкоуровневый доступ к данным осуществляет провайдер (наследник класса
SiteMapProvider) из которого мы получаем информацию о текущей странице.
Провайдеры доступны только во время выполнения, поэтому в режиме редактирования
ни одна страница текущей не является.
Как видим, метод RecurseDataBind сохраняет информацию об уровне
вложенности (переменная level), которой мы можем воспользоваться
при подготовке шаблонов.
Функция ToAbsolute переводит виртуальные пути (~/Default.aspx)
в абсолютные (/Default.aspx). Фактически, она вызывает
VirtualPathUtility.ToAbsolute, но кроме того, обрабатывает ситуации,
когда в качестве пути заданы полные URI (http://domain.tld/path/filename.ext).
Результатом работы метода RecurseDataBind становится список
объектов класса Node, который мы обсудим позднее.
3.2. Шаблоны
Для того, чтобы наш класс понимал шаблоны, мы должны описать свойства класса
ITemplate и установить для них несколько атрибутов:
private ITemplate headerTemplate = null;
private ITemplate footerTemplate =
null;
private ITemplate itemTemplate =
null;
private ITemplate selectedItemTemplate =
null;
private ITemplate emptyTemplate =
null;
…
[Browsable(false)]
[PersistenceMode(PersistenceMode.InnerProperty)]
[DefaultValue(typeof(ITemplate),
"")]
[TemplateContainer(typeof(Node))]
public virtual
ITemplate HeaderTemplate
{
get { return
headerTemplate; }
set { headerTemplate =
value; }
}
В целях экономии места, я опустил описание свойств FooterTemplate,
ItemTemplate, SelectedItemTemplate и
EmptyTemplate, которые полностью идентичны описанию HeaderTemplate.
С помощью атрибутов мы указываем визуальному редактору (т.е. Visual Studio),
как обрабатывать эти свойства:
Browsable(false)
Свойства-шаблоны недоступны на панели Properties во время
редактирования. Такое же поведение характерно для «родных» компонентов
ASP.NET.
PersistentMode(PersistentMode.InnerProperty)
Шаблоны в коде странице представлены не в виде атрибутов, а в виде вложенных
тегов. Пользуясь этой подсказкой, в Visual Studio работает IntelliSense.
DefaultValue(typeof(ITemplate), "")
Значением по умолчанию является пустой шаблон (без текста и вложенных
тегов). Этот атрибут позволяет перевести значение свойства в исходное
состояние.
TemplateContainer(typeof(Node))
Одно из самых важных свойств, которое обеспечивает привязку к данным (data
binding). О подробностях мы поговорим ниже.
Генерация кода страницы выполняется в методе CreateChildControls:
protected override
void CreateChildControls()
{
Controls.Clear();
if(nodes.Count > 0)
{
InstantiateTemplate(headerTemplate);
foreach(Node node in
nodes)
{
if(node.IsSelected)
InstantiateNodeTemplate(selectedItemTemplate, node);
else
InstantiateNodeTemplate(itemTemplate, node);
}
InstantiateTemplate(footerTemplate);
}
else
InstantiateTemplate(emptyTemplate);
}
Два вспомогательных метода InstantiateTemplate и
InstantiateNodeTemplate я написал, чтобы упростить
CreateChildControls:
private void
InstantiateTemplate(ITemplate template)
{
if(template != null)
{
Control templateHolder = new Control();
template.InstantiateIn(templateHolder);
Controls.Add(templateHolder);
}
}
private void
InstantiateNodeTemplate(ITemplate template, Node node)
{
if(template != null)
{
template.InstantiateIn(node);
Controls.Add(node);
node.DataBind();
}
}
Если свойство установлено, нужно добавить в код страницы элементы управления,
описанные в шаблоне.
Эту работу выполняет метод InstantiateIn, которому требуется
родительский объект Control, где и будут созданы дочерние элементы
управления. Если программист определил шаблон в коде страницы, ASP.NET создаёт
для нас объект, реализующий интерфейс ITemplate, в том числе и этот
метод.
В методе InstantiateNodeTemplate мы пользуемся уже готовым
объектом класса Node, который также является наследником
Control. Для того чтобы в код шаблона попали значения выражений вида
<%#Container.Url%>, мы вызываем метод DataBind.
Давайте подробнее остановимся на том, как происходит связывание с данными
(data binding):
- Данные нужно сначала получить, а затем вставить в
Control.
Метод InstantiateIn устроен так, что получение данных и
отображение выполняется через один и тот же объект, поэтому наш класс
Node с одной стороны содержит свойства Url, Title,
Description, а с другой — наследует классу Control
и используется для отображения шаблона. Такой подход снижает зацепление,
т.е. класс выполняет действия, которые никак друг с другом не связаны, и
делать так не рекомендуется. Что ж, это тот самый случай, когда мы ничего не
можем исправить.
- Если мы используем внутри шаблона элементы управления, у которых
установлен атрибут
ID, он будет дублироваться у повторяющихся
шаблонов, что приведёт к ошибке. Речь идёт о таких конструкциях, как:
<ItemTemplate>
<asp:LinkButton
ID=”LB”
runat=”server”
Text=”<%#Container.Title%>”
OnClick=”LB_Click”/>
</ItemTemplate>
Чтобы избежать ошибки, мы должны предупредить ASP.NET, что для дочерних
компонентов нужно генерировать уникальные идентификаторы. Для этого класс
Node должен наследовать пустому интерфейсу
INamingContainer. Поскольку интерфейс пустой (не определяет ни
методов, ни свойств), он действует в качестве маркера.
- Метод
DataBind можно вызывать только после того, как шаблон
инстанцирован и добавлен в родительский список элементов управления.
Извлечение данных идёт вверх по дереву компонентов, поэтому, если бы мы в
атрибуте TemplateContainer определили другой тип контейнера,
ASP.NET искал бы его среди родителей Node.
Последнее, что мы должны сделать, это увязать между собой разворачивание
структуры сайта в линейный список и его отображение:
public
override void
DataBind()
{
base.DataBind();
CreateChildControls();
ChildControlsCreated = true;
}
Метод base.DataBind среди прочего вызывает
PerformDataBinding, и сразу после этого мы создаём дочерние компоненты на
базе шаблонов.
3.3. Доводим компонент до ума
3.3.1. Поддержка ViewState и сериализация
Для того чтобы на странице автоматически работали такие компоненты, как
GridView и Repeater, ASP.NET вызывает метод DataBind
при первой загрузке страницы. Метод вызывается рекурсивно для всех компонентов
страницы, они получают данные и сохраняют их в ViewState. При повторных запросах
POST данные не считываются до тех пор, пока мы сами этого не сделаем.
Мы должны обеспечить такое же поведение для класса Node, иначе
навигационная панель будет «пропадать» со страницы при запросах POST.
Технически, это делается в два этапа:
- Мы переопределяем методы
SaveViewState и
LoadViewState у класса NavigationPanel.
- Мы делаем класс
Node сериализуемым.
Код методов SaveViewState и LoadViewState
достаточно прост, поэтому я не буду останавливаться на нём подробно:
protected override
object SaveViewState()
{
object[] currentStates =
new object[nodes.Count
+ 1];
currentStates[0] = base.SaveViewState();
for(int i = 0; i
< nodes.Count; i++)
currentStates[i + 1] = nodes[i];
return (object)currentStates;
}
protected override
void LoadViewState(object
savedState)
{
if(savedState != null)
{
object[] currentStates = (object[])savedState;
if(currentStates.Length > 0 &&
currentStates[0] != null)
{
base.LoadViewState(currentStates[0]);
nodes.Clear();
for(int i =
1; i < currentStates.Length; i++)
{
nodes.Add((Node)currentStates[i]);
}
}
}
}
Оба метода предполагают, что объекты класса Node умеют себя
сохранять (сериализовывать) и восстанавливать (десериализовывать). В простейших
случаях, когда речь идёт о сохранении/восстановлении публичных свойств,
достаточно пометить класс атрибутом Serializable, и .NET сам сможет
выполнить необходимую работу.
Однако в нашем случае этот способ не подходит, поскольку сериализуемым должен
быть не только класс Node, но и все его предки. Проблему в данном
случае создаёт класс Control, которому мы должны наследовать.
Чтобы её решить, мы должны реализовать в классе Node интерфейс
ISerializable, то есть один-единственный метод GetObjectData:
[SecurityPermission(SecurityAction.LinkDemand, Flags =
SecurityPermissionFlag.SerializationFormatter)]
public void
GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("Url", url);
info.AddValue("Title", title);
info.AddValue("Description", description);
info.AddValue("Level", level);
info.AddValue("IsSelected", isSelected);
}
Кроме того, мы должны добавить в класс защищённый конструктор, который
создаёт объект из сохранённых ранее значений:
protected Node(SerializationInfo info, StreamingContext context)
{
url = info.GetString("Url");
title = info.GetString("Title");
description = info.GetString("Description");
level = info.GetInt32("Level");
isSelected = info.GetBoolean("IsSelected");
}
Узлы дерева мы храним в закрытом поле nodes:
private List<Node> nodes =
new List<Node>();
3.3.2. Родительский блок DIV
Компонент NavigationPanel является наследником WebControl,
который требует, чтобы содержимое компонента размещалось внутри одного из
HTML-тегов. По умолчанию в качестве родительского тега используется SPAN,
но нам больше подошёл бы тег DIV. Чтобы этого добиться, достаточно
переопределить защищённое свойство TagKey:
protected override HtmlTextWriterTag
TagKey
{
get { return
HtmlTextWriterTag.Div; }
}
3.3.3. Завершающие штрихи
При описании компонента NavigationPanel мы должны установить
несколько атрибутов, чтобы Visual Studio могла правильно работать с ним в коде
страницы:
[assembly:TagPrefix("Binateq.Web.Controls",
"binateq")]
namespace Binateq.Web.Controls
{
[AspNetHostingPermission(SecurityAction.Demand, Level
= AspNetHostingPermissionLevel.Minimal)]
[AspNetHostingPermission(SecurityAction.InheritanceDemand, Level =
AspNetHostingPermissionLevel.Minimal)]
[ParseChildren(true)]
[ToolboxData(
"<{0}:NavigationPanel runat="server">
</{0}:NavigationPanel>"
)]
public class
NavigationPanel: HierarchicalDataBoundControl
{
…
}
}
На слово binateq не обращайте внимания — я употребляю его при написании
своего кода, вы же вполне можете задействовать ваше собственное название.
В свойстве assembly:TagPrefix задаётся префикс для компонентов,
который будет использован Visual Studio. Обычно она сама генерирует их (uc1, uc2
и т.д.), но такие названия бессмысленны, поэтому я предпочитаю указать префикс
при написании компонента.
Атрибут ParseChildren указывает Visual Studio на то, что теги
внутри трактуются как значения свойств, то есть содержимое тега
HeaderTemplate будет присвоено свойству
NavigationPanel.HeaderTemplate.
Атрибут ToolBoxData подсказывает Visual Studio, что именно нужно
вставить в код страницы при добавлении компонента. Вместо {0} будет
вставлен префикс, в нашем случае binateq.
4. Заключение
Исходный код компонента, вместе с готовой DLL можно скачать по адресу
http://mark.shevchenko.name/download/navigationpanel.zip.
Помимо основного файла NavigationPanel.cs вы найдёте там
вспомогательный — Utilities.cs, в котором собраны методы для
корректного преобразования виртуальных, абсолютных и относительных путей (тот
самый метод ToAbsolute, про который я не стал писать в статье).
Можно подключить компонент к панели инструментов Visual Studio. Для этого
распахните Toolbox, щёлкните правой кнопкой мыши внутри
закладки General и выберите пункт Choose Items.
В открывшемся диалоге нажмите кнопку Browse и загрузите
Binateq.Controls.dll.
После этого навигационную панель можно будет перетаскивать с панели
инструментов в код страницы. Visual Studio автоматически вставит в начало
страницы строку <%@ Register Assembly="Binateq.Controls"
Namespace="Binateq.Web.Controls" TagPrefix="binateq" %>, и добавит в
проект ссылку (reference) на Binateq.Controls.dll.
При выводе структуры сайта мы можем исключить корневую страницу, установив
SiteMapDataSource.ShowStartingNode в false. Мы также
можем выводить многоуровневую структуру, воспользовавшись свойством
Container.Level:
<ItemTemplate>
<div
class="level<%#Container.Level%>">
<a
href='<%#Container.Url%>'><%#Container.Title%></a>
</div>
</ItemTemplate>
<SelectedItemTemplate>
<div
class="level<%#Container.Level%>">
<%#Container.Title%>
</div>
</SelectedItemTemplate>
Определив в таблице стилей классы div.level1, div.level2,
div.level3, мы можем с помощью отступов отразить древовидную
структуру сайта. Существующий компонент не умеет ограничивать количество уровней
вложенности, и за этим придётся следить самостоятельно. Вы можете внести в код
компонента необходимые изменения, добавив свойство, например, MaxLevel,
и заменив в коде RecourseDataBind
if(hierarchyData.HasChildren)
на
if(hierarchyData.HasChildren
&& (maxLevel == 0 || maxLevel > level))
Если MaxLevel будет равен 0, то компонент будет показывать все
уровни, а если, например, 5, то только первые 5.
Автор:
http://markshevchenko.habrahabr.ru/