Пользовательский серверный элемент Slider на ASP.NET AJAX: ScriptControl шаг за шагом

РазделASP.NET AjaxУровеньОпытный Чтение9 минОпубликовано ТегиASP.NET AJAXScriptControlCustom ControlJavaScript

Готовый SliderExtender не всегда подходит по виду и поведению. Разбираем, как написать свой ползунок как полноценный серверный элемент ASP.NET AJAX со свойствами, событием и клиентским классом.

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

Зачем свой элемент, если есть Toolkit

В AJAX Control Toolkit есть SliderExtender, который превращает TextBox в ползунок. Для быстрых задач его хватает, но расширитель трудно перекрасить под дизайн проекта, у него неудобная модель событий, и он тянет за собой весь Toolkit. Когда ползунок нужен на десятках страниц (фильтр цены, выбор громкости, рейтинг), выгоднее один раз написать собственный элемент.

ASP.NET AJAX даёт для этого готовый каркас: базовый класс ScriptControl на сервере и Sys.UI.Control на клиенте. Серверная часть отвечает за разметку, свойства и событие изменения значения, клиентская за перетаскивание, клавиатуру и синхронизацию со скрытым полем. Связывают их дескрипторы: сервер описывает, какой клиентский класс создать и с какими параметрами.

Архитектура элемента

Такое разделение позволяет элементу работать как обычный контрол Web Forms: его можно положить на страницу из панели инструментов, задать свойства в разметке, привязать к данным и обработать событие ValueChanged в коде, не думая о JavaScript.

  • Серверный класс Slider наследует ScriptControl и реализует IPostBackDataHandler, чтобы принимать значение после отправки формы.
  • Свойства Minimum, Maximum, Value и Step хранятся во ViewState.
  • Разметка: внешний div, дорожка, бегунок с role="slider" и скрытое поле с текущим значением.
  • Метод GetScriptDescriptors передаёт параметры в клиентский класс.
  • Метод GetScriptReferences указывает встроенный ресурс со скриптом.
  • Клиентский класс MyControls.Slider обрабатывает мышь и клавиатуру и пишет значение в скрытое поле.

Серверная часть

Свойства пишутся через ViewState, чтобы значение переживало постбэк. В методе RenderContents выводится разметка, причём бегунок сразу получает ARIA-атрибуты: так элемент доступен с клавиатуры и понятен экранным дикторам. Скрытое поле получает имя UniqueID, и именно по нему LoadPostData найдёт значение в форме.

Slider.vbVB.NET
<ToolboxData("<{0}:Slider runat=server />")> _
Partial Public Class Slider
    Inherits ScriptControl
    Implements IPostBackDataHandler

    Public Event ValueChanged As EventHandler

    Public Property Minimum As Integer
        Get : Return CInt(If(ViewState("Min"), 0)) : End Get
        Set(v As Integer) : ViewState("Min") = v : End Set
    End Property
    Public Property Maximum As Integer
        Get : Return CInt(If(ViewState("Max"), 100)) : End Get
        Set(v As Integer) : ViewState("Max") = v : End Set
    End Property
    Public Property Value As Integer
        Get : Return CInt(If(ViewState("Val"), 0)) : End Get
        Set(v As Integer) : ViewState("Val") = Math.Max(Minimum, Math.Min(Maximum, v)) : End Set
    End Property

    Protected Overrides Sub RenderContents(w As HtmlTextWriter)
        w.Write("<div class=""sl-track""><div class=""sl-thumb"" role=""slider"" tabindex=""0""")
        w.Write(String.Format(" aria-valuemin=""{0}"" aria-valuemax=""{1}"" aria-valuenow=""{2}""", Minimum, Maximum, Value))
        w.Write("></div></div>")
        w.Write(String.Format("<input type=""hidden"" name=""{0}"" value=""{1}"" />", UniqueID, Value))
    End Sub
End Class
Методы интерфейса и дескрипторы вынесены в следующий блок, чтобы код помещался на экран. В реальном проекте это один Partial-класс.

Дескрипторы, ресурсы и PostBack

GetScriptDescriptors возвращает ScriptControlDescriptor с именем клиентского класса и свойствами, которые будут переданы ему при создании. GetScriptReferences указывает на JavaScript-файл, встроенный в сборку как ресурс: так элемент распространяется одной DLL. Ресурс нужно отметить атрибутом WebResource на уровне сборки.

LoadPostData сравнивает пришедшее значение с текущим и возвращает True, если оно изменилось. Тогда ASP.NET вызовет RaisePostDataChangedEvent, где и генерируется событие ValueChanged. Значение из формы обязательно проверяется: это пользовательский ввод, и он может быть любым.

Slider.Partial.vbVB.NET
Protected Overrides Function GetScriptDescriptors() As IEnumerable(Of ScriptDescriptor)
    Dim d As New ScriptControlDescriptor("MyControls.Slider", ClientID)
    d.AddProperty("minimum", Minimum)
    d.AddProperty("maximum", Maximum)
    d.AddProperty("value", Value)
    Return New ScriptDescriptor() {d}
