Click here to Skip to main content
14,972,962 members
Articles / Desktop Programming / WPF
Article
Posted 24 Jan 2021

Tagged as

Stats

12.8K views
866 downloads
38 bookmarked

WPF: DataGrid Filterable, Multi Language

Rate me:
Please Sign up or sign in to vote.
4.95/5 (12 votes)
10 Apr 2021CPOL10 min read
An easy-to-use filterable, multilingual custom DataGrid control for managing and filtering data for your WPF applications
This article covers creating a custom DataGrid Control that inherits from the base DataGrid control class and override some methods to implement filters for each column just like Excel.

Image 1

Contents

Introduction

This article covers creating a custom DataGrid Control that inherits from the base DataGrid control class and overrides some methods to implement filters for each column just like Excel.

You must master the basics of C# programming and have a good level in WPF.

The demo application uses the MVVM design pattern, but it is not necessary to master this pattern to implement this control.

Background

In a professional project, I had to respond to a request from a user who wanted to be able to filter the columns of a list of data like Excel.

As this user is used to using Excel in his daily work, the use of filters gave him a quick overview of the information to be filtered and the actions to be taken.

I first searched the Internet for the suggested solutions that would allow me to implement this new functionality, but I did not find them satisfactory or they were incomplete.

So I took inspiration from these solutions and snippet code to develop a control datagrid custom that would meet the customer's requirements.

Thanks to all the anonymous developers who helped create this control.

How It Works

How to filter data from a datagrid across multiple columns without having to create code that looks like an oil refinery?
The answer is ICollectionView which allows filtering with multiple predicates.

For information, MSDN documentation description of a ICollectionView:

“You can think of a collection view as a layer on top of a binding source collection that allows you to navigate and display the collection based on sort, filter, and group queries, all without having to manipulate the underlying source collection itself”

Basic single column filtering can be summarized like this:

XML
<DataGrid
x:Name="MyDataGrid"
Width="300"
Height="300"/>
C#
...
// the test class
internal class DataTest
{
    public string Letter {get; set; }
}

public MainWindow()
{
   InitializeComponent();

   // the text to filter
   string searchText  = "a"

   // the data
   List<DataTest> data = new List<DataTest> {
   new DataTest{Letter = "A"},
   new DataTest{Letter = "B"},
   new DataTest{Letter = "C"}
   new DataTest{Letter = "D"}};

   // the collection view
   ICollectionView itemsView = CollectionViewSource.GetDefaultView(data);

   // set the ItemSource of the DataGrid control into xaml page
   MyDataGrid.ItemSource = itemsView;

   // the filter
   itemsView.Filter = delegate(object o)
   {
     var item = (DataTest)o;

     // if found returns false (hidden), otherwise returns true (displayed)
     return item?.Letter.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) < 0;
   };
}

The letter "A" is not displayed.

Image 2

That works fine, but how do you filter a second column?

Let's start by adding a new property 'Number' to the DataTest class.

C#
// the test class
internal class DataTest
{
  public string Letter {get; set; }
  public int Number {get; set; }
}

// the string to be filtered
string searchText  = "a"

// adding new string to be filtered
string searchNumber = "2";

// initialize the list with new data
List <DataTest> data = new List <DataTest> {
new DataTest{Letter = "A", Number = 1},
new DataTest{Letter = "B", Number = 2},
new DataTest{Letter = "C", Number = 3},
new DataTest{Letter = "D", Number = 4}
new DataTest{Letter = "E", Number = 5}};

// the collection view and the ItemSource of the DataGrid remains unchanged

// adding a list of Predicate
var criteria = new List<Predicate<DataTest>>();

// ignore case
var ignoreCase = StringComparison.OrdinalIgnoreCase;

// add a criterion for each column
criteria.Add(e => e! = null && e.Letter.IndexOf(searchText, ignoreCase) <0);
criteria.Add(e => e! = null &&
             e.Number.ToString().IndexOf(searchNumber, ignoreCase) < 0);

// the new filter multi criteria
itemsView.Filter = delegate(object o)
{
    var item = o as DataTest;

    // return true (displayed) by all criteria
    return criteria.TrueForAll(x => x (item));
};

The letter "A" and the number 2 are not displayed.

Image 3

Here too it works well, but how to filter several elements with the same criterion?
For example, filter [A, D] from the column Letter and [2, 3] from the column Number.

This is where it gets interesting, replacing the two string variables "searchText" and "searchNumber" with arrays of strings.

C#
string[] searchText  = {"a", "d"};
string[] searchNumber = {"2", "3"};

// modify the two criteria, the search is now carried out on arrays,
// and no longer on the value
// of each property.

