Click here to Skip to main content
15,888,142 members
Articles / Desktop Programming / WPF
Tip/Trick

WPF Datagrid with Footer Aggregate

Rate me:
Please Sign up or sign in to vote.
4.00/5 (1 vote)
11 Apr 2021CPOL 5.5K   6   1
WPF Datagrid with a footer that you can aggregate, align, and use string formats on
I tried to find a WPF datagrid that had a nice looking footer column that was both nice looking and functional. I couldn't find one, so I decided to try my hand at making my own.

Introduction

This will give you a nice datagrid with the ability to add footer columns that will aggregate numeric values. I have also added a third click on the column headers that will remove sorting. When using the datagrid, I default autogeneratecolumns to false because it is looking for a custom column type. If you want to change that, it is possible with some code changes.

Using the Code

Here are the three classes I use and the enum for what type of aggregate you are trying to get.

C#
public class CustomGrid : DataGrid
{
  public CustomGrid()
  {
    this.AutoGenerateColumns = false;
  }
  private Grid FooterGrid;
  private List<Border> FootersBlocks;
  private Border FooterBorder;
  private ScrollViewer ScrollViewer;
  private DataGridColumnHeadersPresenter Headers;
  public override void OnApplyTemplate()
  {
    base.OnApplyTemplate();
    this.CanUserReorderColumns = false;
    FootersBlocks = new List<Border>();
    var temp = this.Template;
    ScrollViewer = (ScrollViewer) temp.FindName("DG_ScrollViewer", this);
    FooterBorder = (Border) temp.FindName("FooterBorder", this);
    ScrollViewer.ApplyTemplate();
    this.ApplyTemplate();
    Headers = (DataGridColumnHeadersPresenter)
               ScrollViewer.Template.FindName("PART_ColumnHeadersPresenter", ScrollViewer);
    FooterGrid = (Grid)temp.FindName("FooterGrid", this);
    this.LayoutUpdated += CustomGrid_LayoutUpdated;
    this.PreviewMouseUp += CustomGrid_PreviewMouseUp;
  }

  protected override void OnSorting(DataGridSortingEventArgs eventArgs)
  {
    //See if we are sorted in descending order by this column already,
    //if so then remove the sorting.
    if (eventArgs.Column.SortDirection != null &&
    eventArgs.Column.SortDirection == System.ComponentModel.ListSortDirection.Descending)
    {
      var view = CollectionViewSource.GetDefaultView(this.ItemsSource);
      view?.SortDescriptions.Clear();
      eventArgs.Column.SortDirection = null;
      eventArgs.Handled = true;
      return;
    }

    base.OnSorting(eventArgs);
  }

  private void CustomGrid_PreviewMouseUp(object sender,
               System.Windows.Input.MouseButtonEventArgs e)
  {
    DependencyObject dep = (DependencyObject) e.OriginalSource;

    // iteratively traverse the visual tree
    while ((dep != null) && !(dep is DataGridCell) && !(dep is DataGridColumnHeader))
  {
      dep = VisualTreeHelper.GetParent(dep);
    }
    //Since we didn't find a cell then maybe a blank row was clicked, see if we can find it
    if (dep == null)
    {
      dep = (DependencyObject)e.OriginalSource;
      while ((dep != null) &&  !(dep is DataGridRow))
      {
        dep = VisualTreeHelper.GetParent(dep);
      }
      if (dep == null)
        return;
      if (dep is DataGridRow)
      {
        SelectedIndex = this.ItemContainerGenerator.IndexFromContainer((DataGridRow) dep);
      }
    }
  }

  private void CustomGrid_LayoutUpdated(object sender, EventArgs e)
  {
    int index = 0;

    double totalColumnWidth = 0;
    foreach (CustomGridColumn item in this.Columns)
    {
      totalColumnWidth += item.ActualWidth;
      if (FootersBlocks.Count > index)
      {
        FootersBlocks[index].Width = item.ActualWidth;
      }
      index++;
    }
    double test = Headers.ActualHeight;
    double addForScrollBar = 0;
    //See if my horizontal scrollbar is visible.
    //If so, add some height the top margin of the footer
    if (ScrollViewer.ComputedHorizontalScrollBarVisibility == Visibility.Visible)
    {
      addForScrollBar = 17;
    }

    FooterBorder.Margin = new Thickness(0, (this.ActualHeight -
    (22 + Headers.ActualHeight)) + (ScrollViewer.VerticalOffset - 5) -
    addForScrollBar, 0, -10);
  }

  protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
  {
    base.OnItemsSourceChanged(oldValue, newValue);
    //I have my own custom view model base with a property changed event
    //that I am going to subscribe to so I can update the footer if any of the values change
    if (oldValue != null)
    {
      foreach (var item in oldValue)
      {
        if (item is FcViewModelBase)
        {
          var bi = item as FcViewModelBase;
          bi.PropertyChanged -= Con_PropertyChanged;
        }
      }
    }
    if (newValue != null)
    {
      foreach (var item in newValue)
      {
        if (item is FcViewModelBase)
        {
          var bi = item as FcViewModelBase;
          bi.PropertyChanged += Con_PropertyChanged;
        }
      }
    }
  }

  protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
  {
    base.OnItemsChanged(e);
    Update_Footers();
  }

  public void Update_Footers()
  {
    FootersBlocks.Clear();
    FooterGrid.Children.Clear();
    FooterGrid.ColumnDefinitions.Clear();
    int index = 0;
    foreach (CustomGridColumn item in this.Columns)
    {
      decimal footerValue = 0;
      var tb = new TextBlock();

      FooterGrid.ColumnDefinitions.Add(new ColumnDefinition() { Width = GridLength.Auto });
      if (item.AggregateType != AggregateType.None)
      {
        foreach (var r in this.Items)
        {
          System.Type type = r.GetType();
          if (type.Name != "NamedObject")
          {
            string val = type.GetProperty(item.ColumnFooter).GetValue(r, null).ToString();
            decimal.TryParse(val, out var fv);
            footerValue += fv;
          }
        }

        switch (item.AggregateType)
        {
          case AggregateType.None:
            break;
          case AggregateType.Sum:
            tb.Text = string.Format(item.StringFormat, footerValue);
            break;
          case AggregateType.Count:
            tb.Text = this.Items.Count.ToString(item.StringFormat);
            break;
          case AggregateType.Average:
            decimal avg = Math.Round(footerValue / this.Items.Count, 2);
            tb.Text = string.Format(item.StringFormat, avg);
            break;
          default:
            break;
        }
      }

      tb.VerticalAlignment = VerticalAlignment.Top;
      tb.Margin = new Thickness(3);
      var borderBrush = (SolidColorBrush)(new BrushConverter().ConvertFrom("#FFB1B1B1"));
      var brd = new Border() { BorderThickness = new Thickness(0,0,1,0),
                BorderBrush = borderBrush, Width = item.ActualWidth };
      brd.Child = tb;
      tb.HorizontalAlignment = item.HorizontalAlignment;
      brd.SetValue(Grid.ColumnProperty, index);
      FooterGrid.Children.Add(brd);
      FootersBlocks.Add(brd);
      index++;
    }
  }

  private void Con_PropertyChanged(object sender,
          System.ComponentModel.PropertyChangedEventArgs e)
  {
    Update_Footers();
  }
}

public class CustomGridColumn : DataGridTextColumn
{
  public CustomGridColumn()
  {
    this.AggregateType = AggregateType.None;
    this.StringFormat = "{0}";
  }

  public static readonly DependencyProperty
         ColumnFooterProperty = DependencyProperty.RegisterAttached
         ("ColumnFooter", typeof(string), typeof(CustomGridColumn));

  public string ColumnFooter
  {
    get { return (string)GetValue(ColumnFooterProperty); }
    set { SetValue(ColumnFooterProperty, value); }
  }

  public static readonly DependencyProperty HorizontalAlignmentProperty =
         DependencyProperty.RegisterAttached("HorizontalAlignment",
         typeof(HorizontalAlignment), typeof(CustomGridColumn));
  public HorizontalAlignment HorizontalAlignment
  {
    get { return (HorizontalAlignment)GetValue(HorizontalAlignmentProperty); }
    set { SetValue(HorizontalAlignmentProperty, value); }
  }

  public static readonly DependencyProperty StringFormatProperty =
         DependencyProperty.RegisterAttached("StringFormat",
         typeof(string), typeof(CustomGridColumn));
  public string StringFormat
  {
    get { return (string)GetValue(StringFormatProperty); }
    set { SetValue(StringFormatProperty, value); }
  }

