Разработка элементов управления ASP.NET на примере навигационной панели (исходники)

Источник: rusdoc

Введение

Эта проблема стара, как само веб-программирование - даже на самом простом сайте нам нужна панель навигации (или меню). Ну да, та самая, где написано: "О компании", "Услуги", "Прайс-лист", "Сервис" и "Контакты".

Давным-давно я писал её на Perl и SSI, потом на PHP, потом на ASP, и конца этому не было, пока не вышла 2-ая версия ASP.NET.

Разработчики Microsoft предложили удобное расширяемое решение, которое позволило описывать иерархию страниц в несложном XML-файле, а при желании - увязывать между собой структуры из разных источников (например, из базы данных, из дерева каталогов на диске, из нескольких XML-файлов).

Кроме того, мы получили компоненты для отображения структуры сайта: TreeView, Menu и SiteMapPath. Казалось бы - вот оно, счастье!

Однако нет. Я обнаружил, что простым сайтам нужна лёгкая линейная структура, и встроенные компоненты оказываются для этого случая слишком "громоздкими". Вроде всё хорошо, но не совсем понятно, стоит ли для пары-тройки ссылок подгружать столько кода на JavaScript.

С другой стороны, дизайнеры тоже не дремлют - иногда с ними можно договориться о цвете ссылок, но если речь идёт о навигации, они непреклонны. Хорошо, если ребята не настаивают на ручной отрисовке каждого пункта меню, но вот подложку и roll-over им надо сделать обязательно. И объяснить, что TreeView или Menu для этого не предназначены, чертовски сложно.

Единственное, что нам остаётся - написать подходящий компонент самостоятельно. Этим мы и займёмся.

Постановка задачи

Для примера возьмём самую простую структуру сайта:

<?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 и разворачивал структуру сайта в линейный список.
  • Использовал шаблоны, разные для текущей страницы и для всех остальных.

Реализация

Нам "всего лишь" осталось реализовать два перечисленных выше пункта, и затем сделать так, чтобы у нас получился компонент, который можно подвесить на панель инструментов (Toolbox).

Подключение к 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, который мы обсудим позднее.

Шаблоны

Для того, чтобы наш класс понимал шаблоны, мы должны описать свойства класса 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, и сразу после этого мы создаём дочерние компоненты на базе шаблонов.

Доводим компонент до ума

Поддержка 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>();

Родительский блок DIV

Компонент NavigationPanel является наследником WebControl, который требует, чтобы содержимое компонента размещалось внутри одного из HTML-тегов. По умолчанию в качестве родительского тега используется SPAN, но нам больше подошёл бы тег DIV. Чтобы этого добиться, достаточно переопределить защищённое свойство TagKey:

protected override HtmlTextWriterTag TagKey
{
  get { return HtmlTextWriterTag.Div; }
}

Завершающие штрихи

При описании компонента 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.

Заключение

Помимо основного файла NavigationPanel.cs вы найдёте там вспомогательный - Utilities.cs, в котором собраны методы для корректного преобразования виртуальных, абсолютных и относительных путей (тот самый метод ToAbsolute, про который я не стал писать в статье).

Можно подключить компонент к панели инструментов Visual Studio. Для этого распахните Toolbox, щёлкните правой кнопкой мыши внутри закладки General и выберите пункт Choose Items. В открывшемся диалоге нажмите кнопку Browse и загрузите Binateq.Controls.dll.

После этого навигационную панель можно будет перетаскивать с панели инструментов в код страницы. Visual Studio автоматически вставит в начало страницы строку , и добавит в проект ссылку (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://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=17075