Filtering the WPF DataGrid automatically via the header (inline filtering)






4.81/5 (31 votes)
This will help you create a grid that has inline filtering like you see in DevExpress / Telerik.
Problem
Before switching to WPF, we used WinForms + DevExpress. For reasons like performance, time, … I liked DevExpress. But now, with WPF, it feels strange to use commercial controls. I feel like the sky is the limit and I could do whatever I want…
…until I wanted to filter the DataGrid
.
I want to achieve something similar that was used in the DevExpress XtraGrid. That filter was so nice!
Customizing the WPF DataGrid
OK, I always want the best for my customers, so let's do something nicer than the default thingies. I don’t know what UX article it was from, but I read that controls should not pollute the UI. They should only be visible when needed. And that is what we’re going to build.
The grid will only show the filter textbox when hovering the header. Once the filter is applied (automatically), the header text itself will update automatically.
Getting started
We’ll need to do the following:
- Create a lookless grid control that implements this filtering functionality (
FilterDataGrid
) - Create a nice style for the header of the grid
- Create a converter that will format our header text
Creating the lookless control
Here is the code:
/// <summary>
/// A grid that makes inline filtering possible.
/// </summary>
public class FilteringDataGrid : DataGrid
{
/// <summary>
/// This dictionary will have a list of all applied filters
/// </summary>
private Dictionary<string, string> columnFilters;
/// <summary>
/// Cache with properties for better performance
/// </summary>
private Dictionary<string, PropertyInfo> propertyCache;
/// <summary>
/// Case sensitive filtering
/// </summary>
public static DependencyProperty IsFilteringCaseSensitiveProperty =
DependencyProperty.Register("IsFilteringCaseSensitive",
typeof(bool), typeof(FilteringDataGrid), new PropertyMetadata(true));
/// <summary>
/// Case sensitive filtering
/// </summary>
public bool IsFilteringCaseSensitive
{
get { return (bool)(GetValue(IsFilteringCaseSensitiveProperty)); }
set { SetValue(IsFilteringCaseSensitiveProperty, value); }
}
/// <summary>
/// Register for all text changed events
/// </summary>
public FilteringDataGrid()
{
// Initialize lists
columnFilters = new Dictionary<string, string>();
propertyCache = new Dictionary<string, PropertyInfo>();
// Add a handler for all text changes
AddHandler(TextBox.TextChangedEvent,
new TextChangedEventHandler(OnTextChanged), true);
// Datacontext changed, so clear the cache
DataContextChanged += new
DependencyPropertyChangedEventHandler(
FilteringDataGrid_DataContextChanged);
}
/// <summary>
/// Clear the property cache if the datacontext changes.
/// This could indicate that an other type of object is bound.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void FilteringDataGrid_DataContextChanged(object sender,
DependencyPropertyChangedEventArgs e)
{
propertyCache.Clear();
}
/// <summary>
/// When a text changes, it might be required to filter
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void OnTextChanged(object sender, TextChangedEventArgs e)
{
// Get the textbox
TextBox filterTextBox = e.OriginalSource as TextBox;
// Get the header of the textbox
DataGridColumnHeader header =
TryFindParent<DataGridColumnHeader>(filterTextBox);
if (header != null)
{
UpdateFilter(filterTextBox, header);
ApplyFilters();
}
}
/// <summary>
/// Update the internal filter
/// </summary>
/// <param name="textBox"></param>
/// <param name="header"></param>
private void UpdateFilter(TextBox textBox, DataGridColumnHeader header)
{
// Try to get the property bound to the column.
// This should be stored as datacontext.
string columnBinding = header.DataContext != null ?
header.DataContext.ToString() : "";
// Set the filter
if (!String.IsNullOrEmpty(columnBinding))
columnFilters[columnBinding] = textBox.Text;
}
/// <summary>
/// Apply the filters
/// </summary>
/// <param name="border"></param>
private void ApplyFilters()
{
// Get the view
ICollectionView view = CollectionViewSource.GetDefaultView(ItemsSource);
if (view != null)
{
// Create a filter
view.Filter = delegate(object item)
{
// Show the current object
bool show = true;
// Loop filters
foreach (KeyValuePair<string, string> filter in columnFilters)
{
object property = GetPropertyValue(item, filter.Key);
if (property != null)
{
// Check if the current column contains a filter
bool containsFilter = false;
if (IsFilteringCaseSensitive)
containsFilter = property.ToString().Contains(filter.Value);
else
containsFilter =
property.ToString().ToLower().Contains(filter.Value.ToLower());
// Do the necessary things if the filter is not correct
if (!containsFilter)
{
show = false;
break;
}
}
}
// Return if it's visible or not
return show;
};
}
}
/// <summary>
/// Get the value of a property
/// </summary>
/// <param name="item"></param>
/// <param name="property"></param>
/// <returns></returns>
private object GetPropertyValue(object item, string property)
{
// No value
object value = null;
// Get property from cache
PropertyInfo pi = null;
if (propertyCache.ContainsKey(property))
pi = propertyCache[property];
else
{
pi = item.GetType().GetProperty(property);
propertyCache.Add(property, pi);
}
// If we have a valid property, get the value
if (pi != null)
value = pi.GetValue(item, null);
// Done
return value;
}
/// <summary>
/// Finds a parent of a given item on the visual tree.
/// </summary>
/// <typeparam name="T">The type of the queried item.</typeparam>
/// <param name="child">A direct or indirect
/// child of the queried item.</param>
/// <returns>The first parent item that matches the submitted
/// type parameter. If not matching item can be found,
/// a null reference is being returned.</returns>
public static T TryFindParent<T>(DependencyObject child)
where T : DependencyObject
{
//get parent item
DependencyObject parentObject = GetParentObject(child);
//we've reached the end of the tree
if (parentObject == null) return null;
//check if the parent matches the type we're looking for
T parent = parentObject as T;
if (parent != null)
{
return parent;
}
else
{
//use recursion to proceed with next level
return TryFindParent<T>(parentObject);
}
}
/// <summary>
/// This method is an alternative to WPF's
/// <see cref="VisualTreeHelper.GetParent"/> method, which also
/// supports content elements. Do note, that for content element,
/// this method falls back to the logical tree of the element.
/// </summary>
/// <param name="child">The item to be processed.</param>
/// <returns>The submitted item's parent, if available. Otherwise null.</returns>
public static DependencyObject GetParentObject(DependencyObject child)
{
if (child == null) return null;
ContentElement contentElement = child as ContentElement;
if (contentElement != null)
{
DependencyObject parent = ContentOperations.GetParent(contentElement);
if (parent != null) return parent;
FrameworkContentElement fce = contentElement as FrameworkContentElement;
return fce != null ? fce.Parent : null;
}
// If it's not a ContentElement, rely on VisualTreeHelper
return VisualTreeHelper.GetParent(child);
}
}
First, I want to thank Philipp Sumi for his snippet for finding ancestors of dependency objects. I have used his two methods: TryFindParent
and GetParentObject
that you can find at the end of the class.
But let’s get to work now, this is how it works:
- The
FilteringDataGrid
exposes aDependencyProperty
:IsFilteringCaseSensitive
. This is just a flag to that decides whether to do case sensitive checks or not on the filtering. - When the grid is initialized, we also initialize two dictionaries. More about this later. The most important thing happening in the constructor is binding to all
TextChanged
events. - Once some text changes in any
TextBox
in the grid, this event will happen. That's why in the methodOnTextChanged
, we only want to processTextBox
es that are present in theDataGridColumnHeader
. - If this
TextBox
is in theDataGridColumnHeader
, we can process. By using theDataContext
on the header, we’ll find the name of the property that is bound to the current column. That is where our two lists come in handy. - First, we’ll use the
columnsFilter
. This is a dictionary that will keep track of all the current properties with all their current filters. This is important to know because one would want to filter on multiple columns at the same time. - After we update the
columnsFilter
dictionary, we’ll want to apply the whole filter to all the rows. This is done using theICollectionView
interface. - This is where the
propertyCache
comes in handy. We’re actually using Reflection to get the property of the object using the property name. After that, we’ll get the value of the current object for that property and we’ll check if it contains the value from our filter. But since Reflection is so heavy, I want to use some form of cache. If we know the object type, we could store the property that matches a certain name. Doing that, we won’t always need to doitem.GetType().GetProperty()
. - And once the filtering is done for each object (including case sensitive checks or not), we’re done.
- Just a last remark. If the
DataContext
changes (fromList<Country>
toList<City>
, for example), we’ll want to clear thepropertyCache
. Because if we store the property “Name” ofCountry
, and later on we want to use that property to get the value of aCity
object, we’ll get exceptions.
Well, well, we got us a nice filter control. But this control will not show any filter in the header. We still need to apply some styling.
You can style however you want, the only requirement is that you put a TextBox
in the DataGridColumnHeader
. You might ask yourself why I’m not using things like PART_filterControl
or so, but the thing is we don’t have access to the actual DataGridColumnHeader
. We only have access to its style from the DataGrid
itself.
Styling the FilterDataGrid
First some XAML…
<local:HeaderFilterConverter x:Key="headerConverter"/>
<Style TargetType="{x:Type my:DataGridColumnHeader}">
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type my:DataGridColumnHeader}">
<ControlTemplate.Resources>
<Storyboard x:Key="ShowFilterControl">
<ObjectAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="filterTextBox"
Storyboard.TargetProperty="(UIElement.Visibility)">
<DiscreteObjectKeyFrame KeyTime="00:00:00"
Value="{x:Static Visibility.Visible}"/>
<DiscreteObjectKeyFrame
KeyTime="00:00:00.5000000"
Value="{x:Static Visibility.Visible}"/>
</ObjectAnimationUsingKeyFrames>
<ColorAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="filterTextBox"
Storyboard.TargetProperty=
"(Panel.Background).(SolidColorBrush.Color)">
<SplineColorKeyFrame
KeyTime="00:00:00"
Value="Transparent"/>
<SplineColorKeyFrame
KeyTime="00:00:00.5000000"
Value="White"/>
</ColorAnimationUsingKeyFrames>
</Storyboard>
<Storyboard x:Key="HideFilterControl">
<ObjectAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="filterTextBox"
Storyboard.TargetProperty="(UIElement.Visibility)">
<DiscreteObjectKeyFrame KeyTime="00:00:00.4000000"
Value="{x:Static Visibility.Collapsed}"/>
</ObjectAnimationUsingKeyFrames>
<ColorAnimationUsingKeyFrames BeginTime="00:00:00"
Storyboard.TargetName="filterTextBox"
Storyboard.TargetProperty=
"(UIElement.OpacityMask).(SolidColorBrush.Color)">
<SplineColorKeyFrame KeyTime="00:00:00" Value="Black"/>
<SplineColorKeyFrame
KeyTime="00:00:00.4000000"
Value="#00000000"/>
</ColorAnimationUsingKeyFrames>
</Storyboard>
</ControlTemplate.Resources>
<my:DataGridHeaderBorder x:Name="dataGridHeaderBorder"
Margin="0" VerticalAlignment="Top"
Height="31"
IsClickable="{TemplateBinding CanUserSort}"
IsHovered="{TemplateBinding IsMouseOver}"
IsPressed="{TemplateBinding IsPressed}"
SeparatorBrush="{TemplateBinding SeparatorBrush}"
SeparatorVisibility="{TemplateBinding SeparatorVisibility}"
SortDirection="{TemplateBinding SortDirection}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
Grid.ColumnSpan="1">
<Grid x:Name="grid" Width="Auto"
Height="Auto"
RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform>
<TransformGroup>
<ScaleTransform/>
<SkewTransform/>
<RotateTransform/>
<TranslateTransform/>
</TransformGroup>
</Grid.RenderTransform>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ContentPresenter x:Name="contentPresenter"
HorizontalAlignment=
"{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment=
"{TemplateBinding VerticalContentAlignment}"
SnapsToDevicePixels=
"{TemplateBinding SnapsToDevicePixels}"
ContentStringFormat=
"{TemplateBinding ContentStringFormat}"
ContentTemplate=
"{TemplateBinding ContentTemplate}">
<ContentPresenter.Content>
<MultiBinding
Converter="{StaticResource headerConverter}">
<MultiBinding.Bindings>
<Binding
ElementName="filterTextBox"
Path="Text" />
<Binding
RelativeSource="{RelativeSource TemplatedParent}"
Path="Content" />
</MultiBinding.Bindings>
</MultiBinding>
</ContentPresenter.Content>
</ContentPresenter>
<TextBox x:Name="filterTextBox"
HorizontalAlignment="Right"
MinWidth="25" Height="Auto"
OpacityMask="Black"
Visibility="Collapsed" Text=""
TextWrapping="Wrap"
Grid.Column="0"
Grid.ColumnSpan="1"/>
</Grid>
</my:DataGridHeaderBorder>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Trigger.EnterActions>
<BeginStoryboard
x:Name="ShowFilterControl_BeginStoryboard"
Storyboard="{StaticResource ShowFilterControl}"/>
<StopStoryboard
BeginStoryboardName=
"HideFilterControl_BeginShowFilterControl"/>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard
x:Name="HideFilterControl_BeginShowFilterControl"
Storyboard="{StaticResource HideFilterControl}"/>
<StopStoryboard BeginSto
ryboardName="ShowFilterControl_BeginStoryboard"/>
</Trigger.ExitActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
Ok that's a big one. Let me explain in a few words what we're doing.
The thing is we have to modify the style of the header. We actually modify the DataGridHeaderBorder
, this one contains the ContentPresenter
.
Well, the ContentPresenter
is wrapped in a grid with two rows. Row 0 contains ContentPresenter
and row 1 contains the filterTextBox
.
And, if you pay close attention, you’ll see that the content of the ContentPresenter
contains a multi binding. This is set to the name of the column itself and the filter. Well see about this in the next section.
And finally, in the styling of our control, we have some triggers that will cause a nice fade-in/fade-out effect on the filter textbox. If you enter the DataGridColumnHeader
with your mouse, the filterTextBox
will appear after 0.5 sec. If you mouse out, it will disappear after 0.5 sec.
Styling the header even more
The final thing we want to do is show the status of the current filters in the headers. Because, when we move away with our mouse, the filterTextBox
is gone and we want to know if the grid is filtered or not.
That’s why we want the header to show if a filter is enabled. Like this:
Things we’ll have to do:
- Bind the filter text to the header content
- Bind the name/header of the column to the header content
- Apply some formatting to the displayed text (bold)
For this, I’ve created a HeaderFilterConverter
. This class implements the IMultiValueConverter
interface. This means you can input multiple values and return a value.
What we’ll do is, we’ll pass the header/name of the column (e.g.: Name) and the current filter (e.g.: San) and we’ll return a TextBlock
. This TextBlock
should look like this: “Name (Filter: San)”. If there is no text at all, it should only display the header/column text.
For this, we will use an interesting technique where we’ll create some XAML code and convert it to an actual UI element. An example of this dynamic XAML can also be found on: http://msdn.microsoft.com/en-us/library/dd894487(VS.95).aspx
/// <summary>
/// This converter will:
/// - Take the header
/// - Take the filtered word (if any)
/// - Add '(Filter: (bold)x(/bold))' to the header
/// </summary>
public class HeaderFilterConverter : IMultiValueConverter
{
/// <summary>
/// Create a nice looking header
/// </summary>
/// <param name="values"></param>
/// <param name="targetType"></param>
/// <param name="parameter"></param>
/// <param name="culture"></param>
/// <returns></returns>
public object Convert(object[] values, Type targetType, object parameter,
System.Globalization.CultureInfo culture)
{
// Get values
string filter = values[0] as string;
string headerText = values[1] as string;
// Generate header text
string text = "{0}{3}" + headerText + " {4}";
if (!String.IsNullOrEmpty(filter))
text += "(Filter: {2}" + values[0] + "{4})";
text += "{1}";
// Escape special XML characters like <>&'
text = new System.Xml.Linq.XText(text).ToString();
// Format the text
text = String.Format(text,
@"<TextBlock xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'>",
"</TextBlock>", "<Run FontWeight='bold' Text='",
"<Run Text='", @"'/>");
// Convert to stream
MemoryStream stream = new MemoryStream(ASCIIEncoding.UTF8.GetBytes(text));
// Convert to object
TextBlock block = (TextBlock)System.Windows.Markup.XamlReader.Load(stream);
return block;
}
/// <summary>
/// Not required
/// </summary>
/// <param name="value"></param>
/// <param name="targetTypes"></param>
/// <param name="parameter"></param>
/// <param name="culture"></param>
/// <returns></returns>
public object[] ConvertBack(object value, Type[] targetTypes,
object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
}
To-do
Take converters into account. Because it could be that the data in the grid does not match the data the user actually sees.
Downloadable sample
I’ve also prepared a fully working downloadable sample. In this sample, youl have a grid with 1000 random items where you can see the filter in action. It also includes all the code from this guide.
Comments
If you really enjoyed this article, don't hesitate to vote for the article of the month. Enjoy!