Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

Avoid the WPF DataGrid Limitations, Replace It with Few Lines of Your Own Code

4.95/5 (8 votes)
3 Jan 2022Public Domain11 min read 16.9K   339  
A deep dive into how to easily display data exactly the way you want when DataGrid is not up to the task
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:

Image 1

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:

XAML
<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

Image 2

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:

C#
(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:

C#
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:

C#
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:

C#
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 ScrollBars 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:

C#
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:

Image 3

Just to show how easy it is to add some advanced functionality, I wrote two methods writing the data to the grid:

  1. fillSimpleGrid(), screenshot at the beginning of this article
  2. 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

License

This article, along with any associated source code and files, is licensed under A Public Domain dedication