  public static readonly DependencyProperty AggregateTypeProperty =
         DependencyProperty.RegisterAttached("AggregateType",
         typeof(AggregateType), typeof(CustomGridColumn));
  public AggregateType AggregateType
  {
    get { return (AggregateType)GetValue(AggregateTypeProperty); }
    set { SetValue(AggregateTypeProperty, value); }
  }
}

public enum AggregateType
{
  None, Sum, Count, Average
}

public class FcViewModelBase : INotifyPropertyChanged, IDisposable
{
  public event PropertyChangedEventHandler PropertyChanged;

  protected void OnPropertyChanged(string propertyName)
  {
    var propertyChanged = this.PropertyChanged;

    if (propertyChanged != null)
    {
      propertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
  }

  public void Dispose()
  {
    Dispose(true);
    GC.SuppressFinalize(this);
  }

  protected virtual void Dispose(bool disposing)
  {
    if (disposing)
    {
      // free managed resources
    }
  }

  protected bool SetProperty<T>(ref T backingField, T Value,
                 Expression<Func<T>> propertyExpression)
  {
    var changed = !EqualityComparer<T>.Default.Equals(backingField, Value);

    if (changed)
    {
      backingField = Value;
      this.OnPropertyChanged(ExtractPropertyName(propertyExpression));
    }

    return changed;
  }

  private static string ExtractPropertyName<T>(Expression<Func<T>> propertyExpression)
  {
    var memberExp = propertyExpression.Body as MemberExpression;

    if (memberExp == null)
    {
      throw new ArgumentException("Expression must be a MemberExpression.",
                                  "propertyExpression");
    }

    return memberExp.Member.Name;
  }
}

Here is my main window xaml that includes the style that I am using for the custom datagrid. You must use the style included because it contains the footer border and grid.

XML
<Window x:Class="WpfApp2.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:WpfApp2"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
  <Window.Resources>
    <SolidColorBrush x:Key="ControlForeground_Normal" Color="#FF000000"/>
    <SolidColorBrush x:Key="ControlOuterBorder_Normal" Color="#FFB2B2B2"/>
    <SolidColorBrush x:Key="ControlInnerBorder_Normal" Color="#FFFFFFFF"/>
    <LinearGradientBrush x:Key="ControlBackground_Normal" 
                         EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="White"/>
      <GradientStop Color="#FFE3E8EB" Offset="1"/>
    </LinearGradientBrush>
    <LinearGradientBrush x:Key="ControlBackground_MouseOver" 
                         EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="#FFD6E9F0"/>
      <GradientStop Color="#FFA6C8D4" Offset="1"/>
    </LinearGradientBrush>
    <LinearGradientBrush x:Key="ControlBackground_Selected" 
                         EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="#FFD6E9F0"/>
      <GradientStop Color="#FFD2EBF3" Offset="1"/>
    </LinearGradientBrush>
    <SolidColorBrush x:Key="ControlOuterBorder_MouseOver" Color="#FF6F9DB5"/>
    <SolidColorBrush x:Key="ControlInnerBorder_MouseOver" Color="#FFFFFFFF"/>
    <LinearGradientBrush x:Key="ControlBackground_Pressed" 
                         EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="#FF8AAEBB" Offset="0"/>
      <GradientStop Color="#FFBBDBE6" Offset="0.15"/>
      <GradientStop Color="#FF7FBFD4" Offset="1"/>
    </LinearGradientBrush>
    <SolidColorBrush x:Key="ControlOuterBorder_Pressed" Color="#FF198FB0"/>
    <LinearGradientBrush x:Key="ControlInnerBorder_Pressed" 
                         EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="#FF65A5BB" Offset="0"/>
      <GradientStop Color="#FFCCEDF8" Offset="0.143"/>
    </LinearGradientBrush>
    <SolidColorBrush x:Key="ControlInnerBorder_Disabled" Color="Transparent"/>
    <LinearGradientBrush x:Key="ControlBackground_Disabled" 
                         EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="#FFE3E8EB" Offset="1"/>
      <GradientStop Color="White"/>
    </LinearGradientBrush>
    <SolidColorBrush x:Key="ControlOuterBorder_Focused" Color="#FF00B1F0"/>
    <SolidColorBrush x:Key="ControlInnerBorder_Focused" Color="Transparent"/>
    <SolidColorBrush x:Key="ControlBackground_Focused" Color="Transparent"/>
    <LinearGradientBrush x:Key="GridView_HeaderBackground" 
                         EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="#FF0E7094" Offset="1"/>
      <GradientStop Color="#FF1990B1"/>
    </LinearGradientBrush>
    <ControlTemplate x:Key="DataGridTemplate1" TargetType="{x:Type local:CustomGrid}">
      <Border Background="{TemplateBinding Background}" 
              BorderThickness="{TemplateBinding BorderThickness}" 
              BorderBrush="{TemplateBinding BorderBrush}" 
              Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
        <ScrollViewer x:Name="DG_ScrollViewer" Focusable="false">
          <ScrollViewer.Template>
            <ControlTemplate TargetType="{x:Type ScrollViewer}">
              <Grid>
                <Grid.ColumnDefinitions>
                  <ColumnDefinition Width="Auto"/>
                  <ColumnDefinition Width="*"/>
                  <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                  <RowDefinition Height="Auto"/>
                  <RowDefinition Height="*"/>
                  <RowDefinition Height="Auto"/>

