Группировка строк в ASP.NET GridView: RowSpan и строки-заголовки групп

РазделGridViewУровеньСредний Чтение8 минОпубликовано ТегиASP.NETGridViewVB.NET

GridView не умеет группировать строки, но это легко добавить. Покажем два приёма: объединение повторяющихся ячеек через RowSpan и отдельную строку-заголовок над каждой группой с подсчётом итогов.

Главное. GridView группируется вручную: данные сортируются по ключу группы, а затем либо одинаковые ячейки объединяются через RowSpan в DataBound, либо в RowDataBound вставляется строка-заголовок с итогами.

Что значит сгруппировать строки

Отчёт «товары по категориям» в плоском GridView выглядит утомительно: в первой колонке десятки раз подряд повторяется «Напитки», затем столько же раз «Приправы». Глаз пользователя ищет границы групп, а таблица их не показывает. В настольных отчётах эту задачу решает группировка, и её хочется видеть и на веб-странице.

Стандартный GridView группировки не поддерживает, но у нас есть доступ к готовой HTML-таблице до отправки в браузер. Это позволяет либо объединить одинаковые ячейки соседних строк, либо вставить между группами дополнительную строку с названием и итогами. Оба приёма работают поверх обычной привязки данных и не требуют сторонних компонентов.

Главное условие: данные отсортированы

Оба способа сравнивают соседние строки, поэтому данные обязаны прийти отсортированными по полю группировки. Если в выборке «Напитки» встречаются в начале и в конце, получится две отдельные группы. Сортировку надёжнее всего задать в SQL, а не в гриде.

Если на гриде включена пользовательская сортировка по колонкам, группировку придётся отключать для всех колонок, кроме ключа группы, или добавлять ключ первым полем в выражение сортировки DataView: «CategoryName, » плюс выбранная пользователем колонка.

ProductsByCategory.sqlSQL
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.

Report.aspx.vbVB.NET
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
Для TemplateField свойство Text ячейки пустое. Сравнивайте текст вложенного Label через FindControl или значения из DataKeys.

Способ второй: строка-заголовок группы

Объединение ячеек хорошо читается, но плохо переносит чередование цвета строк и длинные группы. Второй приём нагляднее: перед первой строкой каждой группы вставляется отдельная строка во всю ширину таблицы с названием группы. В неё же удобно выводить количество позиций и сумму.

Строку вставляем в RowDataBound. Внутренняя таблица грида доступна как родитель строки данных, а позицию нового ряда даёт индекс текущей строки в этой таблице. Предыдущее значение ключа храним в поле класса.

Report.aspx.vbVB.NET
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 по классу группы.

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

В Blazor группировку делают прямо в разметке: GroupBy в LINQ и вложенный цикл @foreach выводят заголовок и строки группы без всяких трюков с таблицей. QuickGrid группировку не поддерживает, поэтому для интерактивных отчётов берут MudBlazor DataGrid с параметром Groupable или AG Grid с row grouping и агрегатами на клиенте.

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

Почему объединение ячеек нужно делать в DataBound, а не в RowDataBound?

В RowDataBound следующая строка ещё не существует, сравнивать не с чем. DataBound срабатывает после создания всех строк.

Можно ли объединять сразу несколько колонок?

Да, вызовите MergeColumn для каждой, но для вложенных уровней сравнивайте ещё и родительскую колонку, иначе объединятся одинаковые значения из разных групп.

Строки-заголовки пропадают после нажатия кнопки. Почему?

Они добавлены в разметку динамически и не сохраняются в ViewState. Повторите привязку данных на каждом postback.

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