
Группировка строк в ASP.NET GridView: RowSpan и строки-заголовки групп
GridView не умеет группировать строки, но это легко добавить. Покажем два приёма: объединение повторяющихся ячеек через RowSpan и отдельную строку-заголовок над каждой группой с подсчётом итогов.
Что значит сгруппировать строки
Отчёт «товары по категориям» в плоском GridView выглядит утомительно: в первой колонке десятки раз подряд повторяется «Напитки», затем столько же раз «Приправы». Глаз пользователя ищет границы групп, а таблица их не показывает. В настольных отчётах эту задачу решает группировка, и её хочется видеть и на веб-странице.
Стандартный GridView группировки не поддерживает, но у нас есть доступ к готовой HTML-таблице до отправки в браузер. Это позволяет либо объединить одинаковые ячейки соседних строк, либо вставить между группами дополнительную строку с названием и итогами. Оба приёма работают поверх обычной привязки данных и не требуют сторонних компонентов.
Главное условие: данные отсортированы
Оба способа сравнивают соседние строки, поэтому данные обязаны прийти отсортированными по полю группировки. Если в выборке «Напитки» встречаются в начале и в конце, получится две отдельные группы. Сортировку надёжнее всего задать в SQL, а не в гриде.
Если на гриде включена пользовательская сортировка по колонкам, группировку придётся отключать для всех колонок, кроме ключа группы, или добавлять ключ первым полем в выражение сортировки DataView: «CategoryName, » плюс выбранная пользователем колонка.
SELECT c.CategoryName, p.ProductName, p.UnitPrice, p.UnitsInStock
FROM Products AS p
JOIN Categories AS c ON c.CategoryID = p.CategoryID
ORDER BY c.CategoryName, p.ProductName; -- сначала поле группыСпособ первый: объединение ячеек через RowSpan
Идея простая: идём по строкам снизу вверх и сравниваем текст первой ячейки с текстом ячейки в строке ниже. Если он совпадает, верхней ячейке ставим RowSpan на единицу больше, чем у нижней, а нижнюю скрываем. Проход снизу вверх нужен как раз для того, чтобы RowSpan накапливался естественно.
Выполнять это нужно после того, как все строки созданы, то есть в событии DataBound грида, а не в RowDataBound.
Protected Sub gvReport_DataBound(sender As Object, e As EventArgs) _
Handles gvReport.DataBound
MergeColumn(gvReport, 0) ' объединяем колонку категории
End Sub
Private Sub MergeColumn(grid As GridView, col As Integer)
For i As Integer = grid.Rows.Count - 2 To 0 Step -1
Dim cur As TableCell = grid.Rows(i).Cells(col)
Dim nextCell As TableCell = grid.Rows(i + 1).Cells(col)
If cur.Text = nextCell.Text Then
' RowSpan по умолчанию 0, это равно одной строке
cur.RowSpan = If(nextCell.RowSpan < 2, 2, nextCell.RowSpan + 1)
nextCell.Visible = False
cur.VerticalAlign = VerticalAlign.Top
End If
Next
End SubСпособ второй: строка-заголовок группы
Объединение ячеек хорошо читается, но плохо переносит чередование цвета строк и длинные группы. Второй приём нагляднее: перед первой строкой каждой группы вставляется отдельная строка во всю ширину таблицы с названием группы. В неё же удобно выводить количество позиций и сумму.
Строку вставляем в RowDataBound. Внутренняя таблица грида доступна как родитель строки данных, а позицию нового ряда даёт индекс текущей строки в этой таблице. Предыдущее значение ключа храним в поле класса.
Private lastGroup As String = Nothing
Protected Sub gvReport_RowDataBound(sender As Object, _
e As GridViewRowEventArgs) Handles gvReport.RowDataBound
If e.Row.RowType <> DataControlRowType.DataRow Then Return
Dim group = CStr(DataBinder.Eval(e.Row.DataItem, "CategoryName"))
If group = lastGroup Then Return
lastGroup = group
' Новая строка во всю ширину таблицы
Dim tbl = DirectCast(e.Row.Parent, Table)
Dim header As New GridViewRow(-1, -1, DataControlRowType.DataRow, _
DataControlRowState.Normal)
Dim cell As New TableCell()
cell.ColumnSpan = gvReport.Columns.Count
cell.Text = HttpUtility.HtmlEncode(group)
cell.CssClass = "group-head"
header.Cells.Add(cell)
tbl.Rows.AddAt(tbl.Rows.GetRowIndex(e.Row), header)
End SubИтоги по группе
Чтобы вывести в заголовке количество товаров или сумму остатков, их нужно посчитать заранее: в момент вставки заголовка строки группы ещё не привязаны. Самый простой путь: до DataBind пройти по DataTable и собрать словарь Dictionary(Of String, Decimal) с итогами по ключу. Тогда в RowDataBound достаточно дописать к названию группы значение из словаря.
Альтернатива: считать итоги прямо в SQL оконной функцией SUM(...) OVER (PARTITION BY CategoryName) и брать их из первой строки группы. Это избавляет от лишнего прохода по данным и хорошо работает при постраничном выводе, когда на странице видна только часть группы, а итог нужен полный.
Подводные камни
Обе техники меняют только визуальную разметку, поэтому у них есть ограничения, о которых стоит помнить заранее:
- вставленные строки не сохраняются в ViewState, после postback без повторной привязки они исчезнут, поэтому вызывайте DataBind и на postback, если грид должен остаться сгруппированным;
- индексы в Rows не меняются, но в Controls внутренней таблицы появляются лишние строки, учитывайте это, если где-то перебираете строки по Controls;
- при постраничном выводе группа может начаться на одной странице и закончиться на другой, заголовок тогда повторяется на следующей странице, это обычно даже удобно;
- чередование AlternatingRowStyle считается по строкам данных и с заголовками групп может выглядеть неровно, проще задать фон через CSS по классу группы.
Как это делают сегодня
Вопросы и ответы
Почему объединение ячеек нужно делать в DataBound, а не в RowDataBound?
В RowDataBound следующая строка ещё не существует, сравнивать не с чем. DataBound срабатывает после создания всех строк.
Можно ли объединять сразу несколько колонок?
Да, вызовите MergeColumn для каждой, но для вложенных уровней сравнивайте ещё и родительскую колонку, иначе объединятся одинаковые значения из разных групп.
Строки-заголовки пропадают после нажатия кнопки. Почему?
Они добавлены в разметку динамически и не сохраняются в ViewState. Повторите привязку данных на каждом postback.