Monday, November 6, 2017

Editable DataGrid with fixed footers

This post shows how to create a re-usable User Control that implements an editable DataGrid with fixed footers. I'm sure there are people out there who could do a better job, but I haven't found anyone publishing a solution so here's mine.

The project is based on Thibaud's generic solution, but heavily modified to look like a fixed footer and to be editable. It targets framework 4.0 or later and is written in C#.

The User Control defines a Grid that contains two Data Grids. The main grid sits in the top row and the footers are in a single row data grid with no headers that sits in the bottom row. The solution makes several assumptions for simplicity, such as assuming all the values to be aggregated into the footers are decimals. I've also assumed you don't need to see headers on the footer grid.

The end result will look something like this.


Start a new WPF User Control project and call it FooterGrid.

Rename UserControl1 to Grid. The XAML for the User Control contains the Grid and minimal definitions for the Data Grids. It looks like this.

<UserControl x:Class="FooterGrid.FooterGrid"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:FooterGrid">
    <Grid Name="xLayout">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <DataGrid Name="xMain" Grid.Row="0" AutoGenerateColumns="False"/>
        <DataGrid Name="xFooter" Grid.Row="1" AutoGenerateColumns="False"
                  HeadersVisibility="Row" IsReadOnly="True"/>
    </Grid>
</UserControl>

Most of the heavy lifting is going to be done in the code-behind Grid.xaml.cs.

Let's start by adding the namespace and static class, together with some static globals that will make our lives easier.

using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Data;
using System.Windows.Controls;
using System.Collections;
using System.Reflection;
using System.Collections.ObjectModel;

namespace FooterGrid
{
    public partial class FooterGrid
    {
        private static FooterGrid dgOuter;
        private static DataGrid dgMain;
        private static DataGrid dgFooter;
        private static Grid gLayout;

        public FooterGrid()
        {
            InitializeComponent();
        }
    }
}

In this project we're going to add the minimum dependency properties we need to get the result I showed you above. You will need to add more properties if you want to set properties that are supported by DataGrid but not by UserControl. Anything defined in System.Windows.Control or its base classes will not require you to add a new dependency property.

If you do need to add Dependency Properties you can just follow the pattern I've used. It looks like a lot of code, but it's very repetitive.

public static ObservableCollection<DataGridColumn> GetColumns(DependencyObject obj)
{
    return (ObservableCollection<DataGridColumn>)obj.GetValue(ColumnsProperty);
}

public static void SetColumns(DependencyObject obj, ObservableCollection<DataGridColumn> value)
{
    obj.SetValue(ColumnsProperty, value);
}

public static readonly DependencyProperty ColumnsProperty =
            DependencyProperty.RegisterAttached("Columns", typeof(ObservableCollection<DataGridColumn>), typeof(FooterGrid), new UIPropertyMetadata(new ObservableCollection<DataGridColumn>(), ColumnsPropertyChanged));

public static int GetColumnSpan(DependencyObject obj)
{
    return (int)obj.GetValue(ColumnSpanProperty);
}

public static void SetColumnSpan(DependencyObject obj, int value)
{
    obj.SetValue(ColumnSpanProperty, value);
}

public static readonly DependencyProperty ColumnSpanProperty =
            DependencyProperty.RegisterAttached("ColumnSpan", typeof(int), typeof(FooterGrid), new UIPropertyMetadata(1));

public static IEnumerable GetItemsSource(DependencyObject obj)
{
    return (IEnumerable)obj.GetValue(ItemsSourceProperty);
}

public static void SetItemsSource(DependencyObject obj, IEnumerable value)
{
    obj.SetValue(ItemsSourceProperty, value);
}

public static readonly DependencyProperty ItemsSourceProperty =
            DependencyProperty.RegisterAttached("ItemsSource", typeof(IEnumerable), typeof(FooterGrid), new UIPropertyMetadata(null, SetxMainItemsSource));

private static Nullable<Double> GetInternalWidthOnColumn(DependencyObject obj)
{
    return (double)obj.GetValue(InternalWidthOnColumnProperty);
}

private static void SetInternalWidthOnColumn(DependencyObject obj, Nullable<Double> value)
{
    obj.SetValue(InternalWidthOnColumnProperty, value);
}

private static readonly DependencyProperty InternalWidthOnColumnProperty =
            DependencyProperty.RegisterAttached("InternalWidthOnColumn", typeof(Nullable<Double>), typeof(FooterGrid), new UIPropertyMetadata(null, InternalWidthOnColumnPropertyChanged));

public static String GetAggregate(DependencyObject obj)
{
    return (String)obj.GetValue(AggregateProperty);
}

public static void SetAggregate(DependencyObject obj, String value)
{
    obj.SetValue(AggregateProperty, value);
}

public static readonly DependencyProperty AggregateProperty =
            DependencyProperty.RegisterAttached("Aggregate", typeof(String), typeof(FooterGrid), new UIPropertyMetadata(""));

Some of these Dependency Property definitions specify methods to be called when the property is changed. Those methods are listed here. SetxMainItemsSource is the first opportunity we get to get references to the main visual components. We also set the main grid's ItemsSource to the User Control's ItemsSource. We have to use a dependency property for ItemsSource because User Controls don't have them.

Note the horizontal scroll bar is under the footer grid, so the main grid's horizontal scroll position is bound to the footer grid's horizontal scroll position.

private static void InternalWidthOnColumnPropertyChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
    if (e.NewValue != null)
    {
        ((DataGridColumn)sender).Width = ((Nullable<double>)e.NewValue).Value;
    }
}

private static void ColumnsPropertyChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
    // Put code here to track changes to the column collection as they happen
}

private static void SetxMainItemsSource(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
    // We just got an ItemsSource, but we probably don't have columns yet
    dgOuter = (sender as FooterGrid);
    dgMain = dgOuter.FindName("xMain") as DataGrid;
    dgFooter = dgOuter.FindName("xFooter") as DataGrid;
    gLayout = dgOuter.FindName("xLayout") as Grid;
    dgMain.ItemsSource = GetItemsSource(sender);
    SynchronizeVerticalDataGrid(dgOuter);
    dgFooter.AddHandler(ScrollViewer.ScrollChangedEvent, new RoutedEventHandler(xDataGrid_ScrollChanged));
}

Let's add the SynchronizedVerticalDataGrid method which handles some properties, does some binding, and sets some event handlers.

private static void SynchronizeVerticalDataGrid(FooterGrid source)
{
    // Called when the ItemsSource property is processed.
    dgMain.Background = source.Background;
    dgFooter.Background = source.Background;
    dgFooter.DataContext = source.DataContext;
    dgMain.HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden;
    dgFooter.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
    dgMain.CurrentCellChanged += CalcFooterItemSource;
    dgMain.Loaded += InitMain;
}

InitMain is called when the main grid has been loaded. We take the Column collection that is in the ColumnsProperty and clone the columns into both the main and the footer grid. Note we store the aggregate in the header of the footer column. This is a bit of a hack. Then we call BindFooterColumns.

public static void InitMain(object sender, EventArgs e)
{
    // Should have columns and rows so we can create columns in Main and Footer and populate Footer
    ObservableCollection<DataGridColumn> Cols = dgOuter.GetValue(ColumnsProperty) as ObservableCollection<DataGridColumn>;
    foreach (DataGridColumn dc in Cols)
    {
        dc.DisplayIndex = Cols.IndexOf(dc);
        dgMain.Columns.Add(CloneColumn(dc, true));
        dgFooter.Columns.Add(CloneColumn(dc, false)); 
    }
    BindFooterColumns(sender, e);
}

private static DataGridTextColumn CloneColumn(DataGridColumn dc, bool isMain)
{
    // Create a new column and return it. Add properties as needed
    DataGridTextColumn newDC = new DataGridTextColumn();
    Binding b = (dc as DataGridBoundColumn).Binding as Binding;
    newDC.Width = dc.Width;
    newDC.Header = (isMain) ? dc.Header : dc.GetValue(AggregateProperty);
    newDC.DisplayIndex = dc.DisplayIndex;
    newDC.Binding = new Binding
    {
        Path = new PropertyPath(b.Path.Path)
    };
    return newDC;
}

BindFooterColumns causes the width, columnspan, and display index of the corresponding main and footer columns to be bound together. This means when the user adjusts a column width or reorders the columns in the main grid, the footer table will change to match.

