Manish Dalal's blog

Exploring .net!

Silverlight DataGrid Custom Sorting

Silverlight DataGrid provides automatic sorting functionality for any data source that implements IList. It does this by internally wrapping the data source in a ListCollectionView. However there are many situations where it is desirable to alter the default sort implementation. This is particularly true when the DataGrid is bound to a data source that is paging data from the server. Fortunately, the DataGrid knows to delegate sorting to a data source that implements ICollectionView. This blog post examines what it takes to implement ICollectionView and provides a sample implementation in the form of a reusable SortableCollectionView.

Default Sorting

default sorting I will start with the paging application that I had previously developed as part of Stealth Paging blog post.

The sample application displays a (paged) list of Persons to user. It implements on demand auto paging, where the data is retrieved one page or 20 rows at a time. As user scrolls and nears the end of available data to display, another call is made to the backend service to get more data.

Run the sample application. Now try sorting on the Age field. Sort again to get data in descending order.

 

 

Pre default sort after default sorting 

Notice that the top Person after sorting in descending order has Age of 19. This is because DataGrid is sorting only the data that is available on the client side. However a user would expect to see 100, not 19, assuming that base data consisted of 101 Persons with Age from 0 to 100. Since we are paging data from the server, we need to alter the default sort behavior, so as to carry out server side sorting.

Custom Sorting

We will implement ICollectionView interface on the base ObservableCollection to take control of sorting. ICollectionView interface defines functions for current record management, custom sorting, filtering, and grouping. Current implementation of DataGrid (Dec 2008 release) only uses current record management and custom sorting functionality. Sorting functionality encompasses CanSort and SortDescriptions properties. For custom sorting, we need to listen for changes to SortDescriptions collection, and in turn refresh the data for new sort options.

Create a new SilverlightApplication and the corresponding Web Application to host and test the SilverlightApplication. I will reuse code from Stealth Paging application. Copy over Person.cs, PeopleService.svc and PeopleService.svc.cs files to the web application. Modify Person.cs and add a new field CountyCode as shown:

private int _countyCode;
[DataMember]
public int CountyCode {
    get { return _countyCode; }
    set { _countyCode = value; }
}

This will help us test sub sorting behavior.

Next modify PeopleService.svc.cs class as shown:

[ServiceContract(Namespace = "")]
[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
public class PeopleService {
    [OperationContract]
    public List<Person> GetData(int startRow, int endRow, Dictionary<string, string> sortBy) {
 
        List<Person> personList = new List<Person>();
        for (int i = 0; i < 101; i++) {
            personList.Add(new Person() {
                FirstName = string.Format("First Name {0}", i),
                LastName = string.Format("Last Name {0}", i),
                Age = i,
                City = string.Format("City {0}", i),
                CountyCode = i / 10
            });
        }
        //
        if (null == sortBy || 0 >= sortBy.Keys.Count) {
            return personList.Skip(startRow).Take(endRow - startRow).ToList<Person>();
        }
 
        IEnumerable<Person> source = personList;
        
        foreach (string propertyName in sortBy.Keys) {
            IOrderedEnumerable<Person> orderedEnumerable = source as IOrderedEnumerable<Person>;
            string propName = propertyName;
            if (null == orderedEnumerable) {
                if ("ASC" == sortBy[propertyName]) {
                    source = source.OrderBy<Person, object>(p => GetKeySelector(p, propName));
                } else {
                    source = source.OrderByDescending<Person, object>(p => GetKeySelector(p, propName));
                }
            } else {
                if ("ASC" == sortBy[propertyName]) {
                    source = orderedEnumerable.ThenBy<Person, object>(p => GetKeySelector(p, propName));
                } else {
                    source = orderedEnumerable.ThenByDescending<Person, object>(p => GetKeySelector(p, propName));
                }
            }
        }
        //
        return source.Skip(startRow).Take(endRow - startRow).ToList<Person>();
    }
 
    private object GetKeySelector(Person p, string propertyName) {
        object target = p;
        target = target.GetType().InvokeMember(propertyName, BindingFlags.GetProperty, null, target, null);
        return target;
    }
 
}

Note that we have changed the GetData method to take an additional parameter, Dictionary<string, string> sortBy. Client will request sorting by including one or more columns to sort on and the corresponding sort order, “ASC” for ascending and “DESC” for descending.

In the GetData method, we are first building a list of 101 Person. This is just for demo purpose, in a real application you will not need this step. Next we check to see if the user has requested sorting. If there is no need to sort, we just return the paged data.

If user has provided sortBy data, we loop over sortBy.Keys collection to get properties to sort over. Here we are using LINQ IOrderedEnumerable interface to build composite sort query. For each sort, we also check key’s value to determine the sort order. Finally we add paging functions and execute the composed LINQ query by calling ToList and return the result.

Now lets modify the Silverlight client application to support custom sorting. Add a service reference to the Silverlight client and call is PeopleServiceReference. (use Discover button to find PeopleService). Also add System.Windows.Control.Data reference to the SilverlightApplication project.

 

 

 

Add following xaml to page.xaml:

<UserControl x:Class="SilverlightApplication.Page"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
    xmlns:data="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Data"
    >
    <Grid x:Name="LayoutRoot" Background="White">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <data:DataGrid x:Name="peopleDataGrid" AutoGenerateColumns="True" Grid.Row="0" Margin="10"/>
    </Grid>
</UserControl>

 

Add following code to page.xaml.cs code behind:

public partial class Page : UserControl {
    int _startRow;
    int _pageSize = 20;
    ObservableCollection<Person> _people;
    bool _loading;
    Dictionary<string, string> _sortBy;
 
    public Page() {
        InitializeComponent();
        this.Loaded += new RoutedEventHandler(Page_Loaded);
        this.peopleDataGrid.LoadingRow += new EventHandler<DataGridRowEventArgs>(peopleDataGrid_LoadingRow);
    }
 
    void Page_Loaded(object sender, RoutedEventArgs e) {
        _startRow = 0;
        _people = new ObservableCollection<Person>(); 
        peopleDataGrid.ItemsSource = _people;
        _sortBy = new Dictionary<string, string>();
        GetData(_startRow, _pageSize);
    }
 
    private void GetData(int startRow, int endRow) {
        PeopleServiceClient proxy = new PeopleServiceClient();
        proxy.GetDataCompleted += new EventHandler<GetDataCompletedEventArgs>(proxy_GetDataCompleted);
        _loading = true;
        this.Cursor = Cursors.Wait;
        proxy.GetDataAsync(startRow, endRow, _sortBy);
    }
 
    void proxy_GetDataCompleted(object sender, GetDataCompletedEventArgs e) {
        this.Cursor = Cursors.Arrow;
        _loading = false;
        if (null != e.Error) {
            MessageBox.Show(e.Error.Message, "Error Getting Data", MessageBoxButton.OK);
            return;
        }
        if (0 == _startRow) {
            peopleDataGrid.SelectedItem = null;
            _people.Clear();
        }
        _startRow += _pageSize;
        foreach (Person person in e.Result) {
            _people.Add(person);
        }
    }
 
    void peopleDataGrid_LoadingRow(object sender, DataGridRowEventArgs e) {
        if (_loading || _people.Count < _pageSize) {
            return;
        }
        if (_people.Count - 5 < e.Row.GetIndex()) {
            GetData(_startRow, _startRow + _pageSize);
        }
    }
}

Build and run the application and test out the default sorting functionality.

Add a new class CustomSortDescriptionCollection.cs as shown:

public class CustomSortDescriptionCollection : SortDescriptionCollection {

    public event NotifyCollectionChangedEventHandler MyCollectionChanged {
        add {
            this.CollectionChanged += value;
        }
        remove {
            this.CollectionChanged -= value;
        }
    }
}
 
We need CustomSortDescriptionCollection because the base class SortDescriptionCollection has CollectionChanged declared as protected. And we need access to that event so as to know when user has changed Sort options.

Add a new class SortableCollectionView as shown:

public class SortableCollectionView<T> : ObservableCollection<T>, ICollectionView  {
 
    public SortableCollectionView() {
        this._currentItem = null;
        this._currentPosition = -1;
    }
 
    protected override void InsertItem(int index, T item) {
        base.InsertItem(index, item);
        if (0 == index || null == this._currentItem ) {
            _currentItem = item;
            _currentPosition = index;
        }
    }
 
    public virtual object GetItemAt(int index) {
        if ((index >= 0) && (index < this.Count)) {
            return this[index];
        }
        return null;
    }
 
    #region ICollectionView Members
 
    public bool CanFilter {
        get { return false; }
    }
 
    public bool CanGroup {
        get { return false; }
    }
 
    public bool CanSort {
        get { return true; }
    }
 
    public bool Contains(object item) {
        if (!IsValidType(item)) {
            return false;
        }
        return this.Contains((T)item);
    }
 
    private bool IsValidType(object item) {
        return item is T;
    }
 
    private CultureInfo _culture;
 
    public System.Globalization.CultureInfo Culture {
        get {
            return this._culture;
        }
        set {
            if (this._culture != value) {
                this._culture = value;
                this.OnPropertyChanged(new PropertyChangedEventArgs("Culture"));
            }
        }
    }
 
    public event EventHandler CurrentChanged;
 
    public event CurrentChangingEventHandler CurrentChanging;
 
    private object _currentItem;
 
    public object CurrentItem {
        get { return this._currentItem; }
    }
 
    private int _currentPosition;
 
    public int CurrentPosition {
        get {
            return this._currentPosition;
        }
    }
 
    public IDisposable DeferRefresh() {
        throw new NotImplementedException();
    }
 
    private Predicate<object> _filter;
 
    public Predicate<object> Filter {
        get {
            return _filter;
        }
        set {
            _filter = value;
        }
    }
 
    public ObservableCollection<GroupDescription> GroupDescriptions {
        get { throw new NotImplementedException(); }
    }
 
    public ReadOnlyObservableCollection<object> Groups {
        get { throw new NotImplementedException(); }
    }
 
    public bool IsCurrentAfterLast {
        get {
            if (!this.IsEmpty) {
                return (this.CurrentPosition >= this.Count);
            }
            return true;
        }
    }
 
    public bool IsCurrentBeforeFirst {
        get {
            if (!this.IsEmpty) {
                return (this.CurrentPosition < 0);
            }
            return true;
        }
    }
 
    protected bool IsCurrentInSync {
        get {
            if (this.IsCurrentInView) {
                return (this.GetItemAt(this.CurrentPosition) == this.CurrentItem);
            }
            return (this.CurrentItem == null);
        }
    }
 
    private bool IsCurrentInView {
        get {
            return ((0 <= this.CurrentPosition) && (this.CurrentPosition < this.Count));
        }
    }
 
    public bool IsEmpty {
        get {
            return (this.Count == 0);
        }
    }
 
    public bool MoveCurrentTo(object item) {
        if (!IsValidType(item)) {
            return false;
        } 
        if (object.Equals(this.CurrentItem, item) && ((item != null) || this.IsCurrentInView)) {
            return this.IsCurrentInView;
        }           
        int index = this.IndexOf((T)item);
        return this.MoveCurrentToPosition(index);
    }
 
    public bool MoveCurrentToFirst() {
        return this.MoveCurrentToPosition(0);
    }
 
    public bool MoveCurrentToLast() {
        return this.MoveCurrentToPosition(this.Count - 1);
    }
 
    public bool MoveCurrentToNext() {
        return ((this.CurrentPosition < this.Count) && this.MoveCurrentToPosition(this.CurrentPosition + 1));
    }
 
    public bool MoveCurrentToPrevious() {
        return ((this.CurrentPosition >= 0) && this.MoveCurrentToPosition(this.CurrentPosition - 1));
    }
 
    public bool MoveCurrentToPosition(int position) {
        if ((position < -1) || (position > this.Count)) {
            throw new ArgumentOutOfRangeException("position");
        }
        if (((position != this.CurrentPosition) || !this.IsCurrentInSync) && this.OKToChangeCurrent()) {
            bool isCurrentAfterLast = this.IsCurrentAfterLast;
            bool isCurrentBeforeFirst = this.IsCurrentBeforeFirst;
            ChangeCurrentToPosition(position);
            OnCurrentChanged();
            if (this.IsCurrentAfterLast != isCurrentAfterLast) {
                this.OnPropertyChanged("IsCurrentAfterLast");
            }
            if (this.IsCurrentBeforeFirst != isCurrentBeforeFirst) {
                this.OnPropertyChanged("IsCurrentBeforeFirst");
            }
            this.OnPropertyChanged("CurrentPosition");
            this.OnPropertyChanged("CurrentItem");
        }
        return this.IsCurrentInView;
    }
 
    private void ChangeCurrentToPosition(int position) {
        if (position < 0) {
            this._currentItem = null;
            this._currentPosition = -1;
        } else if (position >= this.Count) {
            this._currentItem = null;
            this._currentPosition = this.Count;
        } else {
            this._currentItem = this[position];
            this._currentPosition = position;
        }
    }
 
    protected bool OKToChangeCurrent() {
        CurrentChangingEventArgs args = new CurrentChangingEventArgs();
        this.OnCurrentChanging(args);
        return !args.Cancel;
    }
 
    protected virtual void OnCurrentChanged() {
        if (this.CurrentChanged != null) {
            this.CurrentChanged(this, EventArgs.Empty);
        }
    }
 
    protected virtual void OnCurrentChanging(CurrentChangingEventArgs args) {
        if (args == null) {
            throw new ArgumentNullException("args");
        }
        if (this.CurrentChanging != null) {
            this.CurrentChanging(this, args);
        }
    }
 
    protected void OnCurrentChanging() {
        this._currentPosition = -1;
        this.OnCurrentChanging(new CurrentChangingEventArgs(false));
    }
 
    protected override void ClearItems() {
        OnCurrentChanging();
        base.ClearItems();
    }
 
    private void OnPropertyChanged(string propertyName) {
        this.OnPropertyChanged(new PropertyChangedEventArgs(propertyName));
    }        
 
    public event EventHandler<RefreshEventArgs> OnRefresh;
    public void Refresh() {
        // sort and refersh
        if (null != OnRefresh) {
            OnRefresh(this, new RefreshEventArgs() { SortDescriptions = SortDescriptions });
        }
        //this.OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
    }
 
    private CustomSortDescriptionCollection _sort;
 
    public SortDescriptionCollection SortDescriptions {
        get {
            if (this._sort == null) {
                this.SetSortDescriptions(new CustomSortDescriptionCollection());
            }
            return this._sort;
        }
    }
 
    private void SetSortDescriptions(CustomSortDescriptionCollection descriptions) {
        if (this._sort != null) {
            this._sort.MyCollectionChanged -= new NotifyCollectionChangedEventHandler(this.SortDescriptionsChanged);
        }
        this._sort = descriptions;
        if (this._sort != null) {
            this._sort.MyCollectionChanged += new NotifyCollectionChangedEventHandler(this.SortDescriptionsChanged);
        }
    }
 
    private void SortDescriptionsChanged(object sender, NotifyCollectionChangedEventArgs e) {
        if (e.Action == NotifyCollectionChangedAction.Remove && e.NewStartingIndex == -1 && SortDescriptions.Count > 0) {
            return;
        }
        if (
            ((e.Action != NotifyCollectionChangedAction.Reset) || (e.NewItems != null)) 
            || (((e.NewStartingIndex != -1) || (e.OldItems != null)) || (e.OldStartingIndex != -1))
            ) {
            this.Refresh();
        }
    }
 
    public System.Collections.IEnumerable SourceCollection {
        get { 
            return this; 
        }
    }
 
    #endregion
}

SortableCollectionView SortableCollectionView inherits from ObservableCollection and also implements ICollectionView. Majority of code is just the default implementation of ICollectionView. For Sorting functionality, have a look at the SortDescriptions property. DataGrid uses this collection to add or remove SortDescription based on user actions. A SortDescription defines the direction and the property name to be used as the criteria for sorting a collection.

We subscribe to the CollectionChanged event of SortDescriptions collection and handle it in the method SortDescriptionsChanged. Here we determine if we need to refresh the data in response to changes to the sort order and if so, we fire the Refresh event. This event is indication to consumers of SortableCollectionView that data needs to be refreshed.

Now modify Page.xaml.cs as shown:

SortableCollectionView<Person> _people;
//ObservableCollection<Person> _people;

void Page_Loaded(object sender, RoutedEventArgs e) {
    _startRow = 0;
    //_people = new ObservableCollection<Person>(); 
    _people = new SortableCollectionView<Person>();
    // default sort order
    _people.SortDescriptions.Add(new SortDescription("Age", ListSortDirection.Ascending));
    _people.OnRefresh += new EventHandler<RefreshEventArgs>(people_OnRefresh);
    peopleDataGrid.ItemsSource = _people;
    _sortBy = new Dictionary<string, string>();
    //nextPageButton.IsEnabled = false;
    GetData(_startRow, _pageSize);
}

void people_OnRefresh(object sender, RefreshEventArgs e) {
    _sortBy.Clear();
    foreach (SortDescription sortDesc in _people.SortDescriptions) {
        _sortBy.Add(sortDesc.PropertyName, (sortDesc.Direction == ListSortDirection.Ascending ? "ASC" : "DESC"));
    }
    _startRow = 0;
    GetData(_startRow, _pageSize);
}

custom sortingWhenever user changes sort options in DataGrid, DataGrid adds or removes SortDescription object in SortDescriptions collection, which causes CollectionChanged event; that SortableCollectionView is listening for. In turn, SortableCollectionView fires OnRefresh event, indicating need to refresh the data as per new sort conditions.

In the people_OnRefresh method, we cycle through the SortDescriptions collection and build the sortBy dictionary that is eventually passed to the backend web service for custom sorting.

Build and run the application. Note that since data is pre sorted by Age field, we have added that to SortDescriptionCollection and it shows on the UI, without any user actions.

Next click on Age field. Data is now sorted and first Person has Age of 100. Try sorting by CountyCode, and add sort by Age descending (hold down shift key to add sub sorts / sort within).

 sorting age sorting countycode age

Custom sorting can be used in other scenarios as well. If data is presorted, you can add new SortDescription to show that on UI. Similarly, it is possible to remove all sorting by clearing SortDescriptions collection. It also enables you to position a fixed row, say an empty row for adding new person, as the first row or the last row. It is also possible to use arbitrary property names in SortMemberPath since we have full control of sorting and can interpret property names as per application requirements.

Additionally filter functionality can also be implemented by setting up the Filter predicate to filter out the item and evaluating the filter when adding an item to the collection.

However there are also certain side effects. If you change any data, it will not be automatically repositioned. Also if you add a new data row, it will not be positioned properly as well. Based on your needs, you may need to re-sort in each of the above cases or use alternate sorting implementation.

Hopefully this will help you incorporate custom sorting functionality in DataGrid, till the Silverlight framework provides an easier alternative (similar to WPF).

Source Code: CustomSorting.zip (185 KB)

Technorati Tags:

Comments

Jonathan van de Veen said:

Realy great to finaly see a complete example of custom sorting working with a datagrid.

I still don't know why Microsoft made their implementation internal.

Great stuff though.

# December 31, 2008 3:02 AM

Anye said:

It's hard to tell because you didn't include the data access code but this looks like it sorts the data returned from the paged query - not sorts the entire query and then returns only the data on the proper page.

For example, if I have 1000 records in my database and a page size of 50, and I want to sort by Last Name descending and return page 3 - I want to run the following metaquery:

select * from SomeTable order by LastName descending

and of those results return rows 101-150.  (So I'd get results near the end of the alphabet, sorted in reverse order)

What your example looks like it would do is

select rows 101-150 from SomeTable

then sort those rows by LastName desc. (so I'd get rows near the front of the alphabet, but sorted in reverse order)

because it does not seem to pass the sort criteria to GetData where it can be used in the query itself.

If I am interpreting this incorrectly can you please post the code that will clear it up?

Thanks

# February 10, 2009 1:56 PM

Kenan Dalley said:

I'm attempting to use the example included above and I'm running into an issue with the fact that my system.dll included with the Silverlight 2 RTM api that I have has no System.ComponentModel.RefreshEventArgs class.  The system.dll in either the normal .NET 2.0 or 3.5 projects that I have DO have that file.  Were you using a different version of the system.dll for Silverlight or am I missing something?

# February 25, 2009 8:49 AM

Kenan Dalley said:

I found out via the downloadable source that the RefreshEventArgs is a class that Manish personally created in the Silverlight project.  So, this answers my question.

# February 25, 2009 10:39 AM

Jonathan said:

When I run your project in SL3 and click on a column to sort, it always calls DeferRefresh which is not implemented.  I looked at some other controls have implemented it and it seems really complex!  Any ideas?

# July 29, 2009 10:28 PM

manish.dalal said:

Jonathan,

I have updated code and added paging. I will publish it soon, in mean while, just implement IDisposable and return this from DeferRefresh. This obviously lacks DeferRefresh implementation, but works fine as long as you are blocking UI when fetching data from server.

# July 30, 2009 4:49 PM

Larsi said:

What about filtering, can the pass this class into the new PagedCollectionView and get custom sorting, filtering and grouping?

# August 6, 2009 4:34 AM

Wei said:

Hi Manish,

I have same problem for sl3 with DeferRefresh and have you post your updated code to handle this and paged punction?

Thanks.

# September 16, 2009 11:29 AM

Ekzarov said:

Hi,

I have same problem for sl3 with DeferRefresh ! Please post new implementation.

Thanks.

# September 24, 2009 7:58 AM

Jennifer said:

This is really great and got custom Paging to work, although I am having problem with Grouping.

I added implementation for the below in SortableCollectionView:

ObservableCollection<GroupDescription> GroupDescriptions

public ReadOnlyObservableCollection<object> Groups

but doesn't work :-(

Do you mind posting the code to add grouping? Thnks.

# December 6, 2009 1:57 PM

Sachin said:

This is really a gr8 Post...

but what if i want to customize my sorting... i.e. i want to fix any row of the grid not to involve in soring, it should remain at the position where it is.

in a real time example. let say if i have a datagrid in which i am showing amount total of the column's value. so in soring what i want that column should sort all the values but the Total.

Please give your thoughts on this it's very urgent.

# January 4, 2010 6:06 AM

bala said:

hi Manish,

public ReadOnlyObservableCollection<object> Groups {        

get { throw new NotImplementedException(); }    }

above code block is not working in SL 3.0 , any idea

# January 22, 2010 7:16 PM

craiggeil said:

Wow, that's a fricking ton of code just to implement custom sorting? That's insane if this is truly the only way to implement it. There has to be a cleaner way to do this by now.

# April 22, 2010 10:58 AM