criteria.Add(e => e != null && !searchText.Contains(e.Letter.ToLower()));
criteria.Add(e => e != null && !searchNumber.Contains(e.Number.ToString()));

// the filter code remains unchanged

The letters [A, D] and the numbers [2, 3] are not displayed.

Image 4

This demonstration explains the basic operating principle of the custom DataGrid control.

The Custom Control

Note: Depending on contributions from multiple developers, fixes, and optimization, there may be some differences between the code in this article and the source code, however the principle remains the same.

In the example above, I used the class DataTest and initialized a list to feed the DataGrid.
For the filters to work, I had to know the name of the class and the name of each field, to work around this problem, reflection comes to our rescue, but first, let's see the implementation of headers custom.

Note: All source files are in the folder FilterDataGrid of the project.

To simplify this article, I won't go into detail about customizing the column header, you will find the DataTemplate for the property DataGridColumn.HeaderTemplate in the file FilterDataGrid.xaml.

At this point, what's important to know is that the header custom contains a Button, a popup which itself contains a TextBox for search, a ListBox, a TreeView, and two OK and Cancel Buttons.

Image 5

When the DataGrid is initialized, several methods are called in a specific order, all of these methods have been replaced to provide a specific implementation.

Those which interest us for the moment are OnInitialized, OnAutoGeneratingColumn and OnItemsSourceChanged.

FilterDataGrid class (simplified):

Image 6

The method OnInitialized only takes care of manually defined columns in the code of the XAML page (AutoGenerateColumns="False").

  • DataGridTemplateColumn
  • DataGridTextColumn
XML
<control: FilterDataGrid.Columns>
  <control: DataGridTextColumn IsColumnFiltered="True" ... />
  <control: DataGridTemplateColumn IsColumnFiltered="True" FieldName="LastName" ... />

These two types of columns have been extended with their base class and two DependencyProperty have been implemented, IsColumnFiltered and FieldName, see the file DataGridColumn.cs.

DataGridTemplateColumn and DataGridTextColumn class:

Image 7

A loop cycles through the available columns of the DataGrid and replaces the original HeaderTemplate with the custom template.

For columns of type DataGridTemplateColumn, the FieldName property must be filled in when implementing the custom column, because the binding can be done on any type of control in the template, for example, a TextBox, or Label.

C#
// FilterLanguage : default : 0 (english)
Translate = new Loc {Language = (int) FilterLanguage};

// DataGridTextColumn
var column = (DataGridTextColumn)col;
column.HeaderTemplate = (DataTemplate)FindResource("DataGridHeaderTemplate");
column.FieldName = ((Binding)column.Binding).Path.Path;

The method OnAutoGeneratingColumn only deals with automatically generated columns of type System.Windows.Controls.DataGridTextColumn, there are as many calls to this method as there are columns, the current column is contained in the event handler DataGridAutoGeneratingColumnEventArgs.

C#
e.Column = new DataGridTextColumn
{
            FieldName = e.PropertyName,
            IsColumnFiltered = true,
            HeaderTemplate = (DataTemplate)FindResource("DataGridHeaderTemplate")
            ...
};

The method OnItemsSourceChanged is responsible for initializing ICollectionView and defining the filter (which will be detailed later) as well as reinitializing the source collection loaded previously.

C#
// initialize the collection view, the data source is ItemsSource of the DataGrid
CollectionViewSource =
          System.Windows.Data.CollectionViewSource.GetDefaultView(ItemsSource);

// set Filter
CollectionViewSource.Filter = Filter;

// get type of data source
collectionType = ItemsSource?.Cast<object>().First().GetType();

Custom column headers once the application is run.

Image 8

When clicking on the button (down arrow), a popup window opens, all the content of this popup is generated by the method ShowFilterCommand and the event of the command ExecutedRoutedEventArgs contains the property OriginalSource (the button).

It is not the button which is the parent of the popup, it is the header (of which the button is a child).
Using methods of the class VisualTreeHelper can browse the visual tree and "discover" the elements
that interest us.
Note: You will find all of these class and their methods in FilterHelpers.cs.

Once the header has been retrieved, we can go down in the tree to retrieve the other elements.

Image 9

C#
button = (Button) e.OriginalSource;
var header = VisualTreeHelpers.FindAncestor<DataGridColumnHeader>(button);
popup = VisualTreeHelpers.FindChild<Popup>(header, "FilterPopup");
var columnType = header.Column.GetType();

// get field name from binding Path
if (columnType == typeof(DataGridTextColumn))
{
 var column = (DataGridTextColumn) header.Column;
 fieldName = column.FieldName;
}

// get field name by custom DependencyProperty "FieldName"
if (columnType == typeof(DataGridTemplateColumn))
{
 var column = (DataGridTemplateColumn)header.Column;
 fieldName = column.FieldName;
}

// get type of field
Type fieldType = null;
var fieldProperty = collectionType.GetProperty(fieldName);

// get type or get underlying type if nullable
if (fieldProperty! = null)
 fieldType = Nullable.GetUnderlyingType
             (fieldProperty.PropertyType)??fieldProperty.PropertyType;

At the beginning of the chapter, we saw that for each field, we needed a list of values and a predicate, as we ignore in advance the number of fields, we must use a specific class which will contain this information and some methods and fields for managing the filter and the tree structure (in the case of a DateTime type control).

FilterCommon class (simplified):

Image 10

The method AddFilter of this class, is responsible for adding the predicate to the Dictionary of global scope, declared in the class FilterDataGrid.

C#
// Dictionary declaration, the string key is the name of the field
private readonly Dictionary<string, Predicate<object>> criteria =
                                    new Dictionary<string, Predicate<object>>();
C#
public void AddFilter(Dictionary<string, Predicate<object>> criteria)
{
  if (IsFiltered) return;

  // predicat
  bool Predicate(object o)
  {
    var value = o.GetType().GetProperty(FieldName)?.GetValue(o, null);

    // find the value in the list of values (PreviouslyFilteredItems)
    return! PreviouslyFilteredItems.Contains(value);
  }

  criteria.Add(FieldName, Predicate);
  IsFiltered = true;
}

Let's continue by checking if the filter of the current field is already present in the list of filters, if it is the case, we recover it, otherwise we create a new one.

C#
// if no filter, add new filter for the current fieldName
CurrentFilter = GlobalFilterList.FirstOrDefault(f => f.FieldName == fieldName) ??
                     new FilterCommon
                     {
                       FieldName = fieldName,
                       FieldType = fieldType
                     };

At this point, we have implemented all the elements necessary for the operation of the filter seen in the previous demonstration.

  • IcollectionView
  • List of values to filter
  • Predicate

It's time to fill in the ListBox or the TreeView depending on the type of field to filter.

The property Items of the DataGrid contains the collection of items displayed in the view, not to be confused with ItemsSource which contains the data source.

Reflection is used to retrieve the value of the field.

C#
sourceObjectList = new List<object>();

// retrieves the values of the "FieldName" field and removes the duplicates.
sourceObjectList = Items.Cast<object>()
.Select (x => x.GetType().GetProperty(fieldName)?.GetValue(x, null))
.Distinct() // clear duplicate values before select
.Select(item = > item)
.ToList();

We keep these raw values in a list that will be used later to compare the elements to be filtered and those which are not.

C#
// only the raw values of the items of the datagrid view
rawValuesDataGridItems = new List<object>(sourceObjectList);

If the name of the field is equal to the name of the last filtered field, the already filtered values of this field are added to this list.

C#
if (lastName == CurrentFilter.FieldName)
sourceObjectList.AddRange(PreviouslyFilteredItems);

The presentation as a checkbox depends on the field type, DateTime for the TreeView and all other types for the ListBox.

It is therefore necessary to create another collection of objects which manages the search and the events which are triggered by the check boxes, in order to obtain the status "checked" or "not checked", it's this state which will determine the values to filter.
The class responsible for this is FilterItem.

FilterItem class (simplified):

Image 11

The field Label is the displayed value, the field Content is the raw value, IsChecked is the state of the checkbox, and IsDateChecked is used for dates.

C#
// add the first element (select all) at List
var filterItemList = new List<FilterItem>{new FilterItem
                             {Id = 0, Label = Loc.All, IsChecked = true}};

// fill the list
for (var i = 0; i < sourceObjectList.Count; i ++)
{
  var item = sourceObjectList[i];
  var filterItem = new FilterItem
      {
        Id        = filterItemList.Count,
        FieldType = fieldType,
        Content   = item,
        Label     = item? .ToString (), // Content displayed

        // check or uncheck if the item (value)
        // exists in the previously filtered elements
        IsChecked =! CurrentFilter?.PreviouslyFilteredItems.Contains(item) ?? false
      };

  filterItemList.Add(filterItem);
}

All that remains is to pass this collection to the ItemsSource property of the ListBox or to generate the hierarchical tree for the TreeView in the case of a DateTime type field (see the method BuildTree of the class FilterCommon).