End Function

Protected Overrides Function GetScriptReferences() As IEnumerable(Of ScriptReference)
    Return New ScriptReference() {
        New ScriptReference("MyControls.Slider.js", Me.GetType().Assembly.FullName)}
End Function

Public Function LoadPostData(key As String, values As NameValueCollection) As Boolean _
        Implements IPostBackDataHandler.LoadPostData
    Dim posted As Integer
    If Not Integer.TryParse(values(key), posted) OrElse posted = Value Then Return False
    Value = posted ' сеттер сам ограничит диапазон
    Return True
End Function

Public Sub RaisePostDataChangedEvent() Implements IPostBackDataHandler.RaisePostDataChangedEvent
    RaiseEvent ValueChanged(Me, EventArgs.Empty)
End Sub

Клиентский класс

Клиентская часть регистрируется через Type.registerNamespace и registerClass с базовым Sys.UI.Control. В initialize навешиваются обработчики, в dispose они снимаются: это важно, потому что внутри UpdatePanel элемент создаётся и уничтожается при каждом асинхронном обновлении, и забытые обработчики приводят к утечкам памяти.

Обработка клавиатуры обязательна: стрелки меняют значение на шаг, Home и End переводят в крайние положения. Без этого ползунком невозможно пользоваться без мыши.

MyControls.Slider.jsJavaScript
Type.registerNamespace('MyControls');
MyControls.Slider = function (el) {
  MyControls.Slider.initializeBase(this, [el]);
  this._minimum = 0; this._maximum = 100; this._value = 0;
};
MyControls.Slider.prototype = {
  initialize: function () {
    MyControls.Slider.callBaseMethod(this, 'initialize');
    this._thumb = this.get_element().querySelector('.sl-thumb');
    this._hidden = this.get_element().querySelector('input[type=hidden]');
    this._onKey = Function.createDelegate(this, this._keyDown);
    $addHandler(this._thumb, 'keydown', this._onKey);
    this._render();
  },
  dispose: function () {
    if (this._thumb) $removeHandler(this._thumb, 'keydown', this._onKey);
    MyControls.Slider.callBaseMethod(this, 'dispose');
  },
  _keyDown: function (e) {
    var k = e.keyCode, v = this._value;
    if (k === 37 || k === 40) v--; else if (k === 39 || k === 38) v++;
    else if (k === 36) v = this._minimum; else if (k === 35) v = this._maximum;
    else return;
    e.preventDefault(); this.set_value(v);
  },
  _render: function () {
    var pct = (this._value - this._minimum) / (this._maximum - this._minimum) * 100;
    this._thumb.style.left = pct + '%';
    this._thumb.setAttribute('aria-valuenow', this._value);
    this._hidden.value = this._value;
  },
  get_value: function () { return this._value; },
  set_value: function (v) {
    this._value = Math.max(this._minimum, Math.min(this._maximum, v));
    if (this._thumb) this._render();
  },
  set_minimum: function (v) { this._minimum = v; },
  set_maximum: function (v) { this._maximum = v; }
};
MyControls.Slider.registerClass('MyControls.Slider', Sys.UI.Control);

Перетаскивание и подключение на странице

Перетаскивание мышью добавляется по той же схеме: на mousedown по бегунку подписываемся на mousemove и mouseup документа, переводим координату курсора в долю ширины дорожки и вызываем set_value. На mouseup обработчики снимаются. Для сенсорных экранов те же действия повторяются для событий touchstart, touchmove и touchend.

На странице элемент используется как любой другой: регистрируется директивой Register, получает свойства в разметке, а событие ValueChanged обрабатывается в коде. Внутри UpdatePanel изменение значения можно отправлять асинхронно, вызывая __doPostBack из клиентского класса после отпускания бегунка.

Как это делают сегодня

Сегодня для ползунка хватает нативного input type="range": он доступен с клавиатуры, работает на сенсорных экранах и стилизуется через CSS (accent-color или псевдоэлементы бегунка). Если нужен двойной ползунок или особое поведение, пишут компонент Blazor с параметрами и EventCallback либо подключают библиотеку вроде noUiSlider. Модель ScriptControl и Sys.UI.Control сохраняется только в сопровождаемых проектах Web Forms.

Вопросы и ответы

Почему значение ползунка не приходит на сервер?

Проверьте, что скрытое поле получило name, равный UniqueID элемента, а класс реализует IPostBackDataHandler. Без этого LoadPostData не будет вызван.

Нужен ли ScriptManager на странице?

Да, ScriptControl регистрирует свои скрипты и дескрипторы через ScriptManager. Без него на этапе PreRender будет исключение.

Почему внутри UpdatePanel растёт потребление памяти?

Клиентский класс не снимает обработчики в dispose. Удаляйте их через $removeHandler, иначе каждый асинхронный постбэк оставляет «висящие» ссылки.

Читайте также