Colin Eberhardt's Technology Adventures

Automatically Showing ToolTips on a Trimmed TextBlock (Silverlight)

January 6th, 2011

Silverlight 4 added TextTrimming=”WordEllipsis”, which trims the text that a TextBlock displays based on the available width. This blog post describes a simple method for automatically showing the full text as a tooltip whenever the text is trimmed. This is presented as an attached behaviour.

UPDATE: I have updated this solution to support both WPF and Silverlight, see my recent blog post.

The TextTrimming feature of SL4 allows us to worry a little less about the layout of our applications, giving the option to use fixed widths when rendering text. This can be particularly useful if you have to localize your application. However, arbitrarily trimming the text without providing the user with a mechanism to see the full text is not great for the user. A simple solution is to use a tooltip to present the full text.

Detecting whether the a TextBlock’s text is being trimmed is not something that is available from the TextBlock’s API, there is no IsTrimmed property! A quick search of the Silverlight forums drew a dead end. I did find a decent WPF solution, however this uses various parts of the WPF API that are not present in Silverlight.

Eventually I found that there is a very simple solution … quoting from the MSDN page on Text and Fonts:

You can detect clipped text programmatically because ActualWidth for a TextBlock always reports the expanded size of the text, even if it does not fit in the layout container. If you know where to read the Width for the layout container that is doing the clipping, you can compare these two values.

Therefore, if a TextBlock is trimming the text, the reported ActualWidth of the TextBlock will be the width that the TextBlock requires to render the text in its entirety, not the actual width of the rendered text!

In the example above, the Width of the TextBlock is set explicity, however it is more common that the width of TextBlock is determined by its parent container, a Grid for example.

To wrap this functionality up into something re-useable, I have created a AutoToolTip attached behaviour. The code for this is shown below:

public class TextBlockUtils
{
  /// <summary>
  /// Gets the value of the AutoTooltipProperty dependency property
  /// </summary>
  public static bool GetAutoTooltip(DependencyObject obj)
  {
    return (bool)obj.GetValue(AutoTooltipProperty);
  }
 
  /// <summary>
  /// Sets the value of the AutoTooltipProperty dependency property
  /// </summary>
  public static void SetAutoTooltip(DependencyObject obj, bool value)
  {
    obj.SetValue(AutoTooltipProperty, value);
  }
 
  /// <summary>
  /// Identified the attached AutoTooltip property. When true, this will set the
  /// TextBlock TextTrimming property to WordEllipsis, and display a tooltip with
  /// the full text whenever the text is trimmed.
  /// </summary>
  public static readonly DependencyProperty AutoTooltipProperty =  
           DependencyProperty.RegisterAttached("AutoTooltip", typeof(bool), typeof(TextBlockUtils),
            new PropertyMetadata(false, OnAutoTooltipPropertyChanged));
 
  private static void OnAutoTooltipPropertyChanged(DependencyObject d,
                                     DependencyPropertyChangedEventArgs e)
  {
    TextBlock textBlock = d as TextBlock;
    if (textBlock == null)
      return;
 
    if (e.NewValue.Equals(true))
    {
      textBlock.TextTrimming = TextTrimming.WordEllipsis;
      ComputeAutoTooltip(textBlock);
      textBlock.SizeChanged += TextBlock_SizeChanged;
    }
    else
    {
      textBlock.SizeChanged -= TextBlock_SizeChanged;
    }
  }
 
  private static void TextBlock_SizeChanged(object sender, SizeChangedEventArgs e)
  {
    TextBlock textBlock = sender as TextBlock;
    ComputeAutoTooltip(textBlock);
  }
 
    /// <summary>
  /// Assigns the ToolTip for the given TextBlock based on whether the text is trimmed
  /// </summary>
  private static void ComputeAutoTooltip(TextBlock textBlock)
  {
    FrameworkElement parentElement = VisualTreeHelper.GetParent(textBlock) as FrameworkElement;
    if (parentElement != null)
    {
      if (textBlock.ActualWidth > parentElement.ActualWidth)
      {
        ToolTipService.SetToolTip(textBlock, textBlock.Text);
      }
      else
      {
        ToolTipService.SetToolTip(textBlock, null);
      }
    }
  }
}

Most of this is boiler-plate attached behaviour code. The most interesting method is ComputeAutoTooltip which determines whether a tooltip is required. This simply looks at the ActualWidth of the parent and compares it to the ActualWidth of the TextBlock.

To use this code, ensure that your TextBlock is placed within its own Grid, then set the attached property to true. You can see this in action below:

<Grid util:GridUtils.ColumnDefinitions="2*,"
      util:GridUtils.RowDefinitions=",">
 
  <sdk:GridSplitter Grid.Column="0" Grid.Row="0" Grid.RowSpan="2"
                    ShowsPreview="True"
                    Background="LightGray"
                    Width="10"
                    HorizontalAlignment="Right"/>
 
  <Grid Grid.Column="0"
        Margin="0,0,10,0">
    <TextBlock Text="The rain in Spain stays mainly in the plain"   
                util:TextBlockUtils.AutoTooltip="True"/>
  </Grid>
 
  <Grid Grid.Column="2">
    <TextBlock Text="In Hertford, Hereford, and Hampshire, hurricanes hardly ever happen"
                util:TextBlockUtils.AutoTooltip="True"/>
  </Grid>
 
  <Grid Grid.Column="0" Grid.Row="1"
        Margin="0,0,10,0">
    <TextBlock Text="Peter Piper picked a peck of pickled peppers"
                util:TextBlockUtils.AutoTooltip="True"/>
  </Grid>
 
  <Grid Grid.Column="2" Grid.Row="1">
    <TextBlock Text="She sells sea-shells on the sea-shore"
                util:TextBlockUtils.AutoTooltip="True"/>
  </Grid>
