Click here to Skip to main content
Click here to Skip to main content

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

By , 25 Aug 2009
Rate this:
Please Sign up or sign in to vote.

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.

Normal state

Filtering (mouse over)

Mouse out (after the filter is applied)

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 a DependencyProperty: 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 method OnTextChanged, we only want to process TextBoxes that are present in the DataGridColumnHeader.
  • If this TextBox is in the DataGridColumnHeader, we can process. By using the DataContext 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 the ICollectionView 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 do item.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 (from List<Country> to List<City>, for example), we’ll want to clear the propertyCache. Because if we store the property “Name” of Country, and later on we want to use that property to get the value of a City 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!

License

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

About the Author

Sandrino Di Mattia
Technical Lead RealDolmen
Belgium Belgium
I'm a Technical Consultant at RealDolmen, one of the largest players on the Belgian IT market: http://www.realdolmen.com
 
All posts also appear on my blogs: http://blog.sandrinodimattia.net and http://blog.fabriccontroller.net
Follow on   Twitter

Comments and Discussions

 
GeneralMy vote of 3 Pinmembercaliff2212-Mar-13 7:04 
SuggestionFix for Column Alias Pinmembersmnabil8-Aug-12 0:48 
SuggestionRe: Fix for Column Alias Pinmemberjbs_3-Sep-12 11:54 
Hi. Thought I'd add another way to fix the column header to actual binding issue.
 
In the actual data grid control class, in the UpdateFilter function, you can get the real binding there. The first line is from before. I added the second line. Obviously it's limited to that column type but you can easily take it from there.
var columnBinding = header.DataContext != null ?
                            header.DataContext.ToString() : "";
if (header.Column is DataGridTextColumn)
    columnBinding = ((Binding) ((DataGridTextColumn) header.Column).Binding).Path.Path;

GeneralMy vote of 5 PinmemberKannan.V17-Jul-12 2:26 
SuggestionRemove Filter [modified] Pinmemberlothy@gmx.de7-Feb-12 23:56 
QuestionNice job Pinmemberc4mel0t10-Nov-11 20:44 
QuestionHow can i hide a column? Pinmemberniloo1716-Oct-11 20:02 
QuestionHow I succeed compile this on VS2010 PinmemberNicolas Mathieu25-Sep-11 5:11 
AnswerRe: How I succeed compile this on VS2010 Pinmembershlom2924-Apr-12 22:55 
GeneralMy vote of 4 Pinmemberxam8re4-Aug-11 1:16 
SuggestionStatic Columns Pinmemberlothy@gmx.de3-Aug-11 6:20 
GeneralRe: Static Columns PinmemberLdehoogh29-Nov-11 0:21 
GeneralRe: Static Columns Pinmembermiliu28-Apr-13 11:55 
QuestionVery simple to use but i've got some problem Pinmembermambox6220-Jun-11 21:18 
AnswerRe: Very simple to use but i've got some problem Pinmembermambox6220-Jun-11 21:54 
GeneralDoesn't compile when converted to VS2010 PinmemberKevin Whitefoot24-May-11 23:56 
GeneralNice, but I didn't want the filter textbox to dissapear if it has the focus... PinmemberMike Emerson19-Feb-11 13:05 
QuestionIs this available in VB? PinmemberGlen Lewis7-Sep-10 6:27 
AnswerRe: Is this available in VB? PinmemberGottardo Stefano2-Feb-11 23:12 
GeneralMy vote of 1 Pinmemberali_cagan12-Sep-10 1:33 
GeneralMy vote of 5 PinmemberGreg Tyndall8-Jul-10 1:51 
QuestionNice work but filtering is slow PinmemberKerem Kat18-Jun-10 1:14 
AnswerRe: Nice work but filtering is slow PinmemberMatthew Wright16-Oct-11 14:16 
GeneralThanks and Problem while convert to vb.net Pinmemberganeshsundaram13-Jun-10 18:58 
GeneralRe: Thanks and Problem while convert to vb.net PinmemberGottardo Stefano2-Feb-11 23:10 
GeneralHelp about filtering in datagrid PinmemberTATINCCR16-May-10 5:45 
GeneralRe: Help about filtering in datagrid PinmemberJohn Mike H.25-Oct-12 7:32 
GeneralThanks and a question Pinmembercwford0117-Mar-10 16:08 
GeneralResizable Columns PinmemberLunchboxtheman30-Oct-09 10:51 
GeneralRe: Resizable Columns Pinmemberigitur5-Sep-12 11:41 
GeneralRe: Resizable Columns Pinmemberigitur27-Sep-12 8:34 
GeneralStatic columns PinmemberMember 412342016-Oct-09 16:33 
GeneralRe: Static columns Pinmemberlothy@gmx.de19-Jul-11 1:59 
QuestionThis grid in WinForms and .NET 3.5? Pinmemberfgoldenstein29-Sep-09 10:27 
Generalgreat filtering, detail question Pinmemberklauswiesel20-Sep-09 23:45 
GeneralGood One Pinmemberjackmos2-Sep-09 3:52 
GeneralRe: Good One PinmemberSandrino Di Mattia2-Sep-09 4:00 
Generalnice one PinmemberChougule Vinay29-Aug-09 0:42 
GeneralRe: nice one PinmemberSandrino Di Mattia1-Sep-09 14:44 

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

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

| Advertise | Privacy | Mobile
Web03 | 2.8.140421.2 | Last Updated 25 Aug 2009
Article Copyright 2009 by Sandrino Di Mattia
Everything else Copyright © CodeProject, 1999-2014
Terms of Use
Layout: fixed | fluid