Как стать автором
Обновить

Навигация на AJAX-сайтах: Extender Control для ajaxtoolkit: TabContainer

Время на прочтение 15 мин
Количество просмотров 2.1K
Этот пост будет интересен прежде всего ASP.NET-разработчикам, которые осваивают «продвинутый» функционал AjaxControlToolkit, в частности, расширение стандартного TabControl — контрола, обеспечивающего клиентские вкладки («табы») на странице.

Впрочем, так как задача по сути сводится к клиентскому программированию, общие принципы окажутся полезными не только ASP.NET-разработчикам, поэтому, думаю, место ей в блоге «Веб-разработка».

Постановка проблемы: обеспечить при использовании TabControl соответствие текущей выбранной вкладки содержимому адресной строки браузера. То есть,
  1. чтобы при переходе между вкладками соответствующим образом изменялся адрес,
  2. можно было в любой момент скопировать ссылку, и открыв её после, попасть на ту же вкладку, откуда была скопирована ссылка,
  3. обеспечить корректную работоспособность кнопок «Назад» и «Вперёд» браузера для навигации по вкладкам.
Решил разобраться с AJAX Extender Controls и реализовать эту полезную штуку именно в виде Extender-контрола.
Собственно, задача распадается на две:
  1. принципиально реализовать указанное выше поведение и
  2. оформить решение в виде расширителя (Extender), подобно стандартным расширителям AjaxToolKit.

Реализация синхронизации с адресной строкой

Подход

Очевидно, что необходимо синхронизовать в две стороны:
  • при открывании страницы показать ту вкладку, которая указана в URL (где именно указывать эту информацию — об этом ниже), и
  • при переключении вкладок — изменять URL.
Второй пункт вызывает вопросы. Ведь при измении URL
document.location = "...";
будет происходить перезагрузка страницы, что нас никак не устравает. Недолгое гугление показывает, что единственная часть url, которая не передаётся на сервер и не перезагружает страницу — это якорь, то есть то, что идёт после '#' в адресе, например, http://site.com/user/vasya/profile/#contacts. И его можно менять, не вызывая перезагрузку, вот так:
document.location.hash = «myTabName»;
Таким образом, текущая вкладка будет отображаться на якорь, например, вот так:
  • /user/profile/#contacts
  • /user/profile/#password
  • /user/profile/#subscribe

Принципиальное решение проблемы

Пусть есть вот такой контрол с вкладками:
<ajax:TabContainer ID=«tbcProfile» runat=«server»
   ActiveTabIndex=«0»>   
   <ajax:TabPanel ID=«tabContacts» runat=«server»>
      <ContentTemplate>
         ...
      ContentTemplate>
   ajax:TabPanel>
   <ajax:TabPanel ID=«tabPassword» runat=«server»>
      <ContentTemplate>
         ...
      ContentTemplate>
   ajax:TabPanel>
   <ajax:TabPanel ID=«tabSubscribe» runat=«server»>
      <ContentTemplate>
         ...
      ContentTemplate>
   ajax:TabPanel>
ajax:TabContainer>
Напишем сперва прототип решения (не оформляя его в классы и т.п.), чтобы просто убедиться, что подход сработает. Во-первых, мы должны обеспечить перезапись url в ответ на переключение вкладки. Для этого есть у контрола TabContainer клиентское событие OnClientActiveTabChanged. Указав в нём имя функции-обработчика, мы получим то, что хотели. Вот эта функция:
var tabNames = ['contacts', 'password', 'subscribe'];
function onTabChanged(sender, args) {
    document.location.hash = tabNames[sender.get_activeTabIndex()];
}
Здесь sender — клиентский объект TabContainer, имеющий метод get_activeTabIndex, возвращающий номер текущей выбранной вкладки.А tabNames — массив имён вкладок, отображаемых в URL.