</Grid>

Note the use of the simplified grid syntax which I blogged about earlier. You can see this code in action below. Move the grid splitter to see the tooltips automatically enabled when an ellipsis is displayed:

You can download the full sourcecode here: AutoTooltipTextBlock.zip

Regards, Colin E.

A Simplified Grid Markup for Silverlight and WPF

December 21st, 2010

The WPF / Silverlight syntax is long and cumbersome. This blog post describe a simple attached property that allows you to specify row and column widths / heights as a simple comma separated list, e.g. RowDefinitions=”Auto,,3*,,,,2*”

The Grid is probably one of the most useful and versatile layouts that Silverlight and WPF offers. However, if you hand craft your XAML, as I do, you will probably start to find the Grid markup for defining rows and columns to be verbose and cumbersome. If we look at the following example, which uses a mixture of Auto, Star and Pixel widths / heights:

Despite this being a simple example, the required row and column definitions are highly verbose:

<Grid>
  <Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition/>
    <RowDefinition/>
    <RowDefinition/>
    <RowDefinition/>
    <RowDefinition/>
    <RowDefinition Height="2*"/>
  </Grid.RowDefinitions>
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="100"/>
    <ColumnDefinition/>
  </Grid.ColumnDefinitions>
 
  <TextBlock Text="User Details" FontSize="20"
          HorizontalAlignment="Center"
          Grid.ColumnSpan="2"/>
 
  <TextBlock Text="Forename:"
              Grid.Row="1"/>
  <TextBox Text="Jeremy"
            Grid.Column="1" Grid.Row="1"/>
 
  <TextBlock Text="Surname:"
              Grid.Row="2"/>
  <TextBox Text="James"
            Grid.Column="1" Grid.Row="2"/>
 
  <TextBlock Text="Age:"
              Grid.Row="3"/>
  <TextBox Text="24"
            Grid.Column="1" Grid.Row="3"/>
 
  <TextBlock Text="Phone:"
              Grid.Row="4"/>
  <TextBox Text="+44 191 555467"
            Grid.Column="1" Grid.Row="4"/>
 
  <TextBlock Text="Notes:"
              Grid.Row="5"/>
  <TextBox Text="A multi-line block of text ..." 
            TextWrapping="Wrap"
            Grid.Row="6" Grid.ColumnSpan="2"/>
</Grid>

To provide a simpler alternative, I have created attached properties, one for the column definitions, and the other for the rows. These attached properties allow you to specify the columns / rows as a comma separated list of heights and widths.

Using this technique, the above example becomes:

<Grid local:GridUtils.ColumnDefinitions="100,"
      local:GridUtils.RowDefinitions="Auto,,,,,,2*">
 
  <TextBlock Text="User Details" FontSize="20"
          HorizontalAlignment="Center"
          Grid.ColumnSpan="2"/>
 
  <TextBlock Text="Forename:"
              Grid.Row="1"/>
  <TextBox Text="Jeremy"
            Grid.Column="1" Grid.Row="1"/>
 
  <TextBlock Text="Surname:"
              Grid.Row="2"/>
  <TextBox Text="James"
            Grid.Column="1" Grid.Row="2"/>
 
  <TextBlock Text="Age:"
              Grid.Row="3"/>
  <TextBox Text="24"
            Grid.Column="1" Grid.Row="3"/>
 
  <TextBlock Text="Phone:"
              Grid.Row="4"/>
  <TextBox Text="+44 191 555467"
            Grid.Column="1" Grid.Row="4"/>
 
  <TextBlock Text="Notes:"
              Grid.Row="5"/>
  <TextBox Text="A multi-line block of text ..." 
            TextWrapping="Wrap"
            Grid.Row="6" Grid.ColumnSpan="2"/>
</Grid>

The comma separated list defines the widths / heights of each column / row. The number of items in this list defined the number of rows / column that are generated. This notation supports pixel, star and auto widths / heights. Also, if you want a row or column with the default size, just leave the value blank, i.e. a row definition of “,,,” will create four rows of default height.

The code to achieve this is pretty simple, involving a couple of attached properties and string parsing in their changed event handlers. The complete code for the GridUtils class is given below, feel free to copy and paste it into your project:

using System.Windows;
using System.Windows.Controls;
 
namespace SimplifiedGrid
{
  public class GridUtils
  {
    #region RowDefinitions attached property
 
    /// <summary>
    /// Identified the RowDefinitions attached property
    /// </summary>
    public static readonly DependencyProperty RowDefinitionsProperty =
        DependencyProperty.RegisterAttached("RowDefinitions", typeof(string), typeof(GridUtils),
            new PropertyMetadata("", new PropertyChangedCallback(OnRowDefinitionsPropertyChanged)));
 
    /// <summary>
    /// Gets the value of the RowDefinitions property
    /// </summary>
    public static string GetRowDefinitions(DependencyObject d)
    {
      return (string)d.GetValue(RowDefinitionsProperty);
    }
 
