Updates from QA Training

Copy All To Clipboard: A Silverlight DataGrid story in three iterations

I recently received an email from a former delegate, the gist (if not the exact content) of which was, “we have a customer who wants to click a button instead of doing a Ctrl+A then a Ctrl+C to copy all of the contents of a Silverlight DataGrid to the clipboard.” This kicked off two thoughts.


Daniel Ives | 31 August 2012

I recently received an email from a former delegate, the gist (if not the exact content) of which was, “we have a customer who wants to click a button instead of doing a Ctrl+A then a Ctrl+C to copy all of the contents of a Silverlight DataGrid to the clipboard.” This kicked off two thoughts:

Thought number one: "two keystrokes plus a click on the 'allow Silverlight access to clipboard' button versus one button click and a click on the 'allow access' button. REALLY?!" [I should point out that I prefer keyboard to mouse interaction any day of the week and twice on a Thursday. Which was when I received the email.]
Followed by thought number two: "the customer is always right."

Actually, three thoughts: "can you really do a Ctrl+A, Ctrl+C on a DataGrid in Silverlight? Who knew?"
It piqued my interest so I immediately stopped what I was doing and settled down to some play time (it's OK, I had finished teaching for the day).

I found it so interesting (and, as usual, by "interesting" I mean "couldn't find an easy answer on the interwebs") that I decided to write it up. If you don't want to read my waffle, you can just grab the code here.

Investigation

I just happened to have a Silverlight project that uses a datagrid lying around, so I fired that up and checked. Yup, select all then copy works just dandy … almost; I noticed that it ignores DataGridTemplateColumns.

I also noticed that there's a property (ClipboardCopyMode) you can set that tells it whether or not to include headers when it copies stuff, which suggested strongly that the DataGrid itself knows all about the clipboard and it isn't some Silverlight runtime magic. That ClipboardCopyMode also breaks things. If you have DataGridTemplateColumns and ClipboardCopyMode.IncludeHeaders, you get the header but not the data.

So I fired up dotPeek, JetBrains' .NET decompiler, and had a look at it. Sure enough, there are a few private methods that relate to select and copy. Unfortunately, they themselves use other private and internal methods and properties so my first plan, to simply copy those out, wasn't going to work very well. I had to start from scratch.

I first thought of using some kind of SendKeys API but there isn't one for Silverlight and I didn't fancy using UIAutomation to work around it. So here goes.

Iteration 1 - proof of concept

SelectAll was easy enough:

dataGrid.SelectedItems.Clear();
foreach (var item in dataGrid.ItemsSource)
{
    dataGrid.SelectedItems.Add(item);
}

But then for the copy to clipboard part. Firstly, the functionality we get with Ctrl+A, Ctrl+C gives us a tab-separated list of values just containing text. StringBuilder was called for I felt.

StringBuilder sb = new StringBuilder();

Now my first stab was to just loop through the data-bound item's properties using Reflection and append those to the StringBuilder but that actually gave me the all the properties, even if they weren't being displayed, and it gave me them in alphabetical order, not the order in which they are displayed on the grid. So I quickly modified it to the following:

List<string> propNames = new List<string>();
 
foreach (var column in dataGrid.Columns)
{
    //fragile: what if the column isn't a DataGridTextColumn?
    //  DataGridColumn class doesn't have a Binding property...
    propNames.Add((column as DataGridTextColumn).Binding.Path.Path);
}

You'll note my comments in there. But it'll do for now, or until I add back in the DataGridCheckBoxColumns and DataGridTemplateColumns that I commented out of my test app at any rate. That'll be iteration 2. So having got my list of property names, I could iterate over those, grabbing the appropriate property from the items. Note that I'm using a for loop rather than for each. I took that decision so that I could tell when I was looking at the last property in the list and not append a tab character after that one. Also because in my test DataGrid I have the same property bound to more than one column (for test purposes).

foreach (var item in dataGrid.SelectedItems)
{
    Type t = item.GetType();
    for (int i = 0; i < propNames.Count; i++)
    {
        sb.Append(t.GetProperty(propNames[i]).GetValue(item, null));
        if (i < propNames.Count - 1)
        {
            sb.Append('\t');
        }
    }
    sb.AppendLine(); //except maybe for the last one...
}

Not happy about the extra newline but I really can't be bothered to remove the last one. For now at any rate. Finally for the really easy bit - shove it on the Clipboard.

Clipboard.SetText(sb.ToString());
dataGrid.Focus(); //because it feels right to do so...

Super!

Iteration 2 - support the ClipboardCopyMode property and fix the cast

…and fix the extra line issue whilst I'm at it.

I'm going to use Reflection to find out if my DataGridColumns have a Binding property and if so, to use it. I decided to give up on trying to get the data out of DataGridTemplate columns, probably for the same reason as MS did: too much effort! I'm thinking loop through the tree of the column's DataTemplate looking for something that has a binding.

First addition: add a variable about whether to include headers. I'm going to look at the DataGrid's ClipboardCopyMode more than once, so I'll stick it in a variable:

bool includeHeaders =
    (dataGrid.ClipboardCopyMode == DataGridClipboardCopyMode.IncludeHeader);

Next, the DataGridColumns loop. Using Reflection, I'm looking for a property called Binding of type Binding. If I find it I grab that binding, still using reflection, and find out what property it's bound to. Finally, if we're including headers I append the header to the StringBuilder and then, as long as this isn't the last column in the grid, I also append a tab character. Actually, whilst typing this I've just noticed a problem: what if we are including columns but the last column doesn't have a Binding property? Drat!

foreach (var c in dataGrid.Columns)
{
    var b = c.GetType().GetProperty("Binding", typeof(Binding));
    if (b != null)
    {
        Binding binding = b.GetValue(c, null) as Binding;
        propNames.Add(binding.Path.Path);
        if (includeHeaders)
        {
            sb.Append(c.Header); //will only work for a string header
            if (dataGrid.Columns.IndexOf(c) < dataGrid.Columns.Count - 1)
            {
                sb.Append('\t');
            }
        }
    }
}

You'll hopefully have noticed my comment in there. I'm not too bothered by it because to be honest, I can't figure out how to set the header using a content control anyway! Once we've done all that, we'll need to append a newline (if we're including headers).

if (includeHeaders)
{
    sb.AppendLine();
}

Finally a tweak to the SelectedItems loop to only append a line if this isn't the last SelectedItem.

foreach (var item in dataGrid.SelectedItems)
{
    Type t = item.GetType();
    for (int i = 0; i < propNames.Count; i++)
    {
        sb.Append(t.GetProperty(propNames[i]).GetValue(item, null));
        if (i < propNames.Count - 1)
        {
            sb.Append('\t');
        }
    }
    // new bit
    if (dataGrid.SelectedItems.IndexOf(item) < dataGrid.SelectedItems.Count - 1)
    {
        sb.AppendLine();
    }
}
Clipboard.SetText(sb.ToString());
dataGrid.Focus(); //because it feels right

Super-duper! But I'm unhappy about the column / tab issue. Modified it to always append a tab character and then to remove the last one when adding the newline.

        …
        if (includeHeaders)
        {
            sb.Append(c.Header); //will only work for a string header
            sb.Append('\t');
        }
    }
}
if (includeHeaders)
{
    sb.Remove(sb.Length - 1, 1);
    sb.AppendLine();
}

Now we're cooking on gas. But one thing I failed to mention is that I've been writing all this in code-behind! Sorry about that! It must be time to tidy things up into an extension method or two on DataGrid. And you know what? I'm kind of desperate to make my stuff work better than Ctrl+A, Ctrl+C. So I will try to come up with a solution for DataGridTemplateColumns.

Iteration 3 - Extension methods … and fix TemplateColumns...

I hope you already know about extension methods. Put simply, it's a static method in a static class and the first parameter gets a "this" put in front of it so it looks like an instance method
I thought, seeing as I was fiddling around, I'd also make it possible to change the column separator from the default tab character:

public static class DataGridExtensions
{
    public static void SelectAll(this DataGrid dataGrid)
    {
        dataGrid.SelectedItems.Clear();
        foreach (var item in dataGrid.ItemsSource)
        {
            dataGrid.SelectedItems.Add(item);
        }
    }
 
    public static void CopyAllToClipboard(this DataGrid dataGrid,
        string separator = "\t")
    {
        dataGrid.SelectAll();
        dataGrid.CopySelectedItemsToClipboard(separator);
    }

So far so simple.  The heavy lifting is done by the CopySelectedItemsToClipboard method. I've done it this way so that if you're using SelectionMode.Extended, you'd be able to copy only the selected rows of the DG to the clipboard as well. So here's the signature:

public static void CopySelectedItemsToClipboard(this DataGrid dataGrid,
    string separator = "\t")

The body of CopySelectedItemsToClipboard looks very similar to what we had at the end of iteration 2, but with some changes. I'll annotate.

StringBuilder sb = new StringBuilder();
List<string> propNames = new List<string>();
bool includeHeaders =
    (dataGrid.ClipboardCopyMode == DataGridClipboardCopyMode.IncludeHeader);
 
foreach (var c in dataGrid.Columns)
{
    var b = c.GetType().GetProperty("Binding", typeof(Binding));
    Binding binding = null;
    if (b != null)
    {
        binding = b.GetValue(c, null) as Binding;
    }

First change. If the column itself has a Binding property then fine we'll use it (that could be a custom subclass of DataGridColumn that you've made). Otherwise, I'm going to assume it's a DataGridTemplate column:

    else
    {
        var dgtc = c as DataGridTemplateColumn;
        if (dgtc != null)
        {
            var template = dgtc.CellTemplate;
            binding = LookForBindingObject(template.LoadContent());
        }
    }

We'll see the LookForBindingObject method later. It's not pretty but I've already spent much longer writing this up than I'd intended.

    if (binding != null) //i.e. we found one!
    {
        propNames.Add(binding.Path.Path);
        if (includeHeaders)
        {
            sb.AppendFormat("{0}{1}", c.Header, separator);
        }
    }
}

Little change there to use our parameterised separator string.

if (includeHeaders)
{
    sb.Remove(sb.Length - separator.Length, 1);
    sb.AppendLine();
}

 …And another one there to take it into account when removing the last one.

 

foreach (var item in dataGrid.SelectedItems)
{
    Type t = item.GetType();
    for (int i = 0; i < propNames.Count; i++)
    {
        sb.Append(t.GetProperty(propNames[i]).GetValue(item, null));
        if (i < propNames.Count - 1)
        {
            sb.Append('\t');
        }
    }
    if (dataGrid.SelectedItems.IndexOf(item) < dataGrid.SelectedItems.Count - 1)
    {
        sb.AppendLine();
    }
}
Clipboard.SetText(sb.ToString());

And here's the LookForBindingObject method, based on my idea earlier of walking the template to find bindings. I had to deal with the case where the DataTemplate contains only a single child, and the case where the DataTemplate contains a Panel with children. The code isn't great and needs further refining. I owe a debt of gratitude to this question on StackOverflow for helping me to work out how to find the DPs (the GetFields call with BindingFlags - of course I upvoted it). It'll return the first BindingExpression that it finds in the Template's tree, so will only work for fairly simple cases, but I have tested it against a single TextBlock scenario and a more complex StackPanel containing multiple TextBlocks. So it's not bad. It could probably do with being renamed but I wasn't feeling too creative by this point.

private static Binding LookForBindingObject(DependencyObject obj)
{
    Binding result = null;
 
    //obj has no children
    if (VisualTreeHelper.GetChildrenCount(obj) == 0)
    {
        result = FindBinding(obj);
    }
 
    //obj does have children
    for (int i = 0; i < VisualTreeHelper.GetChildrenCount(obj); i++)
    {
        DependencyObject child = VisualTreeHelper.GetChild(obj, i);
        result = FindBinding(child);
        if (result == null)
        {
            result = LookForBindingObject(child);
        }
    }
    return result;
}
 
private static Binding FindBinding(DependencyObject obj)
{
    Binding result = null;
    foreach (var item in obj.GetType().GetFields(
        BindingFlags.Public |BindingFlags.Static |BindingFlags.FlattenHierarchy))
    {
        DependencyProperty dp = item.GetValue(obj) as DependencyProperty;
        if (dp != null)
        {
            BindingExpression b =((FrameworkElement)obj).GetBindingExpression(dp);
            if (be != null)
            {
                result = be.ParentBinding;
            }
        }
    }
    return result;
}

Super-de-duper!

Iterations 4 and 5 might involve looking into some or none of the following:

  • Optional columnsToIgnore parameter to only copy some columns to clipboard
  • Handle multiple binding expressions in TemplateColumns (I'm thinking use a dictionary rather than a list for propNames)
  • Don't assume that if it doesn't have a Binding property that it is a DataGridTemplateColumn - just look for a CellTemplate
  • Come up with some better method names
  • What if the DataGrid is bound to something without properties, like a dataset or something?

I hope you've found this useful.


Daniel Ives

Daniel Ives

Principal Technologist - AWS

Daniel joined QA in 2006, having previously worked as a developer trainer on the Microsoft stack. He is an Authorized Amazon Instructor and holds six of the seven current AWS certifications. As a Principal Technologist, Daniel focuses on creating and delivering courses about cloud services, service-oriented architectures, data engineering and enterprise application integration. Areas of expertise: Amazon Web Services, C#, .NET and agile development. His areas of interest include all of the above, plus Google Cloud Platform, Microsoft Azure, Python, sailing, skiing and cycling, although not necessarily in that order or at the same time.
Talk to our learning experts

Talk to our team of learning experts

Every business has different learning needs. QA has over 30 years of experience in combining the highest quality training with the most comprehensive range of learning services, ensuring the very best fit for your organisation.

Get in touch with our learning experts to talk about how we can help.