Wednesday, September 12, 2018

Poor man's DataGrid with footers

I had a new requirement given to me yesterday to add column footers to a complex DataGrid that has a lot of complex bindings and styles. I didn't want to rewrite it to use an Infragistic's XamDataGrid because while they do support footers they have so many other limitations. I needed a simple way to add footers to a DataGrid that doesn't support footers. In the end, the solution wasn't too bad. It involves putting a panel below the DataGrid that contains the footers, then synchronizing the horizontal scroll offsets of the DataGrid and the panel.

While I was at it, I included support for frozen columns. We will be working in C# today.

Start by creating a new WPF project in C# called CheapDataGridFooters. I'm not going to support INotifyPropertyChanged because I want to focus on the scrolling functionality.

We will create a scrolling DataGrid that doesn't have footers. Later, we will add the footers.



The XAML and C# look like this. The LineCount etc. properties will be used later.


<Window x:Class="CheapDataGridFooters.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:CheapDataGridFooters"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525"
        DataContext="{Binding RelativeSource={RelativeSource Self}}">
    <Grid>
        <DataGrid ItemsSource="{Binding Items}" AutoGenerateColumns="False" HorizontalScrollBarVisibility="Auto" FrozenColumnCount="1" IsReadOnly="True">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Description" Binding="{Binding Description}" Width="200"/>
                <DataGridTextColumn Header="Sub Total" Binding="{Binding SubTotal}" Width="100"/>
                <DataGridTextColumn Header="Tax" Binding="{Binding Tax}" Width="100"/>
                <DataGridTextColumn Header="S&amp;H" Binding="{Binding SH}" Width="100"/>
                <DataGridTextColumn Header="Other" Binding="{Binding Other}" Width="100"/>
                <DataGridTextColumn Header="Total" Binding="{Binding Total}" Width="100" IsReadOnly="True"/>
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

---------------------------------------------------------------------


using System;

using System.Collections.Generic;
using System.Linq;
using System.Windows;


namespace CheapDataGridFooters
{
    public partial class MainWindow : Window
    {
        public class cLine
        {
            public String Description { get; set; }
            public Decimal SubTotal { get; set; }
            public Decimal Tax { get; set; }
            public Decimal SH { get; set; }
            public Decimal Other { get; set; }
            public Decimal Total { get; set; }
        }

        public List<cLine> Items { get; set; }

        public Decimal LineCount { get; set; }
        public Decimal TotalSubTotal { get; set; }
        public Decimal TotalTax { get; set; }
        public decimal TotalSH { get; set; }
        public decimal TotalOther { get; set; }
        public decimal TotalTotal { get; set; }

        public MainWindow()
        {
            Items = new List<cLine>();
            for (int i = 0; i < 20; i++)
            {
                cLine oItem = new cLine();
                oItem.Description = "Item " + i.ToString();
                oItem.SubTotal = i;
                oItem.Tax = ((Decimal)i) / 10;
                oItem.SH = 0;
                oItem.Other = 0;
                oItem.Total = oItem.SubTotal + oItem.Tax + oItem.SH + oItem.Other;
                Items.Add(oItem);
                oItem = null;
            }
            LineCount = Items.Count;
            TotalSubTotal = Items.Sum((i) => i.SubTotal);
            TotalTax = Items.Sum((i) => i.Tax);
            TotalSH = Items.Sum((i) => i.SH);
            TotalOther = Items.Sum((i) => i.Other);
            TotalTotal = Items.Sum((i) => i.Total);

            InitializeComponent();
        }
    }
}

At this point we have a simple, scrollable DataGrid with one frozen column.


To add our footers we need to put the DataGrid inside a two-row grid. It's tempting to use a vertical StackPanel, but you have to remember that StackPanels are intended to fully show all their children which means their children won't scroll.

Add some row definitions to the grid.


        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>

We need to define our footer panel. If you have no row headers and no frozen columns you can simplify this to a single ScrollViewer. Otherwise you need a DockPanel with TextBlocks to match the row header and frozen columns plus a ScrollViewer containing TextBlocks that match the non-frozen columns.

In this example, I don't have row headers but I do have a single frozen column.


        <DockPanel Grid.Row="1">
            <TextBlock DockPanel.Dock="Left" Width="200" Text="{Binding LineCount, StringFormat='Count = {0}'}"/>
            <ScrollViewer DockPanel.Dock="Right" ScrollChanged="ScrollViewer_ScrollChanged" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Hidden">
                <StackPanel Orientation="Horizontal" Margin="10,0,0,0">
                    <TextBlock Width="100" Text="{Binding TotalSubTotal}"/>
                    <TextBlock Width="100" Text="{Binding TotalTax}"/>
                    <TextBlock Width="100" Text="{Binding TotalSH}"/>
                    <TextBlock Width="100" Text="{Binding TotalOther}"/>
                    <TextBlock Width="100" Text="{Binding TotalTotal}"/>
                </StackPanel>
            </ScrollViewer>
        </DockPanel>

We will use this ScrollViewer's horizontal scroll bar so we need to hide the DataGrid's horizontal scrollbar. While we're at it, let's add a bottom border to enhance the UI look and add an event to capture a reference to the DataGrid's ScrollViewer. We will need to call a method on it later.

<DataGrid...  Loaded="DataGrid_Loaded" HorizontalScrollBarVisibility="Hidden" BorderBrush="Black" BorderThickness="0,0,0,1">

The DataGrid_Loaded event handler will drill down to the DataGrid's ScrollViewer and store a reference to it. Add this private member and event handler to the code behind. You probably already have a generic library function that can find child controls of a specified type.

using System.Windows.Controls;
using System.Windows.Media;

        private ScrollViewer sv = null;


        private void DataGrid_Loaded(object sender, EventArgs e)
        {
            sv = GetScrollViewer((DataGrid)sender);
        }

        public static ScrollViewer GetScrollViewer(UIElement parent)
        {
            if (parent == null) return null;

            ScrollViewer sv = null;
            for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent) && sv == null; i++)
            {
                if (VisualTreeHelper.GetChild(parent, i) is ScrollViewer)
                    sv = (ScrollViewer)(VisualTreeHelper.GetChild(parent, i));
                else
                    sv = GetScrollViewer(VisualTreeHelper.GetChild(parent, i) as UIElement);
            }
            return sv;
        }

The last thing we have to do is tie the scroll viewer's horizontal scroll offset to the DataGrid's horizontal scroll offset. It would be nice if we could just bind them to the same property, but the horizontal scroll offset is read only.

Add an event handler called ScrollViewer_ScrollChanged


        private void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
        {
            if (sv != null) sv.ScrollToHorizontalOffset(e.HorizontalOffset);
        }

The result looks like this.




I took this approach rather that using an Infragistics grid or my FooterGrid because the original grid was complex and already written. This approach reuses the original grid and can be implemented in less than an hour.

No comments:

Post a Comment