    /// <summary>
    /// Sets the value of the RowDefinitions property
    /// </summary>
    public static void SetRowDefinitions(DependencyObject d, string value)
    {
      d.SetValue(RowDefinitionsProperty, value);
    }
 
    /// <summary>
    /// Handles property changed event for the RowDefinitions property, constructing
    /// the required RowDefinitions elements on the grid which this property is attached to.
    /// </summary>
    private static void OnRowDefinitionsPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
      Grid targetGrid = d as Grid;
 
      // construct the required row definitions
      targetGrid.RowDefinitions.Clear();
      string rowDefs = e.NewValue as string;
      var rowDefArray = rowDefs.Split(',');
      foreach (string rowDefinition in rowDefArray)
      {
        if (rowDefinition.Trim() == "")
        {
          targetGrid.RowDefinitions.Add(new RowDefinition());
        }
        else
        {
          targetGrid.RowDefinitions.Add(new RowDefinition()
          {
            Height = ParseLength(rowDefinition)
          });
        }
      }
    }
 
    #endregion
 
 
    #region ColumnDefinitions attached property
 
    /// <summary>
    /// Identifies the ColumnDefinitions attached property
    /// </summary>
    public static readonly DependencyProperty ColumnDefinitionsProperty =
        DependencyProperty.RegisterAttached("ColumnDefinitions", typeof(string), typeof(GridUtils),
            new PropertyMetadata("", new PropertyChangedCallback(OnColumnDefinitionsPropertyChanged)));
 
    /// <summary>
    /// Gets the value of the ColumnDefinitions property
    /// </summary>
    public static string GetColumnDefinitions(DependencyObject d)
    {
      return (string)d.GetValue(ColumnDefinitionsProperty);
    }
 
    /// <summary>
    /// Sets the value of the ColumnDefinitions property
    /// </summary>
    public static void SetColumnDefinitions(DependencyObject d, string value)
    {
      d.SetValue(ColumnDefinitionsProperty, value);
    }
 
    /// <summary>
    /// Handles property changed event for the ColumnDefinitions property, constructing
    /// the required ColumnDefinitions elements on the grid which this property is attached to.
    /// </summary>
    private static void OnColumnDefinitionsPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
      Grid targetGrid = d as Grid;
 
      // construct the required column definitions
      targetGrid.ColumnDefinitions.Clear();
      string columnDefs = e.NewValue as string;
      var columnDefArray = columnDefs.Split(',');
      foreach (string columnDefinition in columnDefArray)
      {
        if (columnDefinition.Trim() == "")
        {
          targetGrid.ColumnDefinitions.Add(new ColumnDefinition());
        }
        else
        {
          targetGrid.ColumnDefinitions.Add(new ColumnDefinition()
          {
            Width = ParseLength(columnDefinition)
          });
        }
      }
    }
 
    #endregion
 
    /// <summary>
    /// Parses a string to create a GridLength
    /// </summary>
    private static GridLength ParseLength(string length)
    {
      length = length.Trim();
 
      if (length.ToLowerInvariant().Equals("auto"))
      {
        return new GridLength(0, GridUnitType.Auto);
      }
      else if (length.Contains("*"))
      {
        length = length.Replace("*","");
        if (string.IsNullOrEmpty(length)) length = "1";
        return new GridLength(double.Parse(length), GridUnitType.Star);
      }
 
      return new GridLength(double.Parse(length), GridUnitType.Pixel);
    }
  }
}

The code which parses the row and column strings is also executed by the Visual Studio designer, so you can see your new rows / columns immediately. It also works just fine in WPF and Silverlight.

You can download example projects in both technologies: SimplfiedGrid.zip

readers might also be interested in Mike Talbot’s AutoGrid which dynamically adds rows / columns based as children are added to the grid. A very neat idea!

(Thanks to Rob Newsome for coming up with this idea in the first place!)

Regards, Colin E.

Developing a Lookless Silverlight Gauge Control (part 2)

October 20th, 2010

In a previous blog post I described the process of creating a lookless gauge control. I introduced the concept of an attached view model which separates view specific concepts from the control. In this post I demonstrate how this allows for great flexibility when re-templating the control.

In my previous post I described the development of a radial gauge control where I removed any view-specific logic and properties from the control by introducing an attached view model into the control’s template. My reasons for doing this were to create a completely lookless control in the order that this would permit greater flexibility when re-templating or themeing. The control I created was a gauge control, which in my previous blog post I rendered as a radial gauge. The ‘radial concepts’ of angle, and other derived properties are contained within the attached view model, with the Gauge control itself only containing concepts that are common across all gauges (radial or otherwise). The control is shown below:

In this post I will create two different templates for this control. The first is a small variation on the radial gauge which is rendered as a semi-circle. The second is very different, a bullet graph, where the gauge is rendered as a linear indicator. With each, an attached view model is introduced into the controls template in order to compute properties that aid in the rendering of the control.

A Semi-circular Gauge

The original attached view model I created was hard-coded to create a radial control where the needle has a sweep angle of 300 degrees. To support a semi-circular gauge we need to be able to configure this parameter. I achieved this by simply making SweepAngle a public property of the attached view model. This property is used wherever the view model needs to convert a gauge value to a radial location (i.e. when computing the tick mark rotation angles):

public class RadialGaugeControlViewModel : AttachedViewModelBase
{
  public RadialGaugeControlViewModel()
  {
    SweepAngle = 300;
  }
 
