The WPF DataGrid provides a lot of functionalities, but also has many limitations and a very steep learning curve. Writing your own code to display data in rows and columns is surprisingly easy, taking only a few dozens of code lines. So if you ever encounter problems with the DataGrid or don't want to spend days learning how to format it, consider replacing it with your own code. In the past, I wrote a whole article (rating: 4.9 stars) just about how to do formatting with a DataGrid. It's a major headache.
Introduction
I needed to write a financial Balance Sheet window showing the financial numbers of several years, allowing the user to scroll horizontally and vertically, while labels in the leftmost column and topmost row are always displayed, something like this:
As you can see, the grid is "transposed", it has a variable number of columns and a fixed number of rows. With each passing year, one more column gets added. The WPF DataGrid
does not support that out of the box, although there are suggestions on StackOverflow on how to achieve that by rotating the DataGrid
and then rotating each cell back. I preferred a simpler approach. How difficult can it be to write a bunch of numbers on a screen? It's surprisingly easy, as I found out, it took only 30 lines of C# code to display the data and few more lines to cover advanced user interactions:
- Fixed header row and column, i.e., only financial figures get scrolled
- Different formatting of data
- Resizing, i.e., grid makes best use of available screen space
- Grouping of rows, which the user can collapse
- User can drill down to open a new
Window
displaying financial details for that account and year.
This article describes in detail the different steps taken when designing and implementing such a layout.
XAML Code
I know, some people feel the GUI should mostly consist of XAML code and as little C# code as possible. I see this differently. XAML lacks a lot of basic functionality any programming language has, it can't even add 1 + 1. I use XAML only to define content structure, like Grids
containing StackPanels
containing ... and graphic design related matters like fonts and colours. Normally, I would define TextBlocks
also in XAML, but in this project, I create them from code behind.
The XAML code of this project is short and consists only of the main containers in the Balance Sheet Window:
<Window x:Class="TransposedDataGrid.MainWindow"
Title="Balance Sheet" Height="300" Width="500"
Background="SlateGray" FontSize="12">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="auto"/>
</Grid.ColumnDefinitions>
<ScrollViewer Grid.Row="0" Grid.Column="1" x:Name="LabelsTopScrollViewer"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="LabelsTopStackPanel" Orientation="Horizontal"
Background="LightGray"/>
</ScrollViewer>
<ScrollViewer Grid.Row="1" Grid.Column="0" x:Name="LabelsLeftScrollViewer"
VerticalScrollBarVisibility="Hidden" VerticalAlignment="Top">
<StackPanel x:Name="LabelsLeftStackPanel" Background="LightGray"/>
</ScrollViewer>
<ScrollViewer Grid.Row="1" Grid.Column="1" x:Name="DataScrollViewer"
VerticalAlignment="Top" VerticalScrollBarVisibility="Hidden"
HorizontalScrollBarVisibility="Hidden" Background="White">
<StackPanel x:Name="DataStackPanel"
Orientation="Horizontal" Background="white"/>
</ScrollViewer>
<ScrollBar Grid.Row="1" Grid.Column="2" x:Name="VerticalScrollBar"/>
<ScrollBar Grid.Row="2" Grid.Column="1" x:Name="HorizontalScrollBar"
Orientation="Horizontal"/>
</Grid>
</Window>
Scrolling Needs Dictate Containment Structure
The containment structure is defined by the areas in the Window needing to scroll differently. Basically, there are five areas which scroll differently:
- Years label row: scrolls horizontally
- Accounts labels row: scrolls vertically
- Financial data cells: scrolls horizontally and vertically
- Horizontal- and VerticalScrollBar don't get scrolled
Scrolling is easiest done in WPF by using a ScrollViewer
. A ScrollViewer
gives its children unlimited space and has a horizontal and vertical ScrollBar
controlling which part of its content gets displayed. It would be easy to use just one ScrollViewer
for everything, but then the labels would also scroll out of view. However, they have to stay in place, otherwise it is difficult to tell to which account and year a financial figure belongs to.
So I used three ScrollViewers
for each scrollable region and two ScrollBars
with which the user can scroll the financial data.
The next question is, which WPF container should be used to hold the TextBlocks
displaying the data. To my surprise, the simple StackPanel
with Orientation.Vertical
can actually display each row nicely aligned, as long as every data cell uses the same number of text lines. Since the ScrollViewer
offers unlimited space, the content of every cell can be displayed on one line. Of course, a StackPanel
with Orientation.Vertical
displays data automatically vertically aligned (=column).
The creation of the grid content becomes very simple. Here is the pseudo code:
private void fillGrid() {
loop over all account names //i.e. row headers
for each account name, add a TextBlock to LabelsLeftStackPanel
loop over every year //i.e. column
for each year, add a TextBlock to LabelsTopStackPanel //i.e. column headers
for each year, add a YearStackPanel, add it to DataStackPanel
for each financial figure of that year add a TextBlock to YearStackPanel
Isn't this amazingly simple? And the formatting of the TextBlocks
is simple code too, as opposed to XAML where it would take many more lines and where it would be difficult to understand what is going on:
(var padding, var fontWeight) = getFormatting(accountsIndex);
var dataTextBlock = new TextBlock {
Text = data.Accounts[yearIndex, accountsIndex].ToString("0.00"),
Padding = padding,
FontWeight = fontWeight,
TextAlignment = TextAlignment.Right,
Tag = yearIndex*1000 + accountsIndex
};
dataTextBlock.MouseLeftButtonUp += DataTextBlock_MouseLeftButtonUp;
yearStackPanel.Children.Add(dataTextBlock);
Using C# object initializer syntax to set the properties of the TextBlock
makes the syntax look similar to XAML, but with the great advantage that all the functionality of a proper programming language is available. Note the use of the method getFormatting()
which is a kind of replacement of XAML Style
, assigning Padding
and FontWeight
based on some row data.
Event handlers cannot be added as part of the initializer syntax, so it is on its own line of code. DataTextBlock_MouseLeftButtonUp
can be used to offer the user the functionality of getting more information for one particular cell (although I prefer to use ToolTip
for that) or to use it for a "Drill Down", i.e., displaying to the user all the detail data (financial ledger entries) of that account in a new Window
, meaning he can see all the financial statements of that account and year.
Tag is used in DataTextBlock_MouseLeftButtonUp
to identify which data cell was clicked. The actual data can then be easily found in data.Accounts[yearIndex, accountsIndex]
.
Replacing Style with Methods
As you might have noticed, the rows are differently formatted depending on:
- bold if it is a summary account row like Assets
- a bigger margin after the third detail account row, but only if it is not followed by summary account row
Good luck if you try to implement this in XAML with Styles, Triggers and stuff. In code behind, it becomes very easy. I put the code in its own method, because the same formatting is used for row labels and row data, which is similar to declaring a Style
in Resources
:
private (Thickness padding, FontWeight fontWeight) getFormatting(int accountsIndex) {
return Data.RowTypes[accountsIndex] switch {
RowTypeEnum.normal => (new Thickness(2, 1, 2, 1), FontWeights.Normal),
RowTypeEnum.normalLarge => (new Thickness(2, 1, 2, 5), FontWeights.Normal),
RowTypeEnum.total => (new Thickness(2, 1, 2, 7), FontWeights.Bold),
_ => throw new NotSupportedException(),
};
}
In this application, there is a separation of data layer (class Data
) and presentation layer (class MainWindow
). The data layer tells the presentation layer with the RowTypes
property for each row if it is a normal row, a normal row which needs more distance from the next row or a row displaying a total account.
Giving Columns Headers the Same Width as the Data Cells in the Same Column
Initially, I thought it would be quite a challenge to get all the labels and data nicely aligned on the screen, but actually I had to solve only one problem: The column heads displaying only four digits were narrower than the data cells which can display much longer numbers. Because of different scrolling, I could not use one StackPanel
for label and data, meaning I had to force the width of the YearStackPanel
onto the width of the TextBox
displaying that year's label:
private void YearStackPanel_SizeChanged(object sender, SizeChangedEventArgs e) {
var yearStackPanel = (StackPanel)sender;
var textBlock = (TextBlock)yearStackPanel.Tag;
textBlock.Width = yearStackPanel.ActualWidth;
}
Now imagine doing that in XAML, I wouldn't know how. Of course, it would be possible to bind the width
of the YearStackPanel
to the year label's TextBlock
, but many such bindings would be needed and every passing year one more.
Coordinating the Scrolling
One more problem had to be solved. Scrolling with the vertical ScrollBar
needs to be executed in the LabelsLeftScrollViewer
and in the DataScrollViewer
. That might sound easy, but there is one challenge: If the user holds the mouse over the accounts column and scrolls with the mouse wheel the LabelsLeftScrollViewer
, the DataScrollViewer
and the VerticalScrollBar
need to do the same scrolling. Again, this can be easily implemented using event handlers:
private void VerticalScrollBar_ValueChanged
(object sender, RoutedPropertyChangedEventArgs<double> e) {
LabelsLeftScrollViewer.ScrollToVerticalOffset(VerticalScrollBar.Value);
DataScrollViewer.ScrollToVerticalOffset(VerticalScrollBar.Value);
}
private void LabelsLeftScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) {
VerticalScrollBar.Value = e.VerticalOffset;
}
private void DataScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) {
VerticalScrollBar.Value = e.VerticalOffset;
}
Note: Luckily, WPF prevents an endless loop of:
1) VerticalScrollBar_ValueChanged ->
2) DataScrollViewer_ScrollChanged ->
3) VerticalScrollBar_ValueChanged ->
4) ...
In 2), the new value assigned to VerticalScrollBar.Value
is the same as the existing one and WPF will not raise VerticalScrollBar_ValueChanged
again.
There is one more question: which values should the ScrollBar
s use ? How the ScrollBar
works is not easy to explain and I wrote a whole article about that:
The easiest solution is if the VerticalScrollBar
gets set like this:
private void DataStackPanel_SizeChanged(object sender, SizeChangedEventArgs e) {
setScrollBars();
}
private void setScrollBars() {
VerticalScrollBar.LargeChange =
VerticalScrollBar.ViewportSize = DataScrollViewer.ActualHeight;
VerticalScrollBar.Maximum =
DataStackPanel.ActualHeight - LabelsLeftScrollViewer.ActualHeight;
}
Value
: Offset of the DataScrollViewer
, i.e., how many vertical pixels at the top of the financial data grid will not be shown. MinValue
: constant 0. The vertical scrolling starts from the top of the financial data grid, which is pixel 0 of the DataScrollViewer
. ViewPortSize
: is the height of the gray rectangle in the VerticalScrollBar
the user moves up and down for scrolling. I give it the same value as LargeCharge
, which is the number of pixels the financial data grid moves up or down when the user "pages". 1 Page = the number of vertical pixels of the financial data grid displayed on the screen, i.e., height of DataScrollViewer
.
Most confusing is the calculation of the Maximum
. One would think it is the number of pixels needed to display all the financial data grid rows. But that is not correct. Executing DataScrollViewer.ScrollToVerticalOffset(VerticalScrollBar.Value)
would display just the very last row of pixels (!) and most of the DataScrollViewer
would be empty. To set VerticalScrollBar.Maximum
correctly, the maximum value must be "the number of pixels needed to display everything" - "number of pixels displayed on 1 page". If you find this confusing, read my article about ScrollBars
for a more detailed explanation.
Collapsing Rows
There can be hundreds of accounts. It's nice if the window can display them all, but it might also be helpful if the user can collapse some detail rows, like in the following screenshot, the detail rows belonging to Assets:
Just to show how easy it is to add some advanced functionality, I wrote two methods writing the data to the grid:
fillSimpleGrid()
, screenshot at the beginning of this article fillGrid()
, screenshot above
I changed the controls in the label column from simple TextBlocks
into a horizontal StackPanel
containing two TextBlocks
, the first just showing + or - and the second, the actual account name. Detail rows don't show the +-, but the TextBlock
is still invisibly there to ensure that the account name is displayed at the right place.
When the user clicks on +- of a bold row, the financial data cell TextBoxes
of that row get their Visibility
set to Collapsed
. Again, I was surprised how easy it was to implement this.
Disadvantages When Not Using a DataGrid
- The greatest disadvantage in the proposed solution in my view is that you cannot just mark some rows and copy paste it into another application. If data export is important, I would create one button which exports all the data to the Windows
Clipboard
. Or just capture "Ctrl C" and copy everything to the Clipboard
. - A not so obvious disadvantage is that this solution does not support virtualisation. For a huge grid, it would take too much RAM to create for every cell, also the not displayed ones, its own WPF control. When the
DataGrid
scrolls, DataGrid
can use virtualisation to reuse just a small set of WPF controls for display purposes. On the other hand, DataGrid
needs a great number of WPF controls to display a cell: TextBlock
, ContentPresenter
, Border
, DataGridCell
and many more to display rows: DataGridCellsPanel
, ItemsPresenter
, DataGridCellsPresenter
, SelectiveScrollingGrid
, Border
, DataGridRow
and again many more Controls
for the grid. This complicated structure and the virtualisation is also the reason why it is so complicated to format a DataGrid
or to access the data of a cell. The proposed solution here is much leaner, needs therefore less RAM, it's very easy to access any data in the grid and formatting is really simple. For these reasons, it probably can display quite a lot of data even without virtualisation. - Sorting is not supported in the present code. But that could be easily done by making the row headers clickable, using Linq to sort the data and call the
fillGrid()
method again that creates the grid content. Or, if you worry about performance (see virtualisation), you could just overwrite the properties of the already created TextBlocks
. - I recommend this solution for readonly data, but it is very easy to add any WPF control, not just the 4 column types
DataGrid
supports out of the box. If you do it yourself, you can easily avoid annoying problems of the DataGrid
, like that the user has to click several times before he can enter data into a cell. On the other hand, you have to do some additional work, like setting TabIndex
from code behind to ensure that the user can easily move from cell to cell using the Tab button.
Some people might miss the use of databinding. I don't!!! For me, data binding performs too much magic during runtime and it leads to code which is very difficult to understand. The XAML syntax for formatting data is different than C# and rather complicated. The use of Styles, Setters, Converters, Triggers make it hard to understand what is going on, which in code behind might be just a simple if
statement.
It is really mind boggling how difficult simple formatting of a DataGrid
is. Please read my 4.93 stars (40 votes) article to see why that is so and how these difficulties can be overcome:
Recommended Reading
Some other highly rated articles I wrote on CodeProject:
History
- 3rd January, 2022: Initial version