Introduction
A common challenge in WPF is to create a ListView
with a Search widget that can filter through its items. This tutorial will show you how to create a Custom Control FilteredListView
that derives from ListView
and allows filtering.
The implementation explained in this article has several important advantages that are missing in other implementations:
- The search widget is part of the
ListView
's template, which makes using it as simple as possible. - The filtering is done immediately as you type the text, without the need to click a button (although, it will be easy to change the code to filter on click instead)
- The filtering uses
<string>
Reactive Extensions (Rx) Throttle to achieve the best user experience and performance. This means that the filtering occurs when the user stopped typing for half a second.
Using the Code
The most simple usage is:
<FilteredListView ItemsSource={Binding Items}/>
As you can see, there's no extra TextBox
needed for the Filter
. The reason is that the TextBox
is included in the custom control's template.
The default filter works by calling .ToString()
on the item. We can customize it and build our own filter. For example, when our items are of type Person
, we can define the Filter Predicate to check both the person's Name
and Occupation
:
<FilteredListView ItemsSource={Binding Items} FilterPredicate="{Binding MyFilter}"/>
In the ViewModel
:
public Func<object, string, bool> MyFilter
{
get
{
return (item, text) =>
{
var person = item as Person;
return person.Name.Contains(text)
|| person.Occupation.Contains(text);
};
}
}
Creating FilteredListView Tutorial
Custom controls should be used when you want to expand on existing functionality, but allow to keep using the existing functionality. Our scenario is a classic example for that. We want to add a Search Filter to a ListView
, but retain all of the ListView
's existing properties and methods.
The first order of business is to create a Custom Control. This is best done in Visual Studio in Project | Add New Item and search for Custom Control (WPF). This will create a class that derives from Control
and a default template in Themes\Generic.xaml.
In the following tutorial, I assume you have basic knowledge in WPF and MVVM. If you're new to Custom Controls and Default Styles (or want to understand them better), I suggest reading Explicit, Implicit and Default styles in WPF.
Creating the Default Style
When creating custom controls, it's best to start with the existing default style in WPF and go from there. This is easiest to do with Blend. After getting the original style from Blend, copy-paste it to Generic.xaml and start editing. All I had to do in this case is to add a TextBox
for the Filter
on top of everything else.
Generic.xaml:
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:FilteredListViewControl">
<FontFamily x:Key="FontAwesome">pack:
/FilteredListViewControl;component/fonts/fontawesome-webfont.ttf#FontAwesome</FontFamily>
<SolidColorBrush x:Key="ListBox.Static.Background" Color="Transparent"/>
<SolidColorBrush x:Key="ListBox.Static.Border" Color="Transparent"/>
<SolidColorBrush x:Key="ListBox.Disabled.Background" Color="#FFFFFFFF"/>
<SolidColorBrush x:Key="ListBox.Disabled.Border" Color="#FFD9D9D9"/>
<Style TargetType="{x:Type local:FilteredListView}" >
<Setter Property="Background" Value="{StaticResource ListBox.Static.Background}"/>
<Setter Property="BorderBrush" Value="{StaticResource ListBox.Static.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Foreground"
Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/>
<Setter Property="ScrollViewer.CanContentScroll" Value="true"/>
<Setter Property="ScrollViewer.PanningMode" Value="Both"/>
<Setter Property="Stylus.IsFlicksEnabled" Value="False"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:FilteredListView}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Padding="0 5">
<Grid>
<TextBox Text="{Binding FilterText, UpdateSourceTrigger=PropertyChanged,
RelativeSource={RelativeSource TemplatedParent}}"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="" FontSize="14"
VerticalAlignment="Center"
HorizontalAlignment="Right"/>
</Grid>
</Border>
<Border Grid.Row="1" x:Name="Bd" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Background="{TemplateBinding Background}" Padding="1" SnapsToDevicePixels="true">
<ScrollViewer Focusable="false" Padding="{TemplateBinding Padding}">
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</ScrollViewer>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="false">
<Setter Property="Background" TargetName="Bd"
Value="{StaticResource ListBox.Disabled.Background}"/>
<Setter Property="BorderBrush" TargetName="Bd"
Value="{StaticResource ListBox.Disabled.Border}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsGrouping" Value="true"/>
<Condition Property="VirtualizingPanel.IsVirtualizingWhenGrouping" Value="false"/>
</MultiTrigger.Conditions>
<Setter Property="ScrollViewer.CanContentScroll" Value="false"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
This might seem like much, but I actually just had to add a TextBox
and a small magnifier-glass icon from Font Awesome:
<Border Padding="0 5">
<Grid>
<TextBox Text="{Binding FilterText, UpdateSourceTrigger=PropertyChanged,
RelativeSource={RelativeSource TemplatedParent}}"/>
<TextBlock FontFamily="{StaticResource FontAwesome}"
Text="" FontSize="14" Margin="0 0"
VerticalAlignment="Center"
HorizontalAlignment="Right"/>
</Grid>
</Border>
Some notes on this code:
- I used
FontAwesome
here for the little magnifier glass icon on the right. This is available with the NuGet package FontAwesome. - The
TextBox
's Text is binded to FilterText
and that binding is set to PropertyChanged
trigger mode. This means the changed callback is called on each key stroke, as opposed to the default behavior where it's called on lost focus.
The Custom Control Code
The entire filtering code is placed in our custom control class, as follows:
public class FilteredListView : ListView
{
static FilteredListView()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(FilteredListView),
new FrameworkPropertyMetadata(typeof(FilteredListView)));
}
public Func<object, string, bool> FilterPredicate
{
get { return (Func<object, string, bool>)GetValue(FilterPredicateProperty); }
set { SetValue(FilterPredicateProperty, value); }
}
public static readonly DependencyProperty FilterPredicateProperty =
DependencyProperty.Register("FilterPredicate",
typeof(Func<object, string, bool>), typeof(FilteredListView), new PropertyMetadata(null));
public Subject<bool> FilterInputSubject = new Subject<bool>();
public string FilterText
{
get { return (string)GetValue(FilterTextProperty); }
set { SetValue(FilterTextProperty, value); }
}
public static readonly DependencyProperty FilterTextProperty =
DependencyProperty.Register("FilterText",
typeof(string),
typeof(FilteredListView),
new PropertyMetadata("",
(d, e) => (d as FilteredListView).FilterInputSubject.OnNext(true)));
public FilteredListView()
{
SetDefaultFilterPredicate();
InitThrottle();
}
private void SetDefaultFilterPredicate()
{
FilterPredicate = (obj, text) => obj.ToString().ToLower().Contains(text);
}
private void InitThrottle()
{
FilterInputSubject.Throttle(TimeSpan.FromMilliseconds(500))
.ObserveOnDispatcher()
.Subscribe(HandleFilterThrottle);
}
private void HandleFilterThrottle(bool b)
{
ICollectionView collectionView = CollectionViewSource.GetDefaultView(this.ItemsSource);
if (collectionView == null) return;
collectionView.Filter = (item) => FilterPredicate(item, FilterText);
}
}
Let's explain what's written here.
- The custom control class derives from
ListView
. This will inherit the full behavior of ListView
and allows us to add to it, which is the point of Custom Controls. - The
static
constructor is boiler-plate code for any custom control that's telling WPF to use your Default Style. FilterPredicate
dependency property is the custom expression of our filter, which can be set from outside. The default implementation simply calls .ToString()
on the item and checks if the text contains FilterText
FilterText
is the property binded to the Text of our TextBox
. On each input change in the TextBox
, we call FilterInputSubject.OnNext(true)
which triggers the Throttle mechanism. After half a second without calls, the Throttle is executed. SetDefaultFilterPredicate
sets the default FilterPredicate
as written above. InitThrottle
initialize the Throttle to fire after 500 milliseconds without action, and then call HandleFilterThrottle
.
Using <string>
Reactive Extensions requires the NuGet packages: System.Reactive.Linq and System.Reactive. HandleFilterThrottle
reapplies the Filter
to our ListView
. It's necessary to set the Filter
property again, since the FilterText
could have been changed.
Summary
This is it for the tutorial. I hope you understood everything and got some benefit from it.
It can be confusing to know when to use Custom Controls or User controls. You can think of User Controls as a reusable UI component that doesn't expand on anything prior. Custom controls, on the other hand, are adding abilities to existing controls. It makes less sense to have a custom control deriving from Control
since it has no existing functionality. Deriving from ListBox
, Button
, or StackPanel
does make sense.
Custom controls are a powerful tool in WPF. I find they require more work initially than user controls, but using them once ready in other Controls is much nicer. This makes them perfect for a dedicated Controls class library in your solution.