Предисловие
В данной статье будет рассмотрена возможность создания собственного элемента управления и его использования в рамках нового проекта на ASP .NET MVC 3.0. Все написанное ниже является точкой зрения автора и может не совпадать с распространенными или общепризнанными методами создания контролов, поэтому критика и комментарии приветствуются.
Введение
Вероятно, многие пользователи, которые ранее работали в своих проектах с WinForms или ASP .NET WebForms, замечали, что Html хелперы в проекте ASP .NET MVC не предоставляют возможность создания такого элемента управления, как CheckBoxList, который мог бы быть полезен в сложных формах фильтров данных или при множественном выборе не структурированных данных, будь то в профиле о пользователе или при добавлении нового топика на хабрахабр. Конечно, никто не запрещает использовать одиночный CheckBox или CheckBoxFor, но будет ли работа с такой группой чекбоксов удобной, а код легко расширяемым, понятным сопровождающему и защищенным от дублирования,- это далеко не последние вопросы для программиста, который планирует использовать свои наработки в будущих проектах. А если принять во внимание, что мы можем добавить к нашему элементу управления некоторые полезные опции для его визуального отображения, то необходимость его создания становится все более и более очевидной.
Перед тем, как окончательно погрузиться в предметную область, хотелось бы отметить, что идея создания рассматриваемого элемента управления была навеяна после просмотра сайта http://mvc.devexpress.com, на котором присутствует множество красивых и функциональных контролов, часть из них и вовсе выглядит потрясающе. Если данная статья будет одобрена и встречена положительными отзывами, то возможны дальнейший обзор и реализация представленных на сайте элементов управления в рамках цикла статей для хабра сообщества.
Анализ
Для начала, нам нужно понять, что же мы всё таки планируем сделать. Элемент управления будет представлен группой CheckBox'ов, который позволяет выводить переданные ему коллекции данных, использует концепцию Model Binding при отправке формы на сервер и имеет следующие настройки отображения:
- Layoutt — каркас (в div или table);
- Direction- направление вывода (по вертикали/горизонтали);
- RepeatColumns — кол-во колонок;
- htmlAttributes — атрибуты, которые будут применяться к контейнеру CheckBox'ов.
Создание проекта
Создадим пустой ASP .NET MVC 3 проект и добавим к нему контроллер DemoController с методом и представлением Index:
После того, как минимальные приготовления выполнены, переходим непосредственно к элементу управления.
Добавление элемента CheckBoxList
Добавляем в корень проекта папку Core с файлом Controls.cs и статическим классом Controls, в котором будет размещена реализация нашего CheckBoxList'a.
На данный момент, в статическом классе Controls мы уже можем создавать собственные реализации Html хелперов, поэтому добавим необходимое для этого кол-ва кода:
public static MvcHtmlString CheckBoxList(this HtmlHelper helper)
{
return new MvcHtmlString("Hello, i'm your CheckBoxList!");
}
И попытаемся его вызвать в нашем представлении Index, контроллера DemoController.
Примечание: Я намеренно не пишу в тексте статьи о подключаемых сборках и файлах с областью видимости, т.к. надеюсь, что Вы сможете это сделать и без моих упоминаний. Единственное что стоит указать, так это добавление строки <add namespace="MVC3Controls_H.Core"/> в файл /Views/web.config, где MVC3Controls_H.Core, это расположение Вашего статического класса с CheckBoxList'ом.
Для вызова нашего элемента, добавляем в Index.cshtml (если Вы указали Razor Engine при добавлении нового представления) следующую строку:
@Html.CheckBoxList()
и укажем в файле Global.asax, который находится в корне проекта, что по умолчанию требуется вызывать контроллер Demo, метод Index:
routes.MapRoute(
"Default", // Route name
"{controller}/{action}/{id}", // URL with parameters
new { controller = "Demo", action = "Index", id = UrlParameter.Optional } // Parameter defaults
);
Если все действия были сделаны правильно, то Вы увидите страницу следующего содержания:
Отлично. Теперь переходим к реализации структуры нашего CheckBoxList'a.
Реализации структуры CheckBoxList
Прежде всего, добавим настройки его отображения в файл Controls.cs. В нашем случае, это будут 3 объекта типа enum:
public enum Layoutt
{
Table = 0,
Flow = 1
}
public enum Direction
{
Horizontal = 0,
Vertical = 1
}
public enum RepeatColumns
{
OneColumn = 1,
TwoColumns = 2,
ThreeColumns = 3,
FourColumns = 4,
FiveColumns = 5
}
И класс CheckBoxList_Settings, в котором мы и будем их передавать в вызове нашего элемента управления:
public class CheckBoxList_Settings
{
public string cbl_Name = "SelectedCheckBoxListItems";
public Layoutt cbl_Layout = Layoutt.Table;
public Direction cbl_Direction = Direction.Horizontal;
public RepeatColumns cbl_RepeatColumns = RepeatColumns.FiveColumns;
}
В качестве настроек по умолчанию мы выберем каркас- таблица, направление вывода- по горизонтали и отображение в виде пяти колонок. Свойство cbl_Name мы рассмотрим позже.
Изменяем конструктор нашего CheckBoxList'а, чтобы он принимал коллекцию типа IDictionary<string, int> и экземпляр класса настроек:
public static MvcHtmlString CheckBoxList(this HtmlHelper helper, IDictionary<string, int> items, CheckBoxList_Settings settings)
{
return new MvcHtmlString("Hello, i'm your CheckBoxList!");
}
Мы пришли к тому, что в представлении нам теперь требуется не только вызвать наш элемент, но и передать ему 2 параметра, для этого добавим в Index метод нашего контроллера коллекцию данных:
Dictionary<string, int> languages = new Dictionary<string, int>
{
{"ActionScript", 0}, {"Delphi", 1}, {"GO", 2}, {"Lua", 3},
{"Prolog", 4}, {"Basic", 5}, {"Eiffel", 6}, {"Haskell", 7},
{"Objective-C", 8}, {"Python", 9}, {"C", 10}, {"Erlang", 11},
{"Java", 12}, {"Pascal", 13}, {"Ruby", 14}, {"C++", 15},
{"F#", 16}, {"JavaScript", 17}, {"Perl", 18}, {"Scala", 19},
{"C#", 20}, {"Fortran", 21}, {"Lisp", 22}, {"PHP", 23}
};
И предоставим нашему представлению эти данные, как модель:
return View(languages);
В коде нашего представления указываем, что моделью для него является тип данных Dictionary<string, int> и вызываем наш CheckBoxList:
@model Dictionary<string, int>
@using MVC3Controls_H.Core
@{
ViewBag.Title = "Index";
}
<h2>Index</h2>
@Html.CheckBoxList(Model, new CheckBoxList_Settings
{
cbl_Name = "SelectedCheckBoxListItems",
cbl_Layout = Layoutt.Table,
cbl_Direction = Direction.Horizontal,
cbl_RepeatColumns = RepeatColumns.FiveColumns
})
Запускаем проект и видим ту же привычную для нас запись:
Анализ текущей структуры приложения
Прежде чем идти далее, нам потребуется разобраться с текущей структурой приложения и возможно изменить её. Вот на чём я хотел бы остановиться:
- На данный момент мы передаём в наше представление модель типа Dictionary<string, int>, однако в большинстве случаев, в приложении используются и другие данные, передаваемые из контроллера, будь то мета-теги (title, description) страницы или сущности (регистрация, форма заказа),- следовательно мы не должны монополизировать модель исключительно для нашего элемента управления. Кроме того, нам потребуется где-то хранить и получать выбранные пользователем значения нашего элемента.
- Предположим, что с течением времени, в нашем проекте CheckBoxList будет вызываться во всё большем и большем кол-ве представлений и в какой-то момент, нам потребуется добавить новый параметр или изменить существующие. В этом случае придется изменять все вызовы нашего элемента управления (во всех представлениях проекта), а это не совсем удобно.
Из сказанного выше следует, что нам потребуется:
- Добавить модель для представления (ViewModel) и передавать коллекцию для отображения в CheckBoxList'e как часть этой модели, наравне с другими данными.
- Перенести вызов нашего элемента управления из представления в partial view, чтобы в случае изменения конструктора или параметров, нам потребовалось изменить вызов для всех используемых в нашем проекте элементов всего в одном месте.
- Вынести передаваемые значения CheckBoxList'у в отдельную модель данных для нашего элемента управления и передавать её в наш partial view, чтобы максимально изолировать наши представления от изменений вызова элемента.
Примечание: Эти и многие другие вопросы следует учитывать и задавать себе до того, как Вы начинаете писать код приложения, однако, т.к. эта статья рассчитана на тех, кто впервые пробует сделать нечто подобное, то я постараюсь последовательно привести Вас к их решению методом проб и ошибок.
Добавление модели представления
Так как наша модель представления служит не только для того, чтобы в ней передавать данные, но и чтобы их получать, то нам потребуется кроме передаваемой коллекции, так же добавить и массив выбранных элементов. Добавим в папку Models папку ViewModels, затем добавим в ViewModels файл ViewModel_Index.cs как показано ниже:
и код нашей модели представления:
public class ViewModel_Index
{
public IDictionary<string, int> Languages { get; set; }
public int[] SelectedCheckBoxListItems { get; set; }
}
Index метод нашего DemoController'a изменится следующий образом:
public ActionResult Index()
{
Dictionary<string, int> languages = new Dictionary<string, int>
{
{"ActionScript", 0}, {"Delphi", 1}, {"GO", 2}, {"Lua", 3},
{"Prolog", 4}, {"Basic", 5}, {"Eiffel", 6}, {"Haskell", 7},
{"Objective-C", 8}, {"Python", 9}, {"C", 10}, {"Erlang", 11},
{"Java", 12}, {"Pascal", 13}, {"Ruby", 14}, {"C++", 15},
{"F#", 16}, {"JavaScript", 17}, {"Perl", 18}, {"Scala", 19},
{"C#", 20}, {"Fortran", 21}, {"Lisp", 22}, {"PHP", 23}
};
ViewModel_Index _ViewModel_Index = new ViewModel_Index
{
Languages = languages
};
return View(_ViewModel_Index);
}
Так как мы изменили передаваемую модель в контроллере, то это так же необходимо сделать и в связанном с ним представлении:
@using MVC3Controls_H.Models.ViewModels
@using MVC3Controls_H.Core
@model ViewModel_Index
@{
ViewBag.Title = "Index";
}
<h2>Index</h2>
@Html.CheckBoxList(Model.Languages, new CheckBoxList_Settings
{
cbl_Name = "SelectedCheckBoxListItems",
cbl_Layout = Layoutt.Table,
cbl_Direction = Direction.Horizontal,
cbl_RepeatColumns = RepeatColumns.FiveColumns
})
Таким образом, если Вам потребуется кроме коллекции данных для нашего элемента управления, передать что-то ещё на страницу, то Вы сможете сделать это, просто изменив модель представления /Models/ViewModels/ViewModels_Index.cs (дополнив её новыми свойствами) и добавив их инициализацию в методе контроллера.
Инкапсуляция вызова нашего CheckBoxList
В своей первой статье, я уже применял подобную схему вызова расширяющего метода, однако для несколько других целей. В текущем же проекте, для того, чтобы вынести вызов нашего CheckBoxList'a в Partial View мы добавим в папку /Views папку Partial, в неё папку Controls, а в папку Controls новое частичное представление CheckBoxList.cshtml:
Теперь структура нашего приложения выглядит следующим образом:
Изменяем наше Index представление, вызывая вместо CheckBoxList'a наше Partial View:
@using MVC3Controls_H.Models.ViewModels
@using MVC3Controls_H.Core
@model ViewModel_Index
@{
ViewBag.Title = "Index";
}
<h2>Index</h2>
@Html.Partial("~/Views/Partial/Controls/CheckBoxList.cshtml")
Добавление модели данных для CheckBoxList
Так как мы планируем перенести вызов нашего элемента управления в наш Partial View, то туда же нужно передавать и данные для него,- следовательно, нам необходимо добавить для него модель данных. В папке Models создадим новую папку CheckBoxList, в неё добавим класс CheckBoxList_Model.cs:
public class CheckBoxList_Model
{
public IDictionary<string, int> items;
public CheckBoxList_Settings settings;
}
Изменим в нашем Index представлении вызов Partial View, передавая в качестве данных добавленную модель:
@Html.Partial("~/Views/Partial/Controls/CheckBoxList.cshtml", new CheckBoxList_Model
{
items = Model.Languages,
settings = new CheckBoxList_Settings
{
cbl_Name = "SelectedCheckBoxListItems",
cbl_Layout = Layoutt.Table,
cbl_Direction = Direction.Horizontal,
cbl_RepeatColumns = RepeatColumns.FiveColumns
}
})
и определим эту модель, как модель для нашего Partial View в /Views/Partial/Controls/CheckBoxList.cshtml, вызывая и передавая значения в наш элемент управления:
@model CheckBoxList_Model
@Html.CheckBoxList(Model.items, Model.settings)
На этом со структурой приложения покончено. Рассмотрим основные её моменты:
Хорошая новость заключается в том, что мы наконец-то закончили со структурой нашего приложения, а плохая- мы ещё практически ничего не написали про сам наш CheckBoxList.
Реализация CheckBoxList
Начнём с того, что опишем общий алгоритм построения нашего элемента:
1. Выбор обрамляющего HTML тега (table, div) на основе параметра cbl_Layout.
2. Определение кол-ва итераций по коллекции на основе параметра cbl_RepeatColumns и кол-ва записей в коллекции.
3. Формирование условия выборки элементов из коллекции на основе порядкового номера текущей итерации:
3.1. Если вывод осуществляется по горизонтали, а кол-во колонок = 4, то первая строка должна состоять из первых 4-х элементов коллекции идущих по порядку, вторая из следующих 4х и т.д.:
[0 1 2 3] - 0 итерация
[4 5 6 7] - 1 итерация
[8 9 10 11]
3.2. Если вывод осуществляется по вертикали, кол-во колонок = 4 а всего элементов 24, то первая строка должна состоять (!) из каждого шестого элемента коллекции, вторая из № итерации + 6 и т.д.:
[0 6 12 18] - 0 итерация
[1 7 13 19] - 1 итерация
[2 8 14 20]
[3 9 15 21]
[4 10 16 22]
[5 11 17 23]
3.3. Не стоит забывать при вычислении кол-ва итераций (кол-во элементов / кол-во колонок) про остаток от деления != 0. В этом случае нам понадобится лишняя итерация для вывода всей коллекции.
[0 1 2 3] - 0 итерация
[4 5 6 7] - 1 итерация
[8 9 10 11]
[12 13 - -]
4. Формирование строки из подходящих под условие выборки на каждой итерации, в зависимости от обрамляющего тега (div, table):
4.1. Для div это будут только переносы в конце строки.
4.2. Для table это:
4.2.1.
5. Закрытие обрамляющего тега и возврат сформированной строки в представление.
Так как основной мыслью статьи не являются код и алгоритмы, то я приведу только часть функций,- остальное Вы сможете увидеть скачав проект в конце статьи, остановившись разве что чуть поподробнее на формировании разметки CheckBox'a:
Формирование HTML разметки CheckBox'a и Model Binding
В общем виде, это самый простой код, которому передаётся элемент коллекции и настройки нашего элемента управления. Пожалуй главным в этом коде будет то, что атрибуту name присваивается имя всего нашего CheckBoxList'a. Это сделано для того, чтобы в дальнейшем, при получении данных на стороне сервера в нашем методе Index контроллера DemoController мы смогли получить выбранные значения в виде сформированного массива,- чуть позже я покажу это на примере.
public static string GenerateHtmlMarkup_CheckBox(KeyValuePair<string, int> item, CheckBoxList_Settings settings)
{
TagBuilder tagBuilder = new TagBuilder("input");
tagBuilder.MergeAttribute("type", "checkbox");
tagBuilder.MergeAttribute("name", settings.cbl_Name);
tagBuilder.MergeAttribute("value", item.Value.ToString());
return tagBuilder.ToString(TagRenderMode.SelfClosing);
}
Формирование HTML разметки Label'a
public static string GenerateHtmlMarkup_Label(KeyValuePair<string, int> item)
{
TagBuilder tagBuilder = new TagBuilder("label");
tagBuilder.SetInnerText(item.Key);
return tagBuilder.ToString(TagRenderMode.Normal);
}
Вычисление кол-ва итераций:
int iMod = items.Count % (int)settings.cbl_RepeatColumns;
int iterationsCount = items.Count / (int)settings.cbl_RepeatColumns + (iMod == 0 ? 0 : 1);
Условие выборки для каждой итерации:
foreach (KeyValuePair<string, int> item in items.Where((item, index) =>
settings.cbl_Direction == Direction.Horizontal ?
index / (int)settings.cbl_RepeatColumns == i
:
(index - i) % iterationsCount == 0))
где i — номер итерации.
При выводе по горизонтали мы берём элементы, индексы которых при делении на кол-во колонок == i:
[0 1 2 3]
[4 5 6 7]
[8 9 10 11]
При выводе по вертикали мы берём элементы, остаток от деления индекса которого и текущей итерации на кол-во итераций == 0:
[0 6 12 18]
[1 7 13 19]
Проверка работоспособности
Давайте на примере 3х CheckBoxList'ов посмотрим, что же в итоге мы получили:
<h2>Index</h2>
@using (Html.BeginForm())
{
@Html.Partial("~/Views/Partial/Controls/CheckBoxList.cshtml", new CheckBoxList_Model
{
items = Model.Languages,
settings = new CheckBoxList_Settings
{
cbl_Name = "SelectedCheckBoxListItems",
cbl_Layout = Layoutt.Table,
cbl_Direction = Direction.Horizontal,
cbl_RepeatColumns = RepeatColumns.FiveColumns
},
htmlAttributes = new { @cellpadding = "0", @cellspacing = "0" }
})
<br />
@Html.Partial("~/Views/Partial/Controls/CheckBoxList.cshtml", new CheckBoxList_Model
{
items = Model.Languages,
settings = new CheckBoxList_Settings
{
cbl_Name = "SelectedCheckBoxListItemsTwo",
cbl_Layout = Layoutt.Flow,
cbl_Direction = Direction.Vertical,
cbl_RepeatColumns = RepeatColumns.ThreeColumns
}
})
<br />
@Html.Partial("~/Views/Partial/Controls/CheckBoxList.cshtml", new CheckBoxList_Model
{
items = Model.Languages,
settings = new CheckBoxList_Settings
{
cbl_Name = "SelectedCheckBoxListItemsThree",
cbl_Layout = Layoutt.Flow,
cbl_Direction = Direction.Horizontal,
cbl_RepeatColumns = RepeatColumns.TwoColumns
}
})
<br />
<input type="submit" value="send" />
}
Для того, чтобы получить выбранные значения, нам потребуется в модель нашего представления ViewModel_Index добавить 3 массива с именами, которые мы передаем для наших CheckBoxList'ов:
public class ViewModel_Index
{
public IDictionary<string, int> Languages { get; set; }
public int[] SelectedCheckBoxListItems { get; set; }
public int[] SelectedCheckBoxListItemsTwo { get; set; }
public int[] SelectedCheckBoxListItemsThree { get; set; }
}
а так же добавить метод контроллера Index, обрабатывающий post запросы:
[HttpPost]
public ActionResult Index(ViewModel_Index model)
{
//TODO:
return View();
}
Поставив точку останова, посмотрим на результат:
Если же Вам абсолютно безразличны дублирование кода, модели представлений и сопровождение, то Вы так же просто можете вызвать непосредственно сам CheckBoxList в представлении и получить выбранные значения на сервере прямо в массиве (однако стоит не забывать о том, что имя CheckBoxList'a и принимаемый параметр на сервере должны быть одинаковы):
Что же, теперь, когда мы полностью закончили то, что планировали- осталось последнее. Рассмотреть возможность расширения нашего элемента управления новыми свойствами на примере object htmlAttributes.
Расширяем CheckBoxList
Для того, чтобы передать новое свойство в код нашего элемента, мы должны пройтись от конструктора, до начального вызова (или наоборот, как кому больше нравится). Всё начинается с нашего конструктора в Controls.cs — добавляем новый параметр object htmlAttributes:
public static MvcHtmlString CheckBoxList(this HtmlHelper helper, IDictionary<string, int> items, CheckBoxList_Settings settings, object htmlAttributes)
Вызов нашего конструктора осуществляется в Partial View с именем CheckBoxList.cshtml. Добавляем новое свойство для модели CheckBoxList'a:
public class CheckBoxList_Model
{
public IDictionary<string, int> items;
public CheckBoxList_Settings settings;
public object htmlAttributes;
}
И передадим его при вызове в нашем Partial View:
До:
@model CheckBoxList_Model
@Html.CheckBoxList(Model.items, Model.settings)
После:
@model CheckBoxList_Model
@Html.CheckBoxList(Model.items, Model.settings, Model.htmlAttributes)
Осталось добавить саму инициализацию свойства в Index представлении и дополнить код нашего элемента:
@Html.Partial("~/Views/Partial/Controls/CheckBoxList.cshtml", new CheckBoxList_Model
{
items = Model.Languages,
settings = new CheckBoxList_Settings
{
cbl_Name = "SelectedCheckBoxListItemsThree",
cbl_Layout = Layoutt.Table,
cbl_Direction = Direction.Vertical,
cbl_RepeatColumns = RepeatColumns.FourColumns
},
htmlAttributes = new { @border = "3", style = "color: Grey; border-style:dashed;" }
})
Код выбора каркаса (table, div):
public static TagBuilder GenerateHtmlMarkup_OuterTag(Layoutt cbl_Layout, IDictionary<string, object> htmlAttributes)
{
...
TagBuilder tagBuilder = new TagBuilder(htmlTag);
tagBuilder.MergeAttributes(htmlAttributes);
...
}
Как можно увидеть ниже, всё работает:
Заключение
В качестве заключения, хотелось бы понять, что же мы всё таки сделали:
- Рассмотрели возможность создания, использования и расширения собственного элемента управления на базе Html хелперов ASP .NET MVC;
- Создали каркас проекта для дальнейших разработок элементов управления;
- Реализовали структуру веб-приложения с разделением обязанностей каждой функциональной единицы;
- Узнали про сайт, с которым есть в чём посоревноваться при написании собственных элементов управления.
Список материалов
1. http://mvc.devexpress.com/Editors/CheckBoxList
2. How to handle checkboxes in ASP.NET MVC forms
3. Исходный код проекта