                </Grid.RowDefinitions>
                <Button Command="{x:Static DataGrid.SelectAllCommand}" 
                        Focusable="false" Style="{DynamicResource 
                        {ComponentResourceKey ResourceId=DataGridSelectAllButtonStyle, 
                        TypeInTargetAssembly={x:Type DataGrid}}}" 
                        Visibility="{Binding HeadersVisibility, 
                        Converter={x:Static DataGrid.HeadersVisibilityConverter}, 
                        ConverterParameter={x:Static DataGridHeadersVisibility.All}, 
                        RelativeSource={RelativeSource AncestorType={x:Type DataGrid}}}" 
                        Width="{Binding CellsPanelHorizontalOffset, 
                        RelativeSource={RelativeSource AncestorType={x:Type DataGrid}}}"/>
                <DataGridColumnHeadersPresenter x:Name="PART_ColumnHeadersPresenter" 
                     Grid.Column="1" Visibility="{Binding HeadersVisibility, 
                     Converter={x:Static DataGrid.HeadersVisibilityConverter}, 
                     ConverterParameter={x:Static DataGridHeadersVisibility.Column}, 
                     RelativeSource={RelativeSource AncestorType={x:Type DataGrid}}}"/>
                <ScrollContentPresenter x:Name="PART_ScrollContentPresenter" 
                     Grid.ColumnSpan="2" CanContentScroll="{TemplateBinding CanContentScroll}"
                     Grid.Row="1"/>
                <ScrollBar x:Name="PART_VerticalScrollBar" Grid.Column="2" 
                           Maximum="{TemplateBinding ScrollableHeight}" 
                           Orientation="Vertical" Grid.Row="1" 
                           ViewportSize="{TemplateBinding ViewportHeight}" 
                           Value="{Binding VerticalOffset, Mode=OneWay, 
                           RelativeSource={RelativeSource TemplatedParent}}" 
                           Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
                <Grid Grid.Column="1" Grid.Row="2">
                  <Grid.ColumnDefinitions>
                    <ColumnDefinition Width=
                         "{Binding NonFrozenColumnsViewportHorizontalOffset, 
                         RelativeSource={RelativeSource AncestorType={x:Type DataGrid}}}"/>
                    <ColumnDefinition Width="*"/>
                  </Grid.ColumnDefinitions>
                  <ScrollBar x:Name="PART_HorizontalScrollBar" Grid.Column="1" 
                   Maximum="{TemplateBinding ScrollableWidth}" Orientation="Horizontal" 
                   ViewportSize="{TemplateBinding ViewportWidth}" 
                   Value="{Binding HorizontalOffset, Mode=OneWay, 
                   RelativeSource={RelativeSource TemplatedParent}}" 
                   Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
                </Grid>
              </Grid>
            </ControlTemplate>
          </ScrollViewer.Template>
          <Grid>
            <ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
                            Margin="0,0,0,25"/>
            <Border Height="25" VerticalAlignment="Top" x:Name="FooterBorder" 
                    BorderBrush="#FFB1B1B1" BorderThickness="1">
              <Border.Background>
                <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
                  <GradientStop Color="White"/>
                  <GradientStop Color="#FFE4E4E4" Offset="0.148"/>
                </LinearGradientBrush>
              </Border.Background>
              <Grid x:Name="FooterGrid" Margin="5,0,0,0">

              </Grid>
            </Border>
          </Grid>
        </ScrollViewer>

      </Border>
    </ControlTemplate>

    <Style TargetType="DataGridRow">
      <Style.Triggers>
        <Trigger Property="IsMouseOver" Value="True">
          <Setter Property="Background" Value="{StaticResource ControlBackground_Selected}" />
        </Trigger>
        <Trigger Property="IsSelected" Value="True">
          <Setter Property="Background" Value="{StaticResource ControlBackground_MouseOver}" />
        </Trigger>
      </Style.Triggers>
    </Style>
    <Style TargetType="DataGridCell">
      <Setter Property="Padding" Value="4"/>
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="{x:Type DataGridCell}">
            <Border Padding="{TemplateBinding Padding}" 
                    BorderBrush="{TemplateBinding BorderBrush}" 
                    BorderThickness="{TemplateBinding BorderThickness}" 
                    Background="{TemplateBinding Background}" SnapsToDevicePixels="True">
              <ContentPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
            </Border>
          </ControlTemplate>
        </Setter.Value>
      </Setter>
      <Style.Triggers>
        <Trigger Property="IsSelected" Value="True">
          <Setter Property="Background" Value="{StaticResource ControlBackground_MouseOver}"/>
          <Setter Property="Foreground" Value="Black"/>
          <Setter Property="BorderBrush" Value="Transparent" />
        </Trigger>
      </Style.Triggers>
    </Style>
  </Window.Resources>
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto" />
      <RowDefinition Height="*" />
    </Grid.RowDefinitions>
    <StackPanel Orientation="Horizontal">
      <TextBlock Text="Group By " VerticalAlignment="Center" />
      <ComboBox  DisplayMemberPath="StyleGroupItemName" Width="150" Height="30" Margin="2" />
      <Button Content="Test"  Width="150" Height="30" />
    </StackPanel>
    <local:CustomGrid ItemsSource="{Binding ItemsList}" 
           Template="{StaticResource DataGridTemplate1}" Grid.Row="1" 
           CanUserAddRows="False" SelectionMode="Single" SelectionUnit="FullRow" 
           HorizontalGridLinesBrush="{StaticResource ControlOuterBorder_Normal}" 
           VerticalGridLinesBrush="{StaticResource ControlOuterBorder_Normal}" 
           BorderBrush="Silver">
      <local:CustomGrid.Columns>
        <local:CustomGridColumn Binding="{Binding Item}" Header="Test Header" />
        <local:CustomGridColumn Binding="{Binding TestItem}" Header="Test" />
        <local:CustomGridColumn Binding="{Binding Total}" AggregateType="Sum" 
         HorizontalAlignment="Center" StringFormat="Total: {0:C2}" 
         ColumnFooter="Total" Header="Total" />
      </local:CustomGrid.Columns>
    </local:CustomGrid>
  </Grid>
</Window>

In this example, I am just using the main window code behind for my DataContext with a sample class to fill the datagrid with some test data.

C#
  public MainWindow()
  {
    InitializeComponent();
    ItemsList = new List<TestClass>();
    for (int i = 0; i < 50; i++)
    {
      var vm = new TestClass { Item = "Test", TestItem = "Test2", Total = 123.5m * i };
      ItemsList.Add(vm);
    }
    this.DataContext = this;
  }
  public List<TestClass> ItemsList { get; set; }
}

public class TestClass : FcViewModelBase
{
  public string Item { get; set; }
  public string TestItem { get; set; }

  private decimal _total;
  public decimal Total
  {
    get { return _total; }
    set { SetProperty(ref _total, value, () => Total); }
  }
}

I hope this will make it easy for the next guy, trying to get my grid lines to look nice while also having my footer act proper when horizontal or vertical scrollbars appeared was difficult.

History

  • 11th April, 2021: Initial version

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Unknown
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionColumn vs row Pin
Сергій Ярошко11-Apr-21 20:11
professionalСергій Ярошко11-Apr-21 20:11 
Help me please: I can't understand if you talk about footer column or footer raw?

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.