private static void BindFooterColumns(object sender, EventArgs e)
{
    // Bind the width and DisplayIndex properties of the Main and Footer columns
    int sourceColIndex = 0;
    bool bAllowColumnDisplayIndexSynchronization = true;
    for (int i = 0; i < dgFooter.Columns.Count; i++)
    {
        if (GetColumnSpan(dgFooter.Columns[i]) > 1)
        {
            bAllowColumnDisplayIndexSynchronization = false;
            break;
        }
    }

    for (int associatedColIndex = 0; associatedColIndex < dgFooter.Columns.Count; associatedColIndex++)
    {
        var colAssociated = dgFooter.Columns[associatedColIndex];
        int columnSpan = GetColumnSpan(colAssociated);

        if (sourceColIndex >= dgMain.Columns.Count) break;
        if (columnSpan <= 1)
        {
            var colSource = dgMain.Columns[sourceColIndex];
            Binding binding = new Binding();
            binding.Mode = BindingMode.TwoWay;
            binding.Source = colSource;
            binding.Path = new PropertyPath(DataGridColumn.WidthProperty);
            BindingOperations.SetBinding(colAssociated, DataGridColumn.WidthProperty, binding);

            if (bAllowColumnDisplayIndexSynchronization)
            {
                binding = new Binding();
                binding.Mode = BindingMode.TwoWay;
                binding.Source = colSource;
                       binding.Path = new PropertyPath(DataGridColumn.DisplayIndexProperty);
                BindingOperations.SetBinding(colAssociated, DataGridColumn.DisplayIndexProperty, binding);
            }

            sourceColIndex++;
        }
        else
        {
            MultiBinding multiBinding = new MultiBinding();
            multiBinding.Converter = WidthConverter;
            for (int i = 0; i < columnSpan; i++)
            {
                var colSource = dgMain.Columns[sourceColIndex];
                Binding binding = new Binding();
                binding.Source = colSource;
                binding.Path = new PropertyPath(DataGridColumn.WidthProperty);
                multiBinding.Bindings.Add(binding);
                binding = new Binding();
                binding.Source = colSource;
                multiBinding.Bindings.Add(binding);
                sourceColIndex++;
            }
            BindingOperations.SetBinding(colAssociated, InternalWidthOnColumnProperty, multiBinding);
        }
    }

    CreateFooterItemSource(sender, e);
}

CreateFooterItemSource creates a single row ItemsSource by using reflection to get the main grid's items and binds the footer grid to this new items source. Then it calls CalcFooterItemSource to do the initial population of the footer.

private static void CreateFooterItemSource(object sender, EventArgs e)
{
    // Create the ItemSource for the Footer based on the itemSource for Main
    Type itemType = dgMain.ItemsSource.GetType().GetGenericArguments()[0];
    Type listType = typeof(List<>).MakeGenericType(itemType);
    IList l = (IList)Activator.CreateInstance(listType);
    var item = Activator.CreateInstance(itemType);
    l.Add(item);

    dgFooter.ItemsSource = l;
    CalcFooterItemSource(sender, e);
}

CalcFooterItemSource is will calculate the footer values and cause them to be displayed. It is called when the footer item source is first created and then again when the user changes the selected cell in the main grid. This method uses reflection to understand the aggregate which means an aggregate can reference data in different columns.

private static void CalcFooterItemSource(object sender, EventArgs e)
{
    // Recalculate Footer values and force update. Call this on initial load and whenever a 
    // cell in Main might have changed

    // Add support for different aggregates as required
    IEnumerable ItemsSource;
    String Aggregate = "";
    String Column = "";
    Decimal Result = 0;
    try
    {
        ItemsSource = dgMain.ItemsSource;
        if (ItemsSource == null) return;

        dgMain.CommitEdit();
        Type itemType = ItemsSource.GetType().GetGenericArguments()[0];

        foreach (DataGridTextColumn c in dgFooter.Columns)
        {
            if (c.Header.ToString() != "")
            {
                Result = 0;
                try
                {
                    Aggregate = c.Header.ToString();
                    PropertyInfo pi;
                    if (Aggregate.StartsWith("SUM(") && Aggregate.EndsWith(")"))
                    {
                        Column = Aggregate.Substring(4, Aggregate.Length - 5);
                        pi = itemType.GetProperty(Column);
                        if (pi == null)
                            throw new Exception(Aggregate + " column '" + Column + "' not found in ItemsSource.");

                        foreach (var item in ItemsSource)
                        {
                            Result += decimal.Parse(pi.GetValue(item, null).ToString());
                        }
                    }
                    if (Aggregate.StartsWith("COUNT(") && Aggregate.EndsWith(")"))
                    {
                       Column = Aggregate.Substring(6, Aggregate.Length - 7);
                       pi = itemType.GetProperty(Column);
                       if (pi == null)
                            throw new Exception(Aggregate + " column '" + Column + "' not found in ItemsSource.");
                       foreach (var item in ItemsSource)
                       {
                           if (!String.IsNullOrEmpty(pi.GetValue(item, null).ToString()))
                                Result += 1;
                       }
                    }
                    if (Aggregate.StartsWith("MAX(") && Aggregate.EndsWith(")"))
                    {
                        Column = Aggregate.Substring(4, Aggregate.Length - 5);
                        pi = itemType.GetProperty(Column);
                        if (pi == null)
                            throw new Exception(Aggregate + " column '" + Column + "' not found in ItemsSource.");
                        foreach (var item in ItemsSource)
                        {
                            if ((Decimal)pi.GetValue(item, null) > Result)
                                Result = (Decimal)pi.GetValue(item, null);
                        }
                    }

                    // Get the path.path of the footer column's binding
                    String p = ((c as DataGridBoundColumn).Binding as Binding).Path.Path;
                    pi = itemType.GetProperty(p);
                    pi.SetValue(((IList)dgFooter.ItemsSource)[0], Result, null);
                }
                catch (Exception ex)
                {
                    throw new Exception(Aggregate + ":" + ex.Message);
                }
            }
        }

        // Crude but effective
        IEnumerable fis = dgFooter.ItemsSource;
        dgFooter.ItemsSource = null;
        dgFooter.ItemsSource = fis;
    }
    catch (Exception ex)
    {
        throw new Exception("CalcFooterItemSource:" + ex.Message);
    }
}