  /// <summary>
  /// Gets / sets the sweep angle of the radial gauge
  /// </summary>
  public double SweepAngle
  { get; set; }
 
  private double ValueToAngle(double value)
  {
    double minAngle = -SweepAngle / 2;
    double maxAngle = SweepAngle / 2;
    double angularRange = maxAngle - minAngle;
 
    return (value - Gauge.Minimum) / (Gauge.Maximum - Gauge.Minimum) *
        angularRange + minAngle;
  }
 
  ...
}

This allows it to be set in the control template of our re-templated gauge control where the view model is instantiated and attached:

<Style TargetType="local:GaugeControl" x:Key="themedGauge">
  <Setter Property="FontSize" Value="8"/>
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="local:GaugeControl">
        <Grid x:Name="LayoutRoot" >
          <Grid>
            <!-- attach the view model -->
            <local:RadialGaugeControlViewModel.Attach>
              <local:RadialGaugeControlViewModel
                     SweepAngle="180"/>
            </local:RadialGaugeControlViewModel.Attach>
 
            <!-- gauge template goes here! -->
        </Grid>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

This means that the various properties of the attached view model that the view binds to in order to render the various control parts, such as the needle, are now computed with a 180 degree sweep.

For the semi-circular gauge, I used a similar technique to the original radial gauge for creating the required layout, where a grid with various rows / columns with star widths / heights create a proportional layout within which elements such as the needle are placed:

<Grid>
<!-- attach the view model -->
<local:RadialGaugeControlViewModel.Attach>
  <local:RadialGaugeControlViewModel SweepAngle="180" Clip="false"/>
</local:RadialGaugeControlViewModel.Attach>
 
<!-- the gauge outline -->
<Path Stroke="Black" StrokeThickness="3" StrokeLineJoin="Round"
      Stretch="Uniform">
  <Path.Data>
    <PathGeometry>
      <PathGeometry.Figures>
        <PathFigure IsClosed="True" 
                    StartPoint="0.5,0">
          <PathFigure.Segments>
            <ArcSegment Size="0.5,0.5" RotationAngle="45" IsLargeArc="False"
                      SweepDirection="Clockwise"
                      Point="1,0.5"/>
            <LineSegment Point="1,0.55"/>
            <LineSegment Point="0,0.55"/>
            <LineSegment Point="0,0.5"/>
            <ArcSegment Size="0.5,0.5" RotationAngle="45" IsLargeArc="False"
                            SweepDirection="Clockwise"
                            Point="0.5,0"/>
          </PathFigure.Segments>
        </PathFigure >
      </PathGeometry.Figures>
    </PathGeometry>
  </Path.Data>
</Path>
 
<Grid ShowGridLines="true">
  <Grid.RowDefinitions>
    <RowDefinition Height="3.5*"/>
    <RowDefinition Height="4*"/>
    <RowDefinition Height=".6*"/>
    <RowDefinition Height=".6*"/>
    <RowDefinition Height="*"/>
  </Grid.RowDefinitions>
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="8.0*"/>
    <ColumnDefinition Width=".7*"/>
    <ColumnDefinition Width=".7*"/>
    <ColumnDefinition Width="8.0*"/>
  </Grid.ColumnDefinitions>
 
  <!-- needle -->
  <Path Stretch="Uniform"
    Grid.Row="1" Grid.Column="1"
    Grid.ColumnSpan="2" Grid.RowSpan="2"
    Fill="Black"
    HorizontalAlignment="Center"
    Stroke="Black" StrokeThickness="0.5"
    Data="M 0,0 l 5,60 l -10, 0"
    RenderTransformOrigin="0.5,1">
    <Path.RenderTransform>
      <RotateTransform Angle="{Binding Path=ValueAngle}"/>
    </Path.RenderTransform>
  </Path>
 
  <!-- needle cover -->
  <Ellipse Fill="White"
            Stroke="Black" StrokeThickness="2"
            Width="13" Height="13"
            HorizontalAlignment="Center" VerticalAlignment="Bottom"
            Grid.Row="2" Grid.Column="1"
            Grid.RowSpan="2" Grid.ColumnSpan="2"/>
</Grid>

You can see the various gridlines below:

The code for creating the major / minor ticks and qualitative ranges are much the same as before. However, for a bit of variation I did not want the labels on this gauge to be rotated. In order to achieve this I used the same technique as before where each label is initially constructed at the same location, then a translate / rotate transform is used to move it to the correct location on the dial. In this control, the text label is rotated a second time in the opposite direction in order to bring it back to its original orientation:

<ItemsControl.ItemTemplate>
  <DataTemplate>
    <Grid Width="50" Height="20">
      <Grid.RenderTransform>
        <TransformGroup>
          <TranslateTransform X="-25" Y="-10"/>
          <TranslateTransform Y="{Binding Path=Parent.GridHeight,
              Converter={StaticResource ScaleFactorConverter}, ConverterParameter=-0.69}"/>
          <RotateTransform Angle="{Binding Path=Angle}"/>
        </TransformGroup>
      </Grid.RenderTransform>
      <TextBlock Text="{Binding Path=Value}" 
            VerticalAlignment="Center" HorizontalAlignment="Center"
            RenderTransformOrigin="0.5, 0.5">
        <!-- rotate the labels by '-Angle' to return to their original orientation -->
        <TextBlock.RenderTransform>
          <RotateTransform Angle="{Binding Path=Angle,
                Converter={StaticResource ScaleFactorConverter}, ConverterParameter=-1}" />
        </TextBlock.RenderTransform>
      </TextBlock>
    </Grid>
  </DataTemplate>
