
Пользовательский серверный элемент Slider на ASP.NET AJAX: ScriptControl шаг за шагом
Готовый SliderExtender не всегда подходит по виду и поведению. Разбираем, как написать свой ползунок как полноценный серверный элемент ASP.NET AJAX со свойствами, событием и клиентским классом.
Зачем свой элемент, если есть 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 найдёт значение в форме.
<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Дескрипторы, ресурсы и PostBack
GetScriptDescriptors возвращает ScriptControlDescriptor с именем клиентского класса и свойствами, которые будут переданы ему при создании. GetScriptReferences указывает на JavaScript-файл, встроенный в сборку как ресурс: так элемент распространяется одной DLL. Ресурс нужно отметить атрибутом WebResource на уровне сборки.
LoadPostData сравнивает пришедшее значение с текущим и возвращает True, если оно изменилось. Тогда ASP.NET вызовет RaisePostDataChangedEvent, где и генерируется событие ValueChanged. Значение из формы обязательно проверяется: это пользовательский ввод, и он может быть любым.
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 переводят в крайние положения. Без этого ползунком невозможно пользоваться без мыши.
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 из клиентского класса после отпускания бегунка.
Как это делают сегодня
Вопросы и ответы
Почему значение ползунка не приходит на сервер?
Проверьте, что скрытое поле получило name, равный UniqueID элемента, а класс реализует IPostBackDataHandler. Без этого LoadPostData не будет вызван.
Нужен ли ScriptManager на странице?
Да, ScriptControl регистрирует свои скрипты и дескрипторы через ScriptManager. Без него на этапе PreRender будет исключение.
Почему внутри UpdatePanel растёт потребление памяти?
Клиентский класс не снимает обработчики в dispose. Удаляйте их через $removeHandler, иначе каждый асинхронный постбэк оставляет «висящие» ссылки.