C#
if (fieldType == typeof(DateTime))
{
  // TreeView
  treeview = VisualTreeHelpers.FindChild<TreeView>(popup.Child, "PopupTreeview");
  treeview.ItemsSource = CurrentFilter?.BuildTree(sourceObjectList, lastFilterName);
  ...
}
else {
  // ListBox
  listBox = VisualTreeHelpers.FindChild<ListBox>(popup.Child, "PopupListBox");
  listBox.ItemsSource = filterItemList;
  ...
}

Each PopUp contains a search TextBox to filter the items, this functionality requires a specially dedicated filter, again ICollectionView is used.

C#
// set CollectionView
ItemCollectionView =
    System.Windows.Data.CollectionViewSource.GetDefaultView(filterItemList);

// set filter in popup
ItemCollectionView.Filter = SearchFilter;

Finally, the PopUp is open.

C#
popup.IsOpen = true;

PopUp example, I added a DateTime field to the DataTest test class for demonstration.

ListBox and TreeView:

Image 12 Image 13

In both cases, the search is carried out through the ListBox or the TreeView and only displays the elements that contain the sought value, when validating, these elements remain displayed in the DataGrid.

Items Checked remain visible in the DataGrid, the raw value of other items is stored in the list PreviouslyFilteredItems of each filter, this operation is carried out in the method ApplyFilterCommand when the Ok button is clicked.

The method ApplyFilterCommand has the task of keeping the list of elements to filter up to date.

Except and Intersect are Linq methods, here is a diagram which explains how these two operations work.

Image 14

C#
// unchecked items : list of the content of the items to filter
var uncheckedItems = new List<object>();

// checked items : list of the content of items not to be
filtered var checkedItems = new List<object>();

// to test if unchecked items are checked again
var contain = false;

// items already filtered
var previousFilteredItems = new List<object>(CurrentFilter.PreviouslyFilteredItems);

// get all items listbox/treeview from popup
var viewItems = ItemCollectionView?.Cast<FilterItem>().Skip(1).ToList()??
                new List<FilterItem>();

// items to be not filtered (checked)
checkedItems = viewItems.Where(f => f.IsChecked).Select(f => f.Content).ToList();

// unchecked:
// the search variable (bool) indicates if this is the search result
// rawValuesDataGridItems is only items displayed in datagrid

if (search) {
  uncheckedItems = rawValuesDataGridItems.Except(checkedItems).ToList();
}
else {
uncheckedItems = viewItems.Where(f =>! f.IsChecked).Select (f => f.Content).ToList();
}

The code for dates works the same, except that the list of items is retrieved by the method GetAllItemsTree of the class FilterCommon.

C#
// get the list of dates from the TreeView (any state: checked / not checked)
var dateList = CurrentFilter.GetAllItemsTree();

// items to be not filtered (checked)
checkedItems = dateList.Where(f => f.IsChecked).Select(f => f.Content).ToList();

Once these lists have been retrieved (checkedItems and uncheckedItems), we must test whether any filtered items have again been checked by an Intersection between checkedItems and previousFilteredItems.

C#
 // check if unchecked(filtered) items have been checked
 contain = checkedItems.Intersect(previousFilteredItems).Any();

 // if that is the case !
 // remove filtered items that should no longer be filtered
 if (contain)
   previousFilteredItems = previousFilteredItems.Except(checkedItems).ToList();

// add the previous filtered items to the list of new items to filter
uncheckedItems.AddRange(previousFilteredItems);

Fill in the HashSet PreviouslyFilteredItems with the elements to filter, HashSet automatically removes duplicates.
HashSet is used because it is the fastest for searching.

C#
// fill the PreviouslyFilteredItems HashSet with unchecked items
  CurrentFilter.PreviouslyFilteredItems =
         new HashSet<object>(uncheckedItems, EqualityComparer<object>.Default);

// add a filter to criteria dictionary if it is not already added previously
if (!CurrentFilter.IsFiltered)
  CurrentFilter.AddFilter(criteria);

// add current filter to GlobalFilterList
if (GlobalFilterList.All(f => f.FieldName! = CurrentFilter.FieldName))
                        GlobalFilterList.Add(CurrentFilter);

// set the current field name as the last filter name
lastFilter = CurrentFilter.FieldName;

The following statement triggers the filter and refreshes the DataGrid view.

C#
// apply filter
CollectionViewSource.Refresh();

// remove the current filter if there is no items to filter
if (!CurrentFilter.PreviouslyFilteredItems.Any())
      RemoveCurrentFilter();

Finally, here are the two filtering methods, the first is the one that applies the filter by aggregating all predicates, the second is the search method for all popups.

Aggregate performs an operation on each element of the list taking into account the operations which have preceded.

