Drag and Drop grouping in Datagrid

Update

The code for the functionality in this post has been updated in Update postings on Datagrid

Introduction

The DataGrid of the Silverlight Toolkit supports grouping but does not have the ability to drag column headers to the area above the grid to group the data, unlike some other 3rd party datagrids.

This blog presents a solution to implement this functionality. The solution is based upon the solution which can be found on the blog Lee’s corner.

Contents

Shortcomings of Lee’s solution

The solution presented on Lee’s corner has the following shortcomings:

  • The solution is not a reusable component
  • The solution is not themable because the Template is replaced
  • When grouping on column, the order of the columns is also changed as noticed by Robbie Symborski
  • Headers on columns do not match group headers

The following paragraphs outline the approaches taken to resolve these shortcomings.

Make reusable

Because integrating the group header panel within the datagrid without changing the template seemed quite complex, the solution consists of the following two components:

  • MyDataGrid: inherited from DataGrid
  • MyDataGridGroupHeaderPanel: group panel that can be linked to the MyDataGrid by using the attribute DataGrid

Using these components a groupable grid can be made as follows:

<Grid>
    <Grid.RowDefinitions>
        <RowDefinition Height="40"/>
        <RowDefinition Height="*"/>
    </Grid.RowDefinitions>
 
    <app:MyDataGridGroupHeaderPanel DataGrid="{Binding ElementName=datagrid1}" Grid.Row="0"/>
 
    <app:MyDataGrid x:Name="datagrid1" Grid.Row="1" ...>
        ...
    </app:MyDataGrid>
</Grid>

The underlying DataGrid relies on a ICollectionView to perform the grouping and sorting. Unfortunately, there is no way to get hold of this data from within the DataGrid itself. The solution is to use an ICollectionView for the ItemsSource and keep a reference to this ICollectionView. In order to be able to still support ordinary IEnumerable item sources the ItemsSource property of the underlying DataGrid is hidden by a new definition as follows:

public ICollectionView CollectionView { get; private set; }
 
public new IEnumerable ItemsSource
{
    get { return GetValue(ItemsSourceProperty) as IEnumerable; }
    set { SetValue(ItemsSourceProperty, value); }
}
 
public static new readonly DependencyProperty ItemsSourceProperty =
    DependencyProperty.Register(
    "ItemsSource",
    typeof(IEnumerable),
    typeof(MyDataGrid),
    new PropertyMetadata(OnItemsSourcePropertyChanged));
 
private static void OnItemsSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    MyDataGrid grid = d as MyDataGrid;
    if (grid == null)
    {
        return;
    }
 
    grid.SetItemsSource((IEnumerable)e.NewValue);
}
 
private void SetItemsSource(IEnumerable itemsSource)
{
    ICollectionView newValue = CreateCollectionView(itemsSource);
 
    CollectionView = newValue;
    base.ItemsSource = newValue;
}
 
private static ICollectionView CreateCollectionView(IEnumerable newItemsSource)
{
    if (newItemsSource == null)
    {
        return null;
    }
 
    ICollectionViewFactory collectionViewFactory = newItemsSource as ICollectionViewFactory;
    if (collectionViewFactory != null)
    {
        return collectionViewFactory.CreateView();
    }
 
    ICollectionView collectionView = newItemsSource as ICollectionView;
    if (collectionView != null)
    {
        return collectionView;
    }
 
    return new PagedCollectionView(newItemsSource);
}

Alternative for replacing the template

As stated in here the only reason to replace the Template is to wrap the DataGridColumnHeadersPresenter in a PanelDragDropTarget to allow the DataGridColumnHeaders to be draggable. This can also be done by overriding the OnApplyTemplate() of MyDataGrid like this:

PanelDragDropTarget _dropTarget;
 
public override void OnApplyTemplate()
{
    base.OnApplyTemplate();
 
    DataGridColumnHeadersPresenter columnHeadersPresenter = this.GetTemplateChild("ColumnHeadersPresenter") as DataGridColumnHeadersPresenter;
    if (columnHeadersPresenter == null)
    {
        return;
    }
 
    Grid columnHeadersPresenterParent = columnHeadersPresenter.Parent as Grid;
    if (columnHeadersPresenterParent == null)
    {
        return;
    }
 
    int indexOfPresenter = columnHeadersPresenterParent.Children.IndexOf(columnHeadersPresenterParent);
    if (indexOfPresenter == -1)
    {
        indexOfPresenter = 1;
    }
 
    _dropTarget = new PanelDragDropTarget() 
    { 
        HorizontalContentAlignment = HorizontalAlignment.Stretch, 
        VerticalContentAlignment = VerticalAlignment.Stretch, 
        AllowedSourceEffects = DragDropEffects.Copy 
    };
    _dropTarget.SetValue(Grid.ColumnProperty, 1);
 
    columnHeadersPresenterParent.Children.Remove(columnHeadersPresenter);
 
    columnHeadersPresenter.ClearValue(Grid.ColumnProperty);
    columnHeadersPresenter.ClearValue(Grid.RowProperty);
 
    _dropTarget.Content = columnHeadersPresenter;
    columnHeadersPresenterParent.Children.Insert(indexOfPresenter, _dropTarget);
}

Preventing column order changes when grouping

The problem in preventing column order changes when regrouping is how to determine when a column header movement is a column order change or a group change. The approach taken in this solution is a pragmatic one: when the control key is pressed during the mousedown on a column header the user is starting a group operation, otherwise the user is starting a column reorder operation. This can be achieved by addint the folowing to code to MyDataGrid:

protected virtual bool AllowGrouping
{
    get
    {
        return (Keyboard.Modifiers & ModifierKeys.Control) != ModifierKeys.None;
    }
}
 
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
    CanUserReorderColumns = !AllowGrouping;
 
    base.OnMouseLeftButtonDown(e);
}

and to add the following line to the OnApplyTemplate() override:

_dropTarget.ItemDragStarting += (s, e) => { e.Cancel = !AllowGrouping; if (e.Cancel) { e.Handled = true; } };

Match column headers with group headers

The column headers must match with both:

  • the group headers on the group rows within the MyDataGrid
  • the group buttons on MyDataGridGroupHeaderPanel

These are separate problems which will be dealt with in the following paragraphs.

Match with group rows within the MyDataGrid

Following this article the matching with group rows within the MyDataGrid can be achieved by adding the following code to MyDataGrid:

protected override void OnLoadingRowGroup(DataGridRowGroupHeaderEventArgs e)
{
    base.OnLoadingRowGroup(e);
 
    string fieldName = e.RowGroupHeader.PropertyName;
 
    e.RowGroupHeader.PropertyName = GetColumnCaptionFromBindingPath(fieldName) ?? fieldName;
}
 
public string GetColumnCaptionFromBindingPath(string bindingPath)
{
    foreach (DataGridBoundColumn column in this.Columns)
    {
        if (column == null)
        {
            continue;
        }
        Binding binding = column.Binding;
        if (binding == null)
        {
            continue;
        }
        if (binding.Path.Path == bindingPath)
        {
            return Convert.ToString(column.Header);
        }
    }
 
    return null;
}

Unfortunately, this only seems to work on initial binding. Whenever an ICollectionView is bound to a DataGrid and the sorting or grouping is changed, either by changing the SortDescriptions, changing the GroupDescriptions or by changing the sort order by clicking on the column headers, the column name is used in stead of the header name. This is probably a bug within the DataGrid component. A work around this problem is to clone the ICollectionView and assign this clone to the ItemsSource of the DataGrid by replacing the definition of SetItemsSource by this

private void SetItemsSource(IEnumerable itemsSource)
{
    ICollectionView oldValue = CollectionView;
    if (oldValue != null)
    {
        (oldValue.SortDescriptions as INotifyCollectionChanged).CollectionChanged -= OnChangeRebindCollection;
        oldValue.GroupDescriptions.CollectionChanged -= OnChangeRebindCollection;
    }
 
    ICollectionView newValue = CreateCollectionView(itemsSource);
    if (newValue != null)
    {
       (newValue.SortDescriptions as INotifyCollectionChanged).CollectionChanged += OnChangeRebindCollection;
        newValue.GroupDescriptions.CollectionChanged += OnChangeRebindCollection;
    }
 
    CollectionView = newValue;
    base.ItemsSource = newValue;
}

and to add the following functions:

private void OnChangeRebindCollection(object sender, NotifyCollectionChangedEventArgs e)
{
    Rebind();
}
 
public void Rebind()
{
    ItemsSource = CollectionView.Clone();
}

Unfortunately reassigning the ItemsSource or the base.ItemsSource does not work. Because the class PagedCollectionView that is used for implementing the CollectionView does not implement a Clone method, the following extension method is created:

public static ICollectionView Clone(this ICollectionView source)
{
    if (source == null)
    {
        return null;
    }
 
    PagedCollectionView result = new PagedCollectionView(source.SourceCollection);
 
    if (source.Culture != null)
    {
        result.Culture = source.Culture;
    }
 
    if (source.Filter != null)
    {
        result.Filter = source.Filter;
    }
 
    foreach (SortDescription sortDescription in source.SortDescriptions)
    {
        result.SortDescriptions.Add(sortDescription);
    }
 
    foreach (GroupDescription groupDescription in source.GroupDescriptions)
    {
        result.GroupDescriptions.Add(groupDescription);
    }
 
    IPagedCollectionView pagedCollectionView = source as IPagedCollectionView;
    if (pagedCollectionView != null)
    {
        result.PageSize = pagedCollectionView.PageSize;
    }
 
    return result;
}

Unfortunately, for some reason or another, the addition of

    result.MoveCurrentTo(source.CurrentItem);

leads to the following exception:

Cannot change or check the contents or current position of the PagedCollectionView while Refresh is being deferred.

This is probably a bug in the implementation of PagedCollectionView: probably the raising of the event INotifyCollectionChanged.CollectionChanged is made before all IDisposable’s returned from the internal calls to DeferRefresh have been disposed. As a consequence of this, it is not possible to keep the selected item selected after regrouping or sorting.

Finally: in order to enable prevention of multiple rebindings of the ItemsSource, the method Rebind() described in this article is refactored as follows:

public void Rebind()
{
    PostponableRebind.Execute();
}
 
private PostponableAction _postponableRebind;
private PostponableAction PostponableRebind
{
    get
    {
        if (_postponableRebind == null)
        {
            _postponableRebind = new PostponableAction(RebindImplementation);
        }
        return _postponableRebind;
    }
}
 
public IDisposable PostponeRebind
{
    get
    {
        return PostponableRebind.PostponeAction;
    }
}
 
private void RebindImplementation()
{
    ItemsSource = CollectionView.Clone();
}

Match with MyDataGridGroupHeaderPanel

In order to support column headers that differ from the column binding names in the MyDataGridGroupHeaderPanel, instances of the inner class GroupDefinition are used as items of the ListBox in stead of the actual headers. This inner class is defined as follows:

private class GroupDefinition
{
    public GroupDefinition(string field, string caption)
    {
        if (String.IsNullOrEmpty(field))
        {
            throw new ArgumentNullException("field");
        }
 
        Field = field;
        Caption = caption ?? field;
    }
 
    public string Field { get; internal set; }
    public string Caption { get; internal set; }
 
    public override string ToString()
    {
        return Caption;
    }
}

The Drag event handler that adds an item to the ListBox becomes:

protected void DragDropTarget_Drop(object sender, Microsoft.Windows.DragEventArgs e)
{
    if (DataGrid == null)
    {
        return;
    }
 
    object data = e.Data.GetData(e.Data.GetFormats()[0]);
    if (data == null)
    {
        return;
    }
 
    DataGridColumnHeader dgch = ((data as ItemDragEventArgs).Data as SelectionCollection)[0].Item as DataGridColumnHeader;
    if (dgch == null)
    {
        return;
    }
 
    string caption = Convert.ToString(dgch.Content);
    string fieldName = DataGrid.GetBindingPathFromColumnCaption(caption);
 
    using (OnHeaderItemsChanged.PostponeAction)
    {
        foreach (GroupDefinition item in listGroups.Items)
        {
            if (item.Field == fieldName)
            {
                listGroups.Items.Remove(item);
                break;
            }
        }
        listGroups.Items.Add(new GroupDefinition(fieldName, caption));
    }
}

Note that extra logic has been added to prevent a single column to be added twice. Unfortunately it is not possible to get the column definition from the DataGridColumnHeader, so the binding name has to be retrieved using the function DataGrid.GetBindingPathFromColumnCaption which is defined in MyDataGrid as

public string GetBindingPathFromColumnCaption(string columnCaption)
{
    DataGridBoundColumn column = Columns.FirstOrDefault((c) => Convert.ToString(c.Header) == columnCaption) as DataGridBoundColumn;
    if (column == null)
    {
        return null;
    }
    Binding binding = column.Binding;
    if (binding == null || binding.Path == null)
    {
        return null;
    }
 
    return binding.Path.Path;
}

The actual synchronization of the GroupDescriptions is moved to the CollectionChanged event handler of the ListBox which is defined as follows:

(listGroups.Items as INotifyCollectionChanged).CollectionChanged += (s, e) => { OnHeaderItemsChanged.Execute(); };

with the following supporting definitions:

private PostponableAction _onHeaderItemsChanged;
private PostponableAction OnHeaderItemsChanged
{
    get
    {
        if (_onHeaderItemsChanged == null)
        {
            _onHeaderItemsChanged = new PostponableAction(OnHeaderItemsChangedImplementation);
        }
        return _onHeaderItemsChanged;
    }
}
 
private void OnHeaderItemsChangedImplementation()
{
    if (DataGrid == null)
    {
        return;
    }
 
    ICollectionView view = DataGrid.CollectionView;
    if (view == null)
    {
        return;
    }
 
    ObservableCollection<GroupDescription> groupDescriptions = view.GroupDescriptions;
    if (groupDescriptions == null)
    {
        return;
    }
 
    using (DataGrid.PostponeRebind)
    {
        groupDescriptions.Clear();
        if (listGroups.Items != null)
        {
            foreach (GroupDefinition groupDefinition in listGroups.Items)
            {
                groupDescriptions.Add(new PropertyGroupDescription { PropertyName = groupDefinition.Field });
            }
        }
    }
}

The ListBox is initially filled by

private void FillGroupHeaderPanelFromGroupDescriptions()
{
    using (OnHeaderItemsChanged.PostponeAction)
    {
        listGroups.Items.Clear();
 
        if (DataGrid == null)
        {
            return;
        }
 
        ICollectionView collection = DataGrid.CollectionView;
        if (collection == null)
        {
            return;
        }
 
        foreach (PropertyGroupDescription groupDescription in collection.GroupDescriptions)
        {
            listGroups.Items.Add(new GroupDefinition(groupDescription.PropertyName, DataGrid.GetColumnCaptionFromBindingPath(groupDescription.PropertyName)));
        }
    }
}

Download

The source accompanying this blog can be downloaded here.

8 Responses to “Drag and Drop grouping in Datagrid”

  1. On developing Pochet.NET» Blog Archive » Drag and Drop grouping in … | How to: DataGrid said:

    Aug 31, 10 at 5:44 pm

    [...] Datagrid net – Google Blog Search [...]

  2. Jhelumi said:

    Sep 08, 10 at 1:35 pm

    Hi There,
    Its a gr8 job. Thanks for this hard work and specially sharing with us all.

    I have one question that How can I apply the filtering to this. I have a Search Box (Free TextBlock) and a CheckBoxList (Column Names) in it so that when the user type in string in the search box and choose the columns he wants to search (checkboxes from drop down) I want to filter the grid.

    Thanks in advance.

  3. Emiel said:

    Sep 08, 10 at 4:30 pm

    You could use the CollectionView.Filter property for that. For more information on this have a look at this

  4. Jhelumi said:

    Sep 08, 10 at 6:38 pm

    Hi,
    Thanks for the response. I’m new to SL so please ignore if I ask any stupid/simple question.
    I need to make it in a way so that when the user type in search field, the grid automatically start filtering based upon the characters entered by the user without forcing him to press a Search/Go button. I’m currently achieving this using the following xaml code.
    /********************

    /*******************************/
    I wonder how can I achieve this in code behind specially applying the filter on CollectionView.
    Any code would be much appreciated.

  5. Emiel said:

    Sep 08, 10 at 10:06 pm

    This article is not about Filtering CollectionViews, but maybe you can find some inspiration in this article and this article. Maybe you can post your questions there.

  6. Jhelumi said:

    Sep 09, 10 at 3:24 pm

    Hi Emiel,

    Thanks for the links. I have managed to build the filtering functionality.
    I’m having another issue now regarding DataGridTemplateColumn. Soon I added a DataGridTemplateColumn type col in my grid and try to drag & drop any col (even other than the DataGridTemplateColumn) I get an error
    “Unable to cast object of type ‘System.Windows.Controls.DataGridTemplateColumn’ to type ‘System.Windows.Controls.DataGridBoundColumn’.”

    at
    public string GetColumnCaptionFromBindingPath(string bindingPath)
    {
    foreach (DataGridBoundColumn column in this.Columns)

    ******************************

    Any ideas how to fix that?
    Thanks in advance

  7. Emiel said:

    Sep 09, 10 at 3:40 pm

    This solution is based on matching the Path.Path property of the binding of a DataGridBoundColumn to the fields in the collection source. So grouping on other type of columns is not supported.

    The exception you found can be dealt with by changing the first few lines of the function GetColumnCaptionFromBindingPath by:

    foreach (object columnObject in this.Columns)
    {
        DataGridBoundColumn column = columnObject as DataGridBoundColumn;
        if (column == null)
        {
            continue;
        }
        ...
  8. Jhelumi said:

    Sep 09, 10 at 4:12 pm

    Thanks for the response.
    I figured that out as

    foreach (DataGridColumn col in this.Columns)
    {
    DataGridBoundColumn column = col as DataGridBoundColumn;
    if (column == null)
    {
    continue;
    }

    Thanks once again
    Cheers


Leave a Reply