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

    Этот пост будет интересен прежде всего 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

    Комментарии 7

      +2
      AJAX-сайты, ASP.NET, пример в рар-архиве! о майн гот
      0
      Я бы предпочел использовать для достижения цели все-таки ASP.NET Ajax History
        0
        да, спасибо, хорошая штука.
        однако важным плюсом моего варианта является простота подключения к уже существующему коду.
        0
        Потому как в вашем примере, последний пункт

        3. обеспечить корректную работоспособность кнопок «Назад» и «Вперёд» браузера для навигации по вкладкам.

        не работает в IE 7.0
          0
          уже работает, смотрим update ;-)
            +1
            Просто класс! Вставил к себе — работает) Спасибо за оперативное исправление!

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое