Friday, January 23, 2015

Displaying Colour Coded Percentage Columns in WPF XamDataGrid

Infragistic's XamDataGrid is a highly customisable WPF control.

I'm going to walk through the steps required to display a number of colour-coded fields on a XamDataGrid, where each field is bound to an underlying percentage based property.  

This is achieved by binding a Brush instance to the Background property of the Field's CellValuePresenter:




I'll start by adding my "% Complete" Field into the FieldLayout.Fields of my XamDataGrid collection:
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:igWPF="http://schemas.infragistics.com/xaml/wpf" 
        xmlns:igDP="http://infragistics.com/DataPresenter"
        xmlns:igEditors="http://infragistics.com/Editors"
        xmlns:local="clr-namespace:XamDataGridPercentField">
    <igWPF:XamDataGrid>
        <igDP:XamDataGrid.FieldLayouts>
            <igWPF:FieldLayout>
                <igWPF:FieldLayout.Fields>
                    <igWPF:Field Label="% Complete" Name="PercentComplete">
                    </igWPF:Field>
                </igWPF:FieldLayout.Fields>
            </igWPF:FieldLayout>
        </igDP:XamDataGrid.FieldLayouts>
    </igWPF:XamDataGrid>
</Window>
In the above snippet PercentComplete is a property found on my data source (I have a simple list of Project items - bound at runtime to the Window's DataContext)

Now add a new CellBinding to the Field.CellBindings collection, targeting the CellValuePresenter's Background property:

<igWPF:Field Label="% Complete" Name="PercentComplete">
  <igWPF:Field.CellBindings>
      <igWPF:CellBinding Property="Background" Target="CellValuePresenter"
        Binding="{Binding DataItem.PercentComplete, 
                  Converter={StaticResource percentToBrushConverter}, 
                  TargetNullValue=White, FallbackValue=Silver}"/>
  </igWPF:Field.CellBindings>
</igWPF:Field>

The value converter, percentToBrushConverter  does the job of converting a double percentage value into a Brush type based on the numeric value - I'll come to that shortly.

I'm also going to define a custom EditorStyle, percentageFieldStyle , which defines how a XamTextEditor control is used to format the text value of the field:
<igWPF:Field Label="% Complete" Name="PercentComplete" 
             EditorStyle="{StaticResource percentageFieldStyle}">
  <igWPF:Field.CellBindings>
      <igWPF:CellBinding Property="Background" Target="CellValuePresenter"
         Binding="{Binding DataItem.PercentComplete, 
                   Converter={StaticResource percentToBrushConverter}, 
                   TargetNullValue=White, FallbackValue=Silver}"/>
  </igWPF:Field.CellBindings>
</igWPF:Field>
Both percentageFieldStyle and percentToBrushConverter are defined in as local resource in my XamDataGrid, read using the StaticResource markup extension
<igDP:XamDataGrid.Resources>
  <local:PercentToBrushConverter x:Key="percentToBrushConverter" />
  <Style x:Key="percentageFieldStyle" TargetType="igEditors:XamTextEditor">
    <Setter Property="Format" Value="P0" />
    <Setter Property="HorizontalContentAlignment" Value="Center" />
    <Setter Property="ToolTip" Value="{Binding Value, StringFormat='0.00', 
                                RelativeSource={RelativeSource Self}}" />
  </Style>
</igDP:XamDataGrid.Resources>
Delving into the brush converter code, I'll start by creating a class PercentColorRanges. This static class is responsible for determining the background colour to use from a predefined range of colours from the bound % value:
static class PercentColorRanges
{
    private class ColorRange
    {
        public ColorRange(int min, int max, Color color)
        {
            Min = min;
            Max = max;
            Color = color;
        }
        public int Min { getprivate set; }
        public int Max { getprivate set; }
        public Color Color { getprivate set; }
    }
 
    private static readonly HashSet<ColorRange> _colorRanges = new HashSet<ColorRange>()
    {
        new ColorRange(0, 20, Colors.OrangeRed),    
        new ColorRange(21, 40, Colors.Orange),    
        new ColorRange(41, 60, Colors.Yellow),    
        new ColorRange(61, 80, Colors.YellowGreen),    
        new ColorRange(81, 100, Colors.Green)
    };
 
    private static readonly Color DefaultColor = Colors.Silver;
 
    public static Color GetColor(int percentage)
    {
        var range = _colorRanges.FirstOrDefault(r => percentage >= r.Min &&
                                                     percentage <= r.Max);
        return range != null ? range.Color : DefaultColor;
    }
}
I'm using a hard-coded range of colours for this demo (a HashSet of type ColorRange), so for example a % value in the range 0-20 will have a colour of OrangeRed, If for some reason our % falls outside of the 0-100 range then we'll use the DefaultColor - Silver.  
Next comes our PercentToBrushConverter class.  This implements the standard IValueConverter interface which WPF uses to convert to and from data values.  In order to preserve resources, we'll share instances of the brushes in a local dictionary object (each Brush marked as frozen to save on having to hook into change notifications):
[ValueConversion(typeof(double), typeof(Brush))]
public class PercentToBrushConverter : IValueConverter
{
    private static readonly IDictionary<intBrush> _brushesByPercent = 
        new Dictionary<intBrush>();
 
    public object Convert(object value, Type targetType, object parameter, 
                          CultureInfo culture)
    {
        var percentageFrac = value as double?;
        if (percentageFrac == null || percentageFrac.GetValueOrDefault() == 0)
        {
            return Binding.DoNothing;
        }
 
        // Convert to whole number, eg 0.01 = 1
        var percentageValue = percentageFrac.GetValueOrDefault();
        var percentageWhole = (int)Math.Round(percentageValue * 100D, 0);
 
        // See if we've already got this in our list of known brushes by %
        Brush brush;
        if (_brushesByPercent.TryGetValue(percentageWhole, out brush))
        {
            return brush;
        }
 
        var color = PercentColorRanges.GetColor(percentageWhole);
 
        brush = new LinearGradientBrush
        {
            StartPoint = new Point(0, 0), 
            EndPoint = new Point(1, 0),
            GradientStops = new GradientStopCollection(new[]
            {
                CreateFrozenGradient(color, percentageValue),
                CreateFrozenGradient(Colors.Transparent, percentageValue)
            })
        };
 
        brush.Freeze();
 
        _brushesByPercent.Add(percentageWhole, brush);
 
        return brush;
    }
 
    private static GradientStop CreateFrozenGradient(Color color, 
                                                     double offset)
    {
        var grad = new GradientStop(color, offset);
        grad.Freeze();
 
        return grad;
    }
 
    public object ConvertBack(object value, Type targetType, object parameter, 
        CultureInfo culture)
    {
        return Binding.DoNothing;
    }
}
The key to this Converter is the Convert method. We can't convert from a Brush back to a percentage value so ConvertBack simply returns Binding.DoNothing - which instructs the binding engine to not perform any binding action.

We have a Dictionary of brushes keyed on the percent value.  This a small performance optimisation - if we've already created and shared out a brush for a specific numeric value then there's no need to create another brush for that value.  Although this does mean we could have up to 100 brush instances in memory.

For the double value passed to the converter we assume percentages are passed in as decimals, eg 1% = 0.01 through to 100% = 1.0.  Although it's an easy task to also handle the case where % values are passed in as integers 1%=1...100%=100.

We round fractions of a percentage down to the nearest 0 decimal places:
var percentageWhole = (int)Math.Round(percentageValue * 100D, 0);


Next we take a quick peek into the Dictionary of existing brushes and return the one that is already there...if we have one:
Brush brush;
if (_brushesByPercent.TryGetValue(percentageWhole, out brush))
{
  return brush; 
}

We can now determine which colour to use for the background, so a call to the static PercentColorRanges.GetColor(percentageWhole) method, that we created earlier, will return the correct colour

Finally we need to create the new Brush add that to our dictionary before returning the Brush :
brush = new LinearGradientBrush
{
  StartPoint = new Point(0, 0), 
  EndPoint = new Point(1, 0),
  GradientStops = new GradientStopCollection(new[]
  {
    CreateFrozenGradient(color, percentageValue),
    CreateFrozenGradient(Colors.Transparent, percentageValue)
  })
}
I've created a helper function CreateFrozenGradient which creates a Frozen GradientStop from a colour and gradient stop offset location.

The Gradient stop offset is expressed using the default coordinate system - relative to the bounded item, so I can use our calculated % value.  See the MappingMode property for more info.

That's it - pretty simple. The sample source code can be found here.

No comments:

Post a Comment