C#
// Filter by aggregation of dictionary predicates.
private bool Filter (object o)
{
  return criteria.Values.Aggregate(true,
         (prevValue, predicate) => prevValue && predicate (o));
}

// Common search filter for all popups
private bool SearchFilter(object obj)
{
  var item = (FilterItem) obj;

  // item.Id == 0, is the item (select all)
  if (string.IsNullOrEmpty(searchText) || item == null || item.Id == 0) return true;

  // DateTime type
  if (item.FieldType == typeof(DateTime))
  return ((DateTime?) item.Content)?.ToString ("d") // date format
                      .IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0;
  // other types
  return item.Content?.ToString().IndexOf
         (searchText, StringComparison.OrdinalIgnoreCase) > = 0;
}

How to Use

  1. They are two way to install 
    • Nuget command : Install Package FilterDataGrid.
    • Or add FilDataGrid project as a reference to your main project.
  2. Add FilterDataGrid control into your XAML page:
    • Namespace:
      XML
      xmlns:control="http://filterdatagrid.control.com/2021"
      
    • Control:
      XML
      <control:FilterDataGrid
                  FilterLanguge="Italian"
                  ShowStatusBar="True"
                  ShowElapsedTime="True"
                  DateFormatString="d" ...
      

      * If you add custom columns, you must set AutoGenerateColumns="False".

      • Properties:

        • ShowStatusBar: Displays the status bar, default: false
        • ShowElapsedTime: Displays the elapsed time of filtering in status bar, default: false
        • DateFormatString: Date display format, default: "d"
        • FilterLanguage:Translation into available language, default: English
      • Languages available : English, French, Russian, German, Italian, Chinese, Dutch
      • The translations are from Google translate. If you find any errors or want to add other languages, please let me know.
    • Custom TextColumn:
      XML
      <control:FilterDataGrid.Columns>
          <Control:DataGridTextColumn IsColumnFiltered="true" ...
      
    • Custom TemplateColumn

      * The property FieldName of DataGridTemplateColumn is required.

      XML
      <control:FilterDataGrid.Columns>
           <control:DataGridTemplateColumn IsColumnFiltered="True"
                                           FieldName="LastName" ...
      

Benchmark

Intel Core i7, 2.93 GHz, 16 GB, Windows 10, 64 bits.
Tested on the "LastName" column of the demo application using a random distinct name generator between 5 and 8 letters in length.
The elapsed time decreases according to the number of columns and the number of filtered elements.

Number of lines Opening of the PopUp Applying the filter Total (PopUp + Filter)
1000 < 1 second < 1 second < 1 second
100,000 < 1 second < 1 second < 1 second
500,000 ± 1.5 second < 1 second ± 2.5 seconds
1 000 000 ± 3 seconds ± 1.5 seconds ± 4.5 seconds

You can display the elapsed time by activating the status bar ShowStatusBar="True" and ShowElapsedTime="True".

Image 15

History

  • 24th January, 2021: Initial version
  • 27th January, 2021: The language change is no longer done by editing the Loc.cs file but by filling in the newly implemented "FilterLanguage" property. In this way, several controls on the same page can have different languages.
  • 11th April, 2021: Correction of several logic errors including dates, the consideration of culture for the display of numerical data, optimizations and contributions to improving reliability.

License

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

Share

About the Author

Macabies Gilles
Software Developer (Senior)
France (Metropolitan) France (Metropolitan)
No Biography provided

Comments and Discussions

 
QuestionRebuild DataGridFilter if ItemSource or DataContext changed Pin
la356115-Mar-21 0:13
Memberla356115-Mar-21 0:13 
AnswerRe: Rebuild DataGridFilter if ItemSource or DataContext changed Pin
Macabies Gilles25-Mar-21 0:02
MemberMacabies Gilles25-Mar-21 0:02 
QuestionWonderful Tool Pin
AnthonyD4218-Feb-21 6:48
MemberAnthonyD4218-Feb-21 6:48 
AnswerMessage Closed Pin
25-Mar-21 0:36
MemberMacabies Gilles25-Mar-21 0:36 
AnswerRe: Wonderful Tool Pin
Macabies Gilles25-Mar-21 1:42
MemberMacabies Gilles25-Mar-21 1:42 
QuestionNice work! Pin
Benny S. Tordrup25-Jan-21 20:48
MemberBenny S. Tordrup25-Jan-21 20:48 
AnswerRe: Nice work! Pin
Macabies Gilles25-Jan-21 23:05
MemberMacabies Gilles25-Jan-21 23:05 
AnswerRe: Nice work! Pin
Macabies Gilles27-Jan-21 1:12
MemberMacabies Gilles27-Jan-21 1:12 

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.