</ItemsControl.ItemTemplate>

The finished semi-circular gauge can be seen here next to my original radial gauge control:

So, the original control has proven to be pretty versatile in that it can be re-templated to look quite different. To allow this, the attached view model was modified to expose a few view-centric properties that could be set when it is created and attached within the template.

However, the example above is still a radial gauge. A much bigger challenge would be to re-template the control to look completely different. That was my next challenge!

A Bullet Graph Template

Radial gauge controls look very pretty; however they eat up a lot of screen real-estate, and are not the best way of visualising a one-dimensional metric. A much clearer visualisation can be achieved by using a linear gauge, a bullet-graph for example. This section describes how a new attached view model can be applied to the gauge control, to support a template which produces a bullet graph.

Using the same process as in the above example, the basic structure of the control is defined using a Grid:

<Style TargetType="local:GaugeControl" x:Key="bulletGraphGauge">
  <Setter Property="FontSize" Value="8"/>
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="local:GaugeControl">
        <Grid x:Name="LayoutRoot" Background="LightBlue">
          <Grid ShowGridLines="True">
            <!-- attach the view model -->
            <local:RadialGaugeControlViewModel.Attach>
              <local:BulletGraphGaugeViewModel/>
            </local:RadialGaugeControlViewModel.Attach>
 
            <!--the grid layout -->
            <Grid.RowDefinitions>
              <RowDefinition Height="3*"/>
              <RowDefinition Height="*"/>
              <RowDefinition Height="2*"/>
              <RowDefinition Height="*"/>
              <RowDefinition Height="3*"/>
            </Grid.RowDefinitions>                
 
            <!-- featured measure indicator -->
            <Rectangle Fill="Black"
                        Grid.Row="2"
                        HorizontalAlignment="Left"
                        Width="{Binding Path=FeaturedMeasureLength}"/>
          </Grid>
        </Grid>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

The ‘featured measure’, which is a thermometer-like bar which indicates the current value of the gauge has its Width property bound to the view model which is attached at the top of the template. To simplify the code, I refactored the attached view model concept to extract a base class which is shared by both the radial and bullet-graph subclasses. This base class takes care of attaching to its parents DataContext, adapting the control’s properties, exposing the actual width / height of the control and the other bits and pieces I described in my previous blog post. The bullet graph view model is then much simpler as a result. In the code below you can see how the FeaturedMeasureLength is computed in the view model:

public class BulletGraphGaugeViewModel : AttachedViewModelBase
{
  public BulletGraphGaugeViewModel()
  {
  }
 
  private GaugeControl Gauge
  {
    // the view model’s DataContext is bound to the Gauge
    get { return DataContext != null ? (GaugeControl)DataContext : null; }
  }
 
  public double FeaturedMeasureLength
  {
    get
    {
      if (Gauge == null)
        return 0;
 
      return ValueToWidth(Gauge.Value - Gauge.Minimum);
    }
  }
 
  /// <summary>
  /// Converts the given value (which should be between Gauge.Maximum / Gauge.Minimum)
  /// into suitable width for rendering within the view.
  /// </summary>
  private double ValueToWidth(double value)
  {
    double range = Gauge.Maximum - Gauge.Minimum;
    return (value) / range * ElementWidth;
  }
}

Next we’ll add the ‘qualitative range’ scale:

<!-- Qualitative ranges -->
<ItemsControl ItemsSource="{Binding Path=Ranges}"
              Grid.Row="1" Grid.RowSpan="3">
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <StackPanel Orientation="Horizontal"/>
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Rectangle Width="{Binding Path=Width}"
                  Stroke="Black" StrokeThickness="0.2"
                  Fill="{Binding Path=Color, Converter={StaticResource ColorToBrushConverter}}"/>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>
<Border BorderBrush="Black" BorderThickness="1.0"
        Grid.Row="1" Grid.RowSpan="3" Margin="-1"/>

Once again, the attached view model is used to adapt the properties exposed by the control in order to provide properties which are easier to bind to in order to create the required UI. Here the Gauge controls QualitativeRange is adapted to expose a Range property, which the ItemsControl in the XAML above binds to, this property is an enumeration of RangeItems which each have the correct width and colour for our UI:

public IEnumerable<RangeItem> Ranges
{
  get
  {
    if (Gauge == null)
      yield break;
 
    for (int i = 0; i < Gauge.QualitativeRange.Count; i++)
    {
      var range = Gauge.QualitativeRange[i];
      if (i == 0)
      {
        // first RangeItem, width is determined from the first range value
        yield return new RangeItem()
        {
          Color = range.Color,
          Width = ValueToWidth(range.Maximum - Gauge.Minimum)
        };
      }
      else
      {
        // subsequent items, width computed as the difference between the
        // current value and its predecessor
        var previousRange = Gauge.QualitativeRange[i - 1];
        yield return new RangeItem()
        {
          Color = range.Color,
          Width = ValueToWidth(range.Maximum - previousRange.Maximum)
        };
      }
    }
  }
}
 
...
 
public class RangeItem
{
  public double Width { get; set; }
  public Color Color { get; set; }
}

Finally, the scales are added:

