A column-driven data grid for WinUI 3. A header row and virtualized data rows, with columns declared in XAML or built at run time and rebuilt while the grid is on screen, all laid out from one solved set of widths. Sorting, filtering, frozen columns and copying to the clipboard; read-only, and nothing else: it is the part WinUI is missing rather than a suite.
WinUI 3 ships no data grid, and the reason one cannot be faked with panels is specific:
WinUI 3 has no Grid.SharedSizeGroup. Aligning columns across rows that are measured
independently — which is what virtualization means — needs something that decides the widths once
and hands the same answer to the header and to every row. Without a real grid control you are
writing one Grid per row with hand-computed widths, and keeping the header in step with it.
That is what this library is: a column model, a width solver, a header strip that scrolls with the
body, and as little else as possible. Everything that can be WinUI's is. The body is a
ListView, so vertical virtualization, keyboard navigation, the focus and the automation tree below
the grid are the platform's rather than this library's reimplementation of them.
The first application to use it replaced a commercial grid, and three things it needed are what this one is measured against:
- Columns that are not known at compile time. They come out of a file, so a cell is a key into a
dictionary the row carries —
MappingName="Cells[Height]"— and there is no typed row class to bind to and never will be. - Columns rebuilt while the grid is on screen, on every change of table, with rows already realized. Including the rows scrolled out of sight: a recycled container still holding the previous column set is the classic bug here, and it only shows for the rows nobody was looking at.
- UI Automation that answers. The application is driven by automation during its test sessions,
and the control it replaced is why that hurt — several of its elements published no
InvokePatternat all, so a driver could see a button and had no way to press it.
- Columns in XAML or from code, and the programmatic API is not an afterthought: an application
whose columns come out of a file has no other one.
Columnsis live, and mutating it rebuilds the cells of every realized row. - One property for the width.
Widthis aGridLength, soWidth="40",Width="Auto"andWidth="*"all go in the same slot — no global width mode with a per-column override to keep in step with it. Fixed widths are served first, automatic ones are measured against the rows that have actually been realized, and the rest is split by star weight, each column held to its ownMinWidthandMaxWidth. If it does not fit, the grid scrolls sideways rather than squeezing a column into nothing. - The widths are solved once per layout pass and handed to the header and to every row, which is what makes a column a column when the platform has no shared size scope.
- Vertical virtualization, because the body is a
ListView. A hundred thousand rows cost what thirty do; the gallery has a page that builds exactly that many. - Cells addressed by name, through an indexer if that is what the row has.
Name,Geometry.LengthandCells[Height]are all mapping names, they chain, and a name nothing on the row answers to leaves the cell empty rather than throwing. - Template columns that keep their page. A
DataTemplatedeclared on the page that uses the grid can reach the row with{x:Bind}and a command on that page with{Binding …, ElementName=Root}, because the grid puts no XAML namescope of its own between the two. It is the integration detail a grid is most likely to get wrong, and there is a gallery page that does nothing but exercise it. - The header scrolls sideways with the body and never vertically, and column edges can be dragged. A dragged column stops being a star column and keeps the width it was given.
- Sorting by pressing a header: ascending, descending, and a third press back to the order the collection gives them — because that order often means something. A column sorts by its value and not by its text, so a column of numbers drawn with two decimals orders as numbers and a column of dates orders as dates. Blanks gather at one end, numbers of different types compare as numbers, and values with nothing in common are compared rather than throwing under somebody's finger.
- Filtering through a predicate of yours, because only your application knows what searching its
rows means.
VisibleRowCountis how many got through. - Frozen columns, so that scrolling a wide table sideways does not leave you reading values whose rows you can no longer identify.
- Copying:
Ctrl+Cputs the selected rows on the clipboard as text a spreadsheet pastes as a table — every column, including the ones scrolled out of sight, quoted properly when a value holds a tab or a line break. - Selection that drives the panel next to it.
SelectionChangedcarries the record;SelectedItemis settable from code, toleratesnull, and is cleared when the rows are replaced — because a panel showing what a row stands for has to be told when that row has gone. Several rows at a time withSelectionMode="Multiple", whereSelectedItemis still the first of them so that a panel following one row goes on working. CellTappedandCellDoubleTappedcarry the record, not just an index, so an application that acts on the thing a row stands for does not have to look it up again.- Something to draw when there is nothing to draw, including when there are no columns. A file carrying geometry and no fields at all is a real case, and rows that are there and invisible read as a control that has crashed.
- UI Automation that works: rows are data items with
SelectionItemPatternand a name built from their own values, a button in a cell template answers toInvokePattern, and column headers are header items named after their columns. - Light, dark and high contrast, following the system, with the row states aliased to the
ListViewItembrushes so that a selected row here looks like a selected item in every other list in the application. - Right to left: the columns flip with
FlowDirection, and there is nothing to configure.
- Windows App SDK 1.8 or later.
- .NET 8 or later.
- Windows 10 version 1809 (build 17763) or later.
dotnet add package Digi21.WinUI.Grid
<Page
x:Name="Root"
xmlns:grid="using:Digi21.WinUI.Grid"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Page.Resources>
<!-- A cell template declared here keeps this page's names: ElementName below reaches
Root, and x:Bind reaches the row. -->
<DataTemplate x:Key="DeleteButtonTemplate" x:DataType="local:Row">
<Button
Command="{Binding DataContext.Delete, ElementName=Root}"
CommandParameter="{x:Bind}"
Content="Delete" />
</DataTemplate>
</Page.Resources>
<grid:DataGrid
ItemsSource="{x:Bind Rows}"
SelectedItem="{x:Bind Selected, Mode=TwoWay}"
SelectionMode="Single"
CellDoubleTapped="OnCellDoubleTapped">
<!-- An icon column that never moves, and three that share what is left. -->
<grid:DataGridTemplateColumn
CellTemplate="{StaticResource DeleteButtonTemplate}"
HeaderText=""
Width="40" />
<grid:DataGridTextColumn HeaderText="Name" MappingName="Name" Width="Auto" />
<grid:DataGridTextColumn HeaderText="Kind" MappingName="Kind" Width="*" />
<grid:DataGridTextColumn
HeaderText="Length"
Format="F2"
MappingName="Geometry.Length"
TextAlignment="Right"
Width="*" />
</grid:DataGrid>
</Page>Three things in there are worth pointing at. Width takes all three kinds in one slot — pixels,
Auto, a star weight — so the icon column and the data columns need no second property between
them. MappingName is a path against the row, and it reads through indexers as well as properties.
And SelectedItem needs Mode=TwoWay written out: WinUI has no way for a dependency property to ask
for two-way binding on your behalf, so this one cannot.
The programmatic API is the same API. An application whose columns come out of a file has no other one, and it runs this again on every change of table:
grid.Columns.Clear();
if (canDelete)
{
grid.Columns.Add(new DataGridTemplateColumn
{
HeaderText = string.Empty,
Width = new GridLength(40),
CellTemplate = (DataTemplate)Resources["DeleteButtonTemplate"],
});
}
foreach (Field field in table.Fields)
{
grid.Columns.Add(new DataGridTextColumn
{
HeaderText = field.Name,
MappingName = $"Cells[{field.Name}]",
});
}Every realized row is rebuilt when that runs, including the ones scrolled out of view.
Every colour the grid paints with is an alias of a WinUI system brush, so it follows the accent colour, both themes and high contrast on its own. Redeclare a key to change one:
<SolidColorBrush x:Key="DataGridHeaderBackgroundBrush" Color="#102A43" />The row states are aliased to the ListViewItem brushes on purpose. That is what makes a grid
dropped into a Digi21.WinUI.Docking pane, in an application with a Digi21.WinUI.Ribbon, agree
with everything around it about what selected looks like — without a single override.
docs/theming.md has the full list of keys, where an override has to go, how to retemplate a control, and what is deliberately not a key.
The grid does not translate what you put in it: a header, a cell, a template are yours and arrive already in the user's language, because only your application knows what it is saying.
What the grid says on its own account is three sentences, and one of them is never seen — it is what a screen reader is told a row is. All three are resource keys, so they are redefined exactly the way a colour is:
<x:String x:Key="DataGridEmptyText">No hay nada que mostrar</x:String>
<x:String x:Key="DataGridRowAutomationNameFormat">Fila {0}, {1}</x:String>
<x:String x:Key="DataGridColumnResizeName">Cambiar el ancho de la columna</x:String>- Columns — the column model:
MappingNameand its three forms, how the widths are solved, whatAutois measured against, and what a cell template can reach from inside a cell. - Theming — every brush, metric, text style and string key, where an override has to go, and what is deliberately not one.
- The control tree — every
PART_the code looks up, what it does with each, and what a replacement template has to keep. - UI Automation — what a driver out of process sees, how a row is named, and what is deliberately not published.
The shape is meant not to rule them out, but none of these is being built now, and a grid that had all of them would be a suite rather than the part WinUI is missing:
- Editing. This is read-only, and there is no editing path in it at all — not a
TextBoxin a cell, not a commit, not a write back through a mapping name. - Grouping.
- Row details and summary rows.
- Export. With copying to the clipboard done, exporting is the application's: it is the only one that knows which format and which names.
- Cell-level selection. Rows are what is selected, whole.
- Dragging a column to reorder it.
- Tree or hierarchical rows.
- Sorting by more than one column at once.
GridPatternandTablePatternfor UI Automation, which would be claiming addressable cells.- Column virtualization. Every column of a realized row is an element, which is the ceiling left in the design; the rows are what virtualizes.
- Rows of different heights. One height for all of them is what lets the list virtualize by arithmetic instead of by measuring rows it has not built.
samples/GridGallery shows what the control can do — columns rebuilt at run time, a template column
with a command, mapping through an indexer, sorting and filtering, frozen columns, selection and
copying, a hundred thousand rows, theming, translation, the empty state and a page that walks the
automation tree of a live grid:
dotnet run --project samples/GridGallery
It is a demonstration, not a test bench: named bug reproductions and the measurements that catch regressions live in a separate, private harness, so that what a prospective user runs is only the library showing itself off.
Issues and pull requests are welcome — see CONTRIBUTING.md. What changes between versions is recorded in CHANGELOG.md.