The WidthConverter class is used to handle columns with ColumnSpan > 1.

private static IMultiValueConverter WidthConverter = new WidthConverterClass();
private class WidthConverterClass : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        double result = 0;
        foreach (var value in values)
        {
            DataGridColumn column = value as DataGridColumn;
            if (column != null)
            {
                result = result + column.ActualWidth;
            }
        }
        return result;
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

The xDataGrid_ScrollChanged event handler keeps the two data grids horizontally scrolling together.

private const string ScrollViewerNameInTemplate = "DG_ScrollViewer";

private static void xDataGrid_ScrollChanged(object sender, RoutedEventArgs eBase)
{
    // The footer scrolled so the main must scroll to match
    ScrollChangedEventArgs e = (ScrollChangedEventArgs)eBase;
    ScrollViewer sourceScrollViewer = (ScrollViewer)e.OriginalSource;
    DataGrid Main = (DataGrid)((Grid)((DataGrid)sender).Parent).FindName("xMain");
    SynchronizeScrollHorizontalOffset(Main, sourceScrollViewer);
}

private static void SynchronizeScrollHorizontalOffset(DataGrid FooterGrid, ScrollViewer sourceScrollViewer)
{
    if (FooterGrid!= null)
    {
        ScrollViewer associatedScrollViewer = (ScrollViewer)FooterGrid.Template.FindName(ScrollViewerNameInTemplate, FooterGrid);
associatedScrollViewer.ScrollToHorizontalOffset(sourceScrollViewer.HorizontalOffset);
    }
}

Consuming the user control is fairly simple. Add a new WPF Application project to the solution and call it TestFooterGrid.


Add a reference to FooterGrid and set the new project as the startup project.

The MainWindow.XAML should look like this. There is a reference to the user control project, the ItemSource attribute is now called FooterGrid.ItemsSource, and the Columns collection is now called FooterGrid.Columns. The DataGrid columns now have a new attribute called FooterGrid.Aggregate.

<Window x:Class="TestFooterGrid.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:TestFooterGrid"
        xmlns:gwf="clr-namespace:FooterGrid;assembly=FooterGrid"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525"
        DataContext="{Binding RelativeSource={RelativeSource Self}}">
    <Grid>
        <gwf:FooterGrid Grid.Row="0" Grid.Column="0" IsEnabled="True"
             gwf:FooterGrid.ItemsSource="{Binding DataSource}">
            <gwf:FooterGrid.Columns>
                <DataGridTextColumn Header="None" Binding="{Binding Title}"/>
                <DataGridTextColumn Header="SUM(A)"  Binding="{Binding A}" gwf:FooterGrid.Aggregate="SUM(A)"/>
                <DataGridTextColumn Header="COUNT(B)" Binding="{Binding B}" gwf:FooterGrid.Aggregate="COUNT(B)"/>
                <DataGridTextColumn Header="MAX(B)" Binding="{Binding C}" gwf:FooterGrid.Aggregate="MAX(B)"/>
            </gwf:FooterGrid.Columns>
        </gwf:FooterGrid>
    </Grid>
</Window>

The code behind looks no different from how you would call a regular DataGrid. Note I took all the MVVM stuff out for simplicity.

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

namespace TestFooterGrid
{
    public partial class MainWindow : Window
    {
        public List<Item> DataSource { get; set; }
        public MainWindow()
        {
            DataSource = new List<Item>();
            for (int i = 0; i < 10; i++)
                DataSource.Add(new Item {Title = "Some Text", A = i, B=i*2, C=i*3 });

            InitializeComponent();
        }
    }

    public class Item
    {
        public decimal A { get; set; }
        public decimal B { get; set; }
        public decimal C { get; set; }
        public String Title { get; set; }
    }
}

Try editing a number, reordering and resizing columns. Also note that the scroll bars show up in the correct places when you resize the grid and the footers truly are fixed. You can also add rows and delete them and the footers are updated correctly.







No comments:

Post a Comment