<!-- upper scale -->
<ItemsControl ItemsSource="{Binding Path=MajorTicks}"
              VerticalAlignment="Center"
              Grid.Row="0"
              ItemsPanel="{StaticResource CanvasTemplate}">
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Grid Width="50" Height="20">
        <Grid.RenderTransform>
          <TransformGroup>
            <!-- centre the labels -->
            <TranslateTransform X="-25" Y="-10"/>
            <TranslateTransform X="{Binding Path=Position}"/>
          </TransformGroup>
        </Grid.RenderTransform>
        <TextBlock Text="{Binding Path=Label}"
                    VerticalAlignment="Center"
                    HorizontalAlignment="Center"/>
      </Grid>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>
 
<!-- major ticks -->
<ItemsControl ItemsSource="{Binding Path=MajorTicks}"
              VerticalAlignment="Bottom"
              Grid.Row="0"
              ItemsPanel="{StaticResource CanvasTemplate}">
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Line X1="{Binding Path=Position}" Y1="0" X2="{Binding Path=Position}" Y2="-5"
            Stroke="Black"/>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>
 
<!-- minor ticks -->          
<ItemsControl ItemsSource="{Binding Path=MinorTicks}"
              VerticalAlignment="Bottom"
              Grid.Row="0"
              ItemsPanel="{StaticResource CanvasTemplate}">
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Line X1="{Binding}" Y1="0" X2="{Binding}" Y2="-3"
            Stroke="Black"/>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

Again, exactly the same pattern is employed, with the MajorTicks and MinorTicks properties exposed by the attached view model, each providing an enumeration of simple value objects with a Position and Label properties, which are bound to the three ItemsControls above to create the required UI.

The complete bullet graph template is shown below next to the original radial gauge:

In conclusion, the use of an attached view model provides a mechanism for exposing view-specific properties to the view logic within the XAML template. This enables the construction of a control which really is entirely lookless.

You can download the sourcecode for this project: GaugeControl.zip

Regards, Colin E.

Developing a (very) Lookless Silverlight Radial Gauge Control

August 19th, 2010

This blog post describes the development of a lookless radial gauge control. In this post I will explore the use of an attached view model in order to move view specific properties and logic out of the control code in order to give a truly lookless control.

Today I had to get up far too early in order to catch an early morning flight to Copenhagen with a connection in Amsterdam. What to do for the six hours I would be travelling? Armed with a netbook and Visual Studio 2010 Express I thought it would be fun to have a go at developing a Silverlight gauge control. I know that there are already one or two free ones out there, with a decent looking one available on codeproject, however, it still felt like a good way to pass the time!

In order to make things a little more challenging I wanted to create a control that was truly lookless. So, what do I mean by this? Firstly a gauge control in its simplest sense displays the location of some indicator between a maximum and minimum value. There is nothing inherently circular about a gauge, thermometers are a good example of a linear gauge. So, I don’t want any ‘circular’ logic in the control itself. Secondly, custom controls often have certain expectations about the presence of named elements within their template. By this I mean that the template must contain, for example, a Path element called ‘needle’ which the control code will manipulate (The gauge published in the codeproject article above requires the presence of four named elements in the template). This forces certain constraints regarding how the control can be templated, this isn’t really lookless is it?

The following example shows the gauge control which I created, and the rest of this post describes the implementation:

<local:GaugeControl Value="65" Width="200" Height="200"  
                    Maximum="100" Minimum="50"
                    x:Name="gauge">
  <local:GaugeControl.QualitativeRange>
    <local:QualitativeRanges>
      <local:QualitativeRange Color="Yellow" Maximum="75"/>
      <local:QualitativeRange Color="Orange" Maximum="90"/>
      <local:QualitativeRange Color="Red" Maximum="100"/>
    </local:QualitativeRanges>
  </local:GaugeControl.QualitativeRange>
</local:GaugeControl>

The Starting Point

The first step was to create a Gauge custom control with Value, Maximum and Minimum dependency properties. The only logic defined within the control itself is to set the DataContext of the root visual element to the control instance itself. This is quite a common approach to control design, allowing elements within the template to bind to the control properties:

public override void OnApplyTemplate()
{
  base.OnApplyTemplate();
 
  Grid root = GetTemplateChild("LayoutRoot") as Grid;
  root.DataContext = this;
}

The first thing I added to the control template was the ‘face’ of the radial gauge. This is simply an Ellipse with a pretty gradient fill and stroke:

<!-- dial background and outer border -->
<Ellipse Stretch="Fill" StrokeThickness="8">
  <Ellipse.Fill>
    <RadialGradientBrush Center="0.5,0.5">
      <GradientStop Color="#EEF"/>
      <GradientStop Color="#99B" Offset="0.9"/>
      <GradientStop Color="#335" Offset="1"/>
    </RadialGradientBrush>
  </Ellipse.Fill>
  <Ellipse.Stroke>
    <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
      <GradientStop Color="#BBD" Offset="0"/>
      <GradientStop Color="#003" Offset="1"/>
    </LinearGradientBrush>
  </Ellipse.Stroke>
</Ellipse>

Adding the Needle

The next thing I added to the control was a needle. This is rendered using a simple Path with a LinearGradient in order to give it some sense of depth. I want the needle to have a length of approximately 70% of the gauge’s radius, a simple way to achieve this is to construct it within a Grid that uses ‘star’ widths / heights to provide a proportional layout and configure the Path to stretch to fill the cell it occupies:

<!-- the needle path -->              
<Grid>
  <Grid.RowDefinitions>
    <RowDefinition Height="3*"/>
    <RowDefinition Height="7*"/>
    <RowDefinition Height="10*"/>
  </Grid.RowDefinitions>
  <Grid.ColumnDefinitions>
    <ColumnDefinition/>
    <ColumnDefinition/>
  </Grid.ColumnDefinitions>
 
  <Path Stretch="Uniform"
      Grid.Row="1" Grid.ColumnSpan="2"
      HorizontalAlignment="Center"
      Stroke="Black" StrokeThickness="0.5"
      Data="M 0,0 l 10,60 l -10, 40 l -10 -40">
    <Path.Fill>
      <LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
        <GradientStop Color="DarkRed" Offset="0"/>
        <GradientStop Color="DarkRed" Offset="0.45"/>
        <GradientStop Color="Red" Offset="0.55"/>
        <GradientStop Color="Red" Offset="1"/>
      </LinearGradientBrush>
    </Path.Fill>
  </Path>
</Grid>

Here you can see how the needle is scaled by its parent Grid:

Rotating the needle to reflect the current Gauge Value is achieved quite simply via RotateTransform. However, this needs to be converted into a rotation angle which depends on the Gauge Maximum /Minimum values together with the overall angle of sweep on the gauge. I initially approach this problem by applying bindings via value converters and multibindings, however I found myself repeating the same conversion logic in numerous places within the template in order to render the ticks etc… Ideally the angle of rotation would be something that the template could bind to. The template DataContext is bound to the Gauge control itself, however as stated earlier I do not want ‘circular’ concepts to leak into the control.

An attached View Model

The solution I came up with for this problem was to create a view model that lives entirely within the control template that acts as an adapter for the Gauge, supplementing its properties with the needed ‘circular’ concepts. In keeping with my aims I could not instantiate this view model within the Gauge control itself, so instead it is created via an attached behaviour within the control template:

<Style TargetType="local:GaugeControl">
  <Setter Property="FontSize" Value="10"/>
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="local:GaugeControl">
        <Grid x:Name="LayoutRoot" >
          <Grid>
            <!-- attached the view model -->
            <local:RadialGaugeControlViewModel.Attach>
              <local:RadialGaugeControlViewModel/>
            </local:RadialGaugeControlViewModel.Attach>
 
            <!-- ... control template goes here ...  -->
          </Grid>
        </Grid>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

The view model defines the Attach property and in its change handler performs the logic required to bind to the DatatContext of the parent (which is the Gauge control itself), and set itself as the DataContext of the Grid to which it is being attached. This allows the rest of the template to bind to properties of the RadialGaugeControlViewModel.

public class RadialGaugeControlViewModel : FrameworkElement, INotifyPropertyChanged
{
  #region Attach attached property
 
  public static readonly DependencyProperty AttachProperty =
      DependencyProperty.RegisterAttached("Attach", typeof(object), typeof(RadialGaugeControlViewModel),
          new PropertyMetadata(null, new PropertyChangedCallback(OnAttachChanged)));
 
  public static RadialGaugeControlViewModel GetAttach(DependencyObject d)
  {
    return (RadialGaugeControlViewModel)d.GetValue(AttachProperty);
  }
 
  public static void SetAttach(DependencyObject d, RadialGaugeControlViewModel value)
  {
    d.SetValue(AttachProperty, value);
  }
 
  /// <summary>
  /// Change handler for the Attach property
  /// </summary>
  private static void OnAttachChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    Grid targetElement = d as Grid;
    RadialGaugeControlViewModel viewModel = e.NewValue as RadialGaugeControlViewModel;
 
    // handle the loaded event
    targetElement.Loaded += new RoutedEventHandler(Grid_Loaded);
 
  }
 
  /// <summary>
  /// Handle the Loaded event of the Grid to enable the attached
  /// view model to bind to properties of the Grid Parent element
  /// </summary>
  static void Grid_Loaded(object sender, RoutedEventArgs e)
  {
    FrameworkElement targetElement = sender as FrameworkElement;
    FrameworkElement parent = targetElement.Parent as FrameworkElement;
 
    // use the attached view model as the DataContext of the element it is attached to
    RadialGaugeControlViewModel attachedModel = GetAttach(targetElement);
    targetElement.DataContext = attachedModel;
 
    // bind the DataContext of the view model to the DataContext of the parent.
    attachedModel.SetBinding(RadialGaugeControlViewModel.DataContextProperty,
      new Binding("DataContext")
      {
        Source = parent
      });
  }
}

It is now possible to expose a property on the view model which provides the Gauge Value as an angle:

public double ValueAngle
{
  get
  {
    if (Gauge == null)
      return 0.0;
 
    return ValueToAngle(Gauge.Value);
  }
}
 
private double ValueToAngle(double value)
{
  double minAngle = -150;
  double maxAngle = 150;
  double angularRange = maxAngle - minAngle;
 
  return (value - Gauge.Minimum) / (Gauge.Maximum - Gauge.Minimum) *
      angularRange + minAngle;
}

This can then be bound to in the template in order to rotate the needle. For an extra ‘flourish’ a drop shadow is also added to the needle which binds to this same rotation angle in order to give a subtle ‘3D’ effect:

