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.

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:
<DataGrid
x:Name="MyDataGrid"
Width="300"
Height="300"/>
...
internal class DataTest
{
public string Letter {get; set; }
}
public MainWindow()
{
InitializeComponent();
string searchText = "a"
List<DataTest> data = new List<DataTest> {
new DataTest{Letter = "A"},
new DataTest{Letter = "B"},
new DataTest{Letter = "C"}
new DataTest{Letter = "D"}};
ICollectionView itemsView = CollectionViewSource.GetDefaultView(data);
MyDataGrid.ItemSource = itemsView;
itemsView.Filter = delegate(object o)
{
var item = (DataTest)o;
return item?.Letter.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) < 0;
};
}
The letter "A" is not displayed.

That works fine, but how do you filter a second column?
Let's start by adding a new property 'Number'
to the DataTest
class.
internal class DataTest
{
public string Letter {get; set; }
public int Number {get; set; }
}
string searchText = "a"
string searchNumber = "2";
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}};
var criteria = new List<Predicate<DataTest>>();
var ignoreCase = StringComparison.OrdinalIgnoreCase;
criteria.Add(e => e! = null && e.Letter.IndexOf(searchText, ignoreCase) <0);
criteria.Add(e => e! = null &&
e.Number.ToString().IndexOf(searchNumber, ignoreCase) < 0);
itemsView.Filter = delegate(object o)
{
var item = o as DataTest;
return criteria.TrueForAll(x => x (item));
};
The letter "A" and the number 2 are not displayed.

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 string
s.
string[] searchText = {"a", "d"};
string[] searchNumber = {"2", "3"};
criteria.Add(e => e != null && !searchText.Contains(e.Letter.ToLower()));
criteria.Add(e => e != null && !searchNumber.Contains(e.Number.ToString()));
The letters [A, D
]
and the numbers [2, 3]
are not displayed.

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 Button
s.

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):

The method OnInitialized
only takes care of manually defined columns in the code of the XAML page (AutoGenerateColumns="False"
).
DataGridTemplateColumn
DataGridTextColumn
<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:

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
.
Translate = new Loc {Language = (int) FilterLanguage};
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
.
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.
CollectionViewSource =
System.Windows.Data.CollectionViewSource.GetDefaultView(ItemsSource);
CollectionViewSource.Filter = Filter;
collectionType = ItemsSource?.Cast<object>().First().GetType();
Custom column headers once the application is run.

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.

button = (Button) e.OriginalSource;
var header = VisualTreeHelpers.FindAncestor<DataGridColumnHeader>(button);
popup = VisualTreeHelpers.FindChild<Popup>(header, "FilterPopup");
var columnType = header.Column.GetType();
if (columnType == typeof(DataGridTextColumn))
{
var column = (DataGridTextColumn) header.Column;
fieldName = column.FieldName;
}
if (columnType == typeof(DataGridTemplateColumn))
{
var column = (DataGridTemplateColumn)header.Column;
fieldName = column.FieldName;
}
Type fieldType = null;
var fieldProperty = collectionType.GetProperty(fieldName);
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):

The method AddFilter
of this class, is responsible for adding the predicate to the Dictionary
of global scope, declared in the class FilterDataGrid
.
private readonly Dictionary<string, Predicate<object>> criteria =
new Dictionary<string, Predicate<object>>();
public void AddFilter(Dictionary<string, Predicate<object>> criteria)
{
if (IsFiltered) return;
bool Predicate(object o)
{
var value = o.GetType().GetProperty(FieldName)?.GetValue(o, null);
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.
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.
sourceObjectList = new List<object>();
sourceObjectList = Items.Cast<object>()
.Select (x => x.GetType().GetProperty(fieldName)?.GetValue(x, null))
.Distinct()
.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.
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.
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):

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.
var filterItemList = new List<FilterItem>{new FilterItem
{Id = 0, Label = Loc.All, IsChecked = true}};
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 (),
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
).
if (fieldType == typeof(DateTime))
{
treeview = VisualTreeHelpers.FindChild<TreeView>(popup.Child, "PopupTreeview");
treeview.ItemsSource = CurrentFilter?.BuildTree(sourceObjectList, lastFilterName);
...
}
else {
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.
ItemCollectionView =
System.Windows.Data.CollectionViewSource.GetDefaultView(filterItemList);
ItemCollectionView.Filter = SearchFilter;
Finally, the PopUp
is open.
popup.IsOpen = true;
PopUp
example, I added a DateTime
field to the DataTest
test class for demonstration.
ListBox
and TreeView
:

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.

var uncheckedItems = new List<object>();
filtered var checkedItems = new List<object>();
var contain = false;
var previousFilteredItems = new List<object>(CurrentFilter.PreviouslyFilteredItems);
var viewItems = ItemCollectionView?.Cast<FilterItem>().Skip(1).ToList()??
new List<FilterItem>();
checkedItems = viewItems.Where(f => f.IsChecked).Select(f => f.Content).ToList();
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
.
var dateList = CurrentFilter.GetAllItemsTree();
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
.
contain = checkedItems.Intersect(previousFilteredItems).Any();
if (contain)
previousFilteredItems = previousFilteredItems.Except(checkedItems).ToList();
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.
CurrentFilter.PreviouslyFilteredItems =
new HashSet<object>(uncheckedItems, EqualityComparer<object>.Default);
if (!CurrentFilter.IsFiltered)
CurrentFilter.AddFilter(criteria);
if (GlobalFilterList.All(f => f.FieldName! = CurrentFilter.FieldName))
GlobalFilterList.Add(CurrentFilter);
lastFilter = CurrentFilter.FieldName;
The following statement triggers the filter and refreshes the DataGrid
view.
CollectionViewSource.Refresh();
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.
private bool Filter (object o)
{
return criteria.Values.Aggregate(true,
(prevValue, predicate) => prevValue && predicate (o));
}
private bool SearchFilter(object obj)
{
var item = (FilterItem) obj;
if (string.IsNullOrEmpty(searchText) || item == null || item.Id == 0) return true;
if (item.FieldType == typeof(DateTime))
return ((DateTime?) item.Content)?.ToString ("d")
.IndexOf(searchText, StringComparison.OrdinalIgnoreCase) >= 0;
return item.Content?.ToString().IndexOf
(searchText, StringComparison.OrdinalIgnoreCase) > = 0;
}
How to Use
- They are two way to install
- Nuget command :
Install Package FilterDataGrid.
- Or add
FilDataGrid
project as a reference to your main project.
- Add
FilterDataGrid
control into your XAML page:
Namespace
:
xmlns:control="http://filterdatagrid.control.com/2021"
Control
:
<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:
<control:FilterDataGrid.Columns>
<Control:DataGridTextColumn IsColumnFiltered="true" ...
- Custom TemplateColumn
* The property FieldName
of DataGridTemplateColumn
is required.
<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"
.

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.