Теперь нужно добиться перехода на вкладку, указанную в адресной строке:
var lastSetTab = null;
function setTabFromUrl()
{
   // получаем имя вкладки из URL
   var tabFromUrl = window.location.hash.replace('#', '');
   // находим клиентский объект TabContainer
   var tbcMenu = $find('<%= tbcProfile.ClientID %>');
   // если объект уже инициализирован и вкладка в URL изменилась с момента последней проверки
   if (tbcMenu != null && tabFromUrl != lastSetTab)
   { // то запоминаем последнее переключение
      lastSetTab = tabFromUrl;
      // ищем, какой индекс у требуемой вкладки
      for (var i = 0; i < tabNames.length; i++)
      {
         if (tabFromUrl == tabNames[i])
         { // если нашли, то
            // временно отключаем обратную синхронизацию с URL
            tbcMenu.supressTabChanged = true;
            // пытаемся сделать активной выбранную вкладку
            try { tbcMenu.set_activeTabIndex(i); }
            // если объект ещё не до конца проинициализирован, может быть исключение
            catch (e) { lastSetTab = null; } // тогда просто отменяем запоминание переключения — словно ничего и не было
            // включаем обратную синхронизацию с URL
            tbcMenu.supressTabChanged = false;
            break;
         }
      }
   }

   // запускаем этот же метод через небольшой интервал времени — отслеживая таким образом кнопки «Назад» и «Вперёд».
   setTimeout(setTabFromUrl, 200);
}

И осталось только вызвать setTabFromUrl в обработчике загрузки страницы. Сразу скажу, что подход сработал. (UPD: с корректировкой для IE)
И теперь можно оформить всё в красивое и повторно используемое решение через расширитель.

Разработка расширителя для TabContainer — «UrlFriendlyTabExtender»

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

Выглядеть это должно так: К «обычному» объявлению
<ajax:TabContainer ID=«tbcProfile» runat=«server»
   ActiveTabIndex=«0»>   
   <ajax:TabPanel ID=«tabContacts» runat=«server»>
      <ContentTemplate>
         ...
      ContentTemplate>
   ajax:TabPanel>
   <ajax:TabPanel ID=«tabPassword» runat=«server»>
      <ContentTemplate>
         ...
      ContentTemplate>
   ajax:TabPanel>
   <ajax:TabPanel ID=«tabSubscribe» runat=«server»>
      <ContentTemplate>
         ...
      ContentTemplate>
   ajax:TabPanel>
ajax:TabContainer>
добавляется объявление:
<ext:UrlFriendlyTabExtender runat=«server» TargetControlID=«tbcProfile» TabNames=«contacts, password, subscribe»/>
И после этого всё вышеописанное должно заработать.

Реализация

Создаём проект библиотеки по шаблону AJAX Extender Control Library. Мы имеем в проекте 3 файла: с серверным C#-кодом, клиентским JS-скриптом и с ресурсами (пустой). Переименуем классы для соответствия нашим требованиям: серверный класс будет называться UrlFriendlyTabExtender, а клиентский — UrlFriendlyTabClientBehavior, файл с ресурсами — с тем же именем, что и клиентский скрипт. Привожу код с комментариями:
класс UrlFriendlyTabExtender (C#)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.UI;
using AjaxControlToolkit;
using System.Text;
using System.ComponentModel;

namespace Utils.Web.Extenders
{
   /// <summary>
   /// Обеспечивает при использовании TabControl соответствие текущей выбранной вкладки содержимому адресной строки браузера.
   /// То есть,
   ///      - чтобы при переходе между вкладками соответствующим образом изменялся адрес,
   ///      - можно было в любой момент скопировать ссылку, и открыв её после, попасть на ту же вкладку,
   ///         откуда была скопирована ссылка,
   ///      - обеспечить корректную работоспособность кнопок «Назад» и «Вперёд» браузера для навигации по вкладкам.
   /// </summary>
   [  // указываем, что наш расширитель именно для TabContainer
      TargetControlType(typeof(TabContainer))
   ]
   public class UrlFriendlyTabExtender: ExtenderControl
   {
      protected override void OnPreRender(EventArgs e)
      {
         base.OnPreRender(e);

         // подписываем TabContainer на его клиентское событие изменения текущей вкладки
         // обработчиком назначаем «статический» метод клиентского класса UrlFriendlyTabClientBehavior.onTabChanged
         ((TabContainer)Parent.FindControl(TargetControlID)).OnClientActiveTabChanged =
            «Utils.Web.Extenders.UrlFriendlyTabClientBehavior.onTabChanged»;

         // описываем, как инициализировать массив имён вкладок — генерим скрипт этого массива
         // и замещаем метод UrlFriendlyTabClientBehavior.initTabNames этим кодом.
         Page.ClientScript.RegisterStartupScript(Page.GetType(), ClientID, string.Format(@"
            Utils.Web.Extenders.UrlFriendlyTabClientBehavior.prototype.initTabNames = function()
            {{
               this._tabNames = [{0}];
            }}
            "
, ScriptTabNamesAsJavaScriptArray()), true);
      }

      /// <summary>
      /// Список слов, разделённых пробелами, запятыми или точками с запятой,
      /// которые представляют ссылочные имена (якори) для вкладок нашей панели.
      /// Имена идут в порядке, соответствующем порядку вкладок.
      /// <example>TabNames=«main, photos, news»</example>
      /// </summary>
      [Bindable(true), Category(«Behaviour»)]
      public string TabNames
      {
         get
         {
            if ((ViewState[«TabNames»] as string) != null)
               return (string)ViewState[«TabNames»];
            else
               return string.Empty;
         }
         set
         {
            ViewState[«TabNames»] = value;
         }
      }

      // стандартные перегрузки для Extender
      protected override IEnumerable<ScriptDescriptor>
            GetScriptDescriptors(System.Web.UI.Control targetControl)
      {
         yield return new ScriptBehaviorDescriptor(«Utils.Web.Extenders.UrlFriendlyTabClientBehavior», targetControl.ClientID);
      }
      protected override IEnumerable<ScriptReference>
            GetScriptReferences()
      {
         yield return new ScriptReference(«Utils.Web.Extenders.UrlFriendlyTabClientBehavior.js», this.GetType().Assembly.FullName);
      }

      /// <summary>
      /// Из строки с разделителями генерирует скрипт JS-массива (без скобок).
      /// </summary>
      /// <returns></returns>
      private string ScriptTabNamesAsJavaScriptArray()
      {
         var tabsMapDeclaration = new StringBuilder();
         IEnumerable<string> tabNames = GetTabNames(TabNames);
         int tabNamesCount = tabNames.Count();
         int tabNamesIndex = 0;
         foreach (string tabName in tabNames)
         {
            if (tabNamesIndex < (tabNamesCount — 1))
               tabsMapDeclaration.AppendFormat("'{0}', ", tabName);
            else
               tabsMapDeclaration.AppendFormat("'{0}'", tabName);
            tabNamesIndex++;
         }
         return tabsMapDeclaration.ToString();
      }
      private IEnumerable<string> GetTabNames(string tabNamesAggregated)
      {
         return tabNamesAggregated.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries);
      }
   }
}
Класс UrlFriendlyTabClientBehavior (JavaScript)
Type.registerNamespace(«Utils.Web.Extenders»);

// короткий метод для создания привязанного делегата
// если он вызывает вопросы, вам сюда: http://habrahabr.ru/blogs/javascript/31647/
function $delegate($this, method)
{
   return function()
   {
      return method.apply($this, arguments);
   };
};

// конструктор класса UrlFriendlyTabClientBehavior
// param: element — DOM-элемент, на который «надет» наш TabContainer
Utils.Web.Extenders.UrlFriendlyTabClientBehavior = function(element)
{
   // конструктор базового класса
   Utils.Web.Extenders.UrlFriendlyTabClientBehavior.initializeBase(this, [element]);
   // описание и инициализация полей нашего класса
   this._lastSetTab = null;
   this._tabNames = [];
};

// статический метод — обработчик клиентского события изменения выбранной вкладки
Utils.Web.Extenders.UrlFriendlyTabClientBehavior.onTabChanged = function(sender, args)
{
   // передаётся клиентский объект TabContainer
   var tbcMenu = sender;
   // по нему получаем DOM-объект (get_element()), а из него — объект нашего класса UrlFriendlyTabClientBehavior
   var extender = tbcMenu.get_element().UrlFriendlyTabClientBehavior;
   // если это не наш код вызвал переключение вкладки
   if (!tbcMenu.supressTabChanged)
   { // то переписываем якорь в адресной строке, получая название якоря методом UrlFriendlyTabClientBehavior.getTabName (см. ниже)
      document.location.hash = extender.getTabName(tbcMenu.get_activeTabIndex());
   }
};

// методы класса UrlFriendlyTabClientBehavior
Utils.Web.Extenders.UrlFriendlyTabClientBehavior.prototype =
{
   initialize: function()
   {
      Utils.Web.Extenders.UrlFriendlyTabClientBehavior.callBaseMethod(this, 'initialize');
      // загружаем в поле _tabNames массив имён вкладок
      this.initTabNames();
      // пытаемся установить ту, что указана в URL
      this.setTabFromUrl();
   },
   // установить текущей ту вкладку, что указана в URL
   setTabFromUrl: function()
   {
      // получаем её имя из URL
      var tabFromUrl = window.location.hash.replace('#', '');
      // находим клиентский объект TabContainer
      var tbcMenu = $find(this.get_element().id);
      // если объект уже инициализирован и вкладка в URL изменилась с момента последней проверки
      if (tbcMenu != null && tabFromUrl != this._lastSetTab)
      { // то запоминаем последнее переключение
         this._lastSetTab = tabFromUrl;
         // ищем, какой индекс у требуемой вкладки
         for (var i = 0; i < this._tabNames.length; i++)
         {
            if (tabFromUrl == this._tabNames[i])
            { // если нашли, то
               // временно отключаем обратную синхронизацию с URL
               tbcMenu.supressTabChanged = true;
               // пытаемся сделать активной выбранную вкладку
               try { tbcMenu.set_activeTabIndex(i); }
               // если объект ещё не до конца проинициализирован, может быть исключение
               catch (e) { this._lastSetTab = null; } // тогда просто отменяем запоминание переключения — словно ничего и не было
               // включаем обратную синхронизацию с URL
               tbcMenu.supressTabChanged = false;
               break;
            }
         }
      }

      // запускаем этот же метод через небольшой интервал времени — отслеживая таким образом кнопки «Назад» и «Вперёд».
      window.setTimeout($delegate(this, this.setTabFromUrl), 200);
   }, 
   // получить имя index-ой вкладки
   getTabName: function(index) 
   {
      return this._tabNames[index];
   },
   dispose: function()
   {    
      Utils.Web.Extenders.UrlFriendlyTabClientBehavior.callBaseMethod(this, 'dispose');
   }
};

Utils.Web.Extenders.UrlFriendlyTabClientBehavior.registerClass('Utils.Web.Extenders.UrlFriendlyTabClientBehavior', Sys.UI.Behavior);

if (typeof (Sys) !== 'undefined') Sys.Application.notifyScriptLoaded();



Собственно, готово. Теперь можно одной строкой добавлять поведение AJAX-навигации для TabContainer ;-)

Online-демо

Скачать:
Исходный код расширителя (проект Visual Studio 2008, C#)
Минималистичный сайт-пример применения расширителя
Библиотека с классом расширителя, собранная в режиме Release

Штука удобная получилась, используйте :). Она будет, кстати, использована в двух интересных проектах, которые уже очень скоро увидят свет на хабре.

UPD: поддержка IE7

Как указал mbrodin, решение в первоначальном виде решало не все поставленные задачи, а именно: в IE, хотя адрес и обновлялся, записей в истории навигации не появлялось, и кнопки «Вперёд» и «Назад» оставались неактивными.

Для решения этой проблемы нужно вручную разъяснить непонятливому IE, что от него требуется добавить запись в историю навигации. Подход я нашёл в библиотеке HistoryKeeper, вот его суть: добавляем к странице невидимый iframe с содержимым, которое и обновляем каждый раз, когда хотим получить запись в истории навигации. Новая версия компонента теперь работает с IE 7, как с родным. Спасибо за обратную связь ;-)

Кросс-пост с блога компании gendix
Теги:
Хабы:
+6
Комментарии 7
Комментарии Комментарии 7

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн