Introduction
The WPF GridView is a powerful WPF control used in displaying data in a Grid like format. Although similar to the DataGrid control, the GridView lacks some built-in features such as sorting the data when a column header is clicked. My research online for existing solutions yielded numerous code-behind examples but I was not content with these because...
- They were not very MVVM friendly
- I would have to re-implement the sorting code every time I create a new GridView
- If I wanted to modify existing code, I would have to go back and modify each and every xaml.cs file individually.
Eventually I stumbled upon a great solution which was created by Thomas Levesque. Levesque uses GridViewColumn attached properties that define the sort property name and attaches a subscriber to the routed event GridViewColumnHeader.ClickEvent. This subscriber then uses CollectionView.SortDescriptions to sort the ListView Items.
Although Levesque's implementation is very solid, it was missing a few features that I consider vital in a sort-able Gridview such as drawing sorting arrows in the column header (without an Adorner) and multi-sorting by Shift-Clicking a column header. So I decided to use his code as the foundation for my custom control.
Sortable GridViewColumnHeader
My implementation consist of a class derived from GridViewColumnHeader, which I named SortableGridViewColumnHeader (very creative, I know). It has two dependency properties
Type | Name |
---|---|
System.Nullable<System.ComponentModel.ListSortDirection> | SortDirection |
System.String | SortPropertyName |
- SortDirection is a read-only dependency property that defines the current sort direction of the column. This is useful for drawing an arrow that changes direction if this property gets modified.
- SortPropertyName is a dependency property that defines the binding path. This dependency property is important because there's no reasonable way for the header to determine the binding of the CellTemplate.
It is important that a column knows when an adjacent column is clicked so that it may clear its SortDirection. I accomplish this by overriding OnVisualParentChanged(). When this method is called, we are able to move up the visual tree to look for the ancestor ListView, get its ICollectionView, then subscribe to SortDescriptions.CollectionChanged.
protected override void OnVisualParentChanged(DependencyObject oldParent) { base.OnVisualParentChanged(oldParent); if (GetAncestor<ListView>(this) is ListView listView) { ((INotifyCollectionChanged)listView.Items.SortDescriptions).CollectionChanged += SortDescriptions_CollectionChanged; } }
If a SortDescription is added and it matches the SortPropertyName Dependency Property, it will update the SortDirection Dependency Property. If a SortDescription is removed, the SortDirection will be set to null.
case NotifyCollectionChangedAction.Add: foreach (SortDescription description in e.NewItems) { if (description.PropertyName == SortPropertyName) { SortDirection = description.Direction; } } break; case NotifyCollectionChangedAction.Remove: foreach (SortDescription description in e.OldItems) { if (description.PropertyName == SortPropertyName) { SortDirection = null; } } break;
In the event that a user clicked a column header that was already sorted, I needed a way to replace the SortDescription with it's opposite Direction. I would have liked to add a case statement for NotifyCollectionChangedAction.Replace, however I came across a complication. When you replace a SortDescription in a SortDescriptionCollection, (Ex. SortDescriptions[i] = new SortDescription()) case statements Remove then Add are called instead of Replace. I'm not sure if this is intentional, because an ObservableCollection would've called Replace in this scenario. Regardless, I had to resort to hacking a workaround to implement this functionality.
((INotifyCollectionChanged)view.SortDescriptions).CollectionChanged -= SortDescriptions_CollectionChanged; view.SortDescriptions[i] = new SortDescription(SortPropertyName, direction); SortDirection = direction; ((INotifyCollectionChanged)view.SortDescriptions).CollectionChanged += SortDescriptions_CollectionChanged;
Overall this implementation has been working great for me and has definitely improved the portability of my code
SortableGridViewColumnHeader is compatible with any WPF visual library that supports a Nullable<ListSortDirection> Template binding. Next I will show an example using MaterialDesign in Xaml.
Styling with Material Design
Included in project is a style that could be used with the Material Design in Xaml, a free open source UI library that implements Google's Material Design guidelines.
My style uses a Material Design control named ListSortDirectionIndicator, which animates the an arrow based on the SortDirection Dependency Property
If you're using Material Design in Xaml, it's pretty simple to import the style into your project, simply build and import the DLL into your project, then add the Resource Dictionary to your merged dictionaries in your App.xaml like so.
<ResourceDictionary.MergedDictionaries> <!-- Material Design Resource Dictionaries go here --> <ResourceDictionary Source="pack://application:,,,/CoderJesus.SortableGridView;component/Themes/Coderjesus.SortableGridViewColumnHeader.xaml"/> </ResourceDictionary.MergedDictionaries>
Define the namespace
xmlns:sgv="clr-namespace:CoderJesus.SortableGridView;assembly=CoderJesus.SortableGridView"
Then build your ListView defining the Sortable Column Header
<GridViewColumn> <GridViewColumn.Header> <sgv:SortableGridViewColumnHeader SortPropertyName="Name"> <TextBlock Text="Name"/> </sgv:SortableGridViewColumnHeader> </GridViewColumn.Header> <GridViewColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding Name}"/> </DataTemplate> </GridViewColumn.CellTemplate> </GridViewColumn>
The ListView should look something like this!
Currently there is an issue with the Material Design library which is affecting my GridView. When a user hovers their mouse over the scrolling thumb (the thumb where you resize a column) no cursor appears. I have reported this issue here and have been personally looking into it. I will report back if I find a solution!
Thanks for reading!
-Update 11/30/2019- I have solved this issue on my end and reported a possible solution to the MaterialDesign devs!
-Update 12/7/2019- The MaterialDesign devs have accepted my pull request :) This bug should be resolved in MaterialDesign 3.0.0.