<Path Stretch="Uniform"
    Grid.Row="1" Grid.ColumnSpan="2"
    HorizontalAlignment="Center"
    Stroke="Black" StrokeThickness="0.5"
    Data="M 0,0 l 10,60 l -10, 40 l -10 -40"
    RenderTransformOrigin="0.5,1">
  <Path.RenderTransform>
    <!-- rotate the needle -->
    <RotateTransform Angle="{Binding Path=ValueAngle}"/>
  </Path.RenderTransform>
  <Path.Fill>
    <LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
      <GradientStop Color="DarkRed" Offset="0"/>
      <GradientStop Color="DarkRed" Offset="0.45"/>
      <GradientStop Color="Red" Offset="0.55"/>
      <GradientStop Color="Red" Offset="1"/>
    </LinearGradientBrush>
  </Path.Fill>
  <Path.Effect>
    <DropShadowEffect Color="Black" Direction="{Binding Path=ValueAngle}"
                      BlurRadius="3"
                      Opacity="0.6"
                      ShadowDepth="5"/>
  </Path.Effect>
</Path>

Adding a Scale

The gauge control needs to have tick marks and labels render around the dial face at regularly spaced intervals between the Maximum and Minimum values. Here the view model comes into its own by providing a list of ‘Tick’ value objects, each of which provide the view with the required information to render tick marks and their labels:

public IEnumerable<Tick> MajorTicks
{
  get
  {
    if (Gauge == null)
      yield break;
 
    double tickSpacing = (Gauge.Maximum - Gauge.Minimum) / 10;
    for (double tick = Gauge.Minimum; tick <= Gauge.Maximum; tick += tickSpacing)
    {
      yield return new Tick()
      {
        Angle = ValueToAngle(tick),
        Value = tick.ToString("N0"),
        Parent = this
      };
    }
  }
}
 
public class Tick
{
  public double Angle { get; set; }
  public string Value { get; set; }
  public RadialGaugeControlViewModel Parent { get; set; }
}

The XAML which renders the major tick marks uses an ItemsControl to create each tick instance:

<!-- major ticks -->
<ItemsControl ItemsSource="{Binding Path=MajorTicks}"
              VerticalAlignment="Center" HorizontalAlignment="Center">
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <Canvas></Canvas>
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>
  <ItemsControl.ItemTemplate>
    <DataTemplate>
      <Ellipse Fill="Black" Width="8" Height="8">
        <Ellipse.RenderTransform>
          <TransformGroup>
            <!-- centre the ellipse -->
            <TranslateTransform X="-4" Y="-4"/>
            <!-- offset to the edge of the gauge -->
            <TranslateTransform X="0"
                Y="{Binding Path=Parent.GridHeight, Converter={StaticResource ScaleFactorConverter},
                                                                 ConverterParameter=-0.37}"/>
            <!-- rotate -->
            <RotateTransform Angle="{Binding Angle}"/>
          </TransformGroup>
        </Ellipse.RenderTransform>
      </Ellipse>
    </DataTemplate>
  </ItemsControl.ItemTemplate>
</ItemsControl>

As you can see each tick is simply an ellipse. The clever part is how each Ellipse is transformed to position it appropriately. It is first centre to make subsequent transforms a little simpler, it is then translated y an offset which moves it to the edge of the gauge face. The offset factor is computed as some fraction of the overall size of the gauge control. In order to achieve this I reluctantly had to angle SizeChanged events on the template Grid in order to expose its ActualHeight / ActualWidth, this is because ElementName binding on these properties appears to be broken.

On attachment the view model handles SizeChanged events as follows:

/// <summary>
/// Handle SizeChanged events from the grid so that we can inform elements
/// of changes in the ActualHeight / ActualWidth
/// </summary>
private void Grid_SizeChanged(object sender, SizeChangedEventArgs e)
{
  OnPropertyChanged("GridHeight");
  OnPropertyChanged("GridWidth");
 
  Grid.Clip = new EllipseGeometry()
  {
    RadiusX = _grid.ActualWidth / 2,
    RadiusY = _grid.ActualHeight / 2,
    Center = new Point(_grid.ActualWidth / 2, _grid.ActualHeight / 2)
  };
}
 
public double GridWidth
{
  get { return _grid.ActualWidth; }
}
 
public double GridHeight
{
  get { return _grid.ActualHeight; }
}

Adding tick labels and minor tick marks both use a simple variation on the above described approach:

I also added a ‘qualitative value’ range which renders a colour coded band beneath the needle. Again, this uses variations on the same approach, with the view model adapting the properties of the Gauge control and the template binding to these properties, together with the Grid size information in order to provide any required scaling.

The last flourish was to add a ‘glass’ effect to the Gauge. This was ‘borrowed’ directly from this fantastic codeproject article on creating ‘round glassy buttons’.

The finished Gauge is shown below, where its value is bound to a Slider control:

Conclusions

I am pretty happy with how this Gauge control turned out, visually I think it looks pretty good. I am also happy that I have succeeded in my initial aim of making it completely lookless. The attached view model within the control template is an interesting approach that moves view specific concepts (in this case angular properties) into the view, which is where the belong.

This radial Gauge control could certainly be improved to allow a more flexible scale calculation. Also, the RadialGaugeControlViewModel could also expose some of the view specific properties such as the radial sweep angle (currently hard-coded to 300 degrees) allowing them to be set in the template. For now, I think I will leave this control as it is.

Tomorrow I am on another early flight, this time heading back home. I might take this as an opportunity to provide a view for this control making use of a different attached view model.

You can download the full source for this article: GaugeControl.zip

(Apologies for the lack of project structure, this code was written using VS 2010 Express).

Regards, Colin E.