Introduction

This article is the third in a series that talks about designing and implementing a set of custom gauge controls for the Windows Phone 7 platform. The first article talked about the design considerations and about using the code, and the second article dug deeper into the implementation of the scales. In this article, I'm going to talk about how I implemented the indicators. Before reading this article, I strongly recommend that you read the first and second articles in the series. This way you can better understand what this article describes and how the indicators feel in the big picture.

The articles in this series

Below, you can find a list of all the articles in this series:

The Indicator base class

This is the base class from which all other indicators should derive. The Indicator class derives from the Control base class and it is abstract since it shouldn’t be used directly. This class exposes the properties that are common to all indicators. These properties are the Value at which the indicator points on the scale and the Owner of the indicator (the scale to which the indicator belongs).

These two properties can be seen in the image below:

The Owner property is a regular CLR property that is used to set the scale to which the indicator belongs. The definition of this property can be seen in the code below:

public Scale Owner
{
    get
    {
        return this.owner;
    }
    internal set
    {
        if (this.owner != value)
        {
            this.owner = value;
            UpdateIndicator(owner);
        }
    }
}

As you can see from the code above, every time an indicator is assigned to a new scale, the indicator is updated. This is done by calling the UpdateIndicator private function. Inside this function, the Value property of the indicator is coerced to be inside the owner scale range. The definition for this method can be seen below:

private void UpdateIndicator(Scale owner)
{
    if (owner != null)
    {
        if (Value < owner.Minimum)
            Value = owner.Minimum;
        if (Value > owner.Maximum)
            Value = owner.Maximum;
    }
    UpdateIndicatorOverride(owner);
}

As you can see, after coercing the value, the method also calls the UpdateIndicatorOverride method. This is a virtual method that can be overridden in the derived classes to add additional logic when the owner changes.

The Value property of the Indicator base class is a dependency property. This property has a change handler that can be seen in the image below:

private static void ValuePropertyChanged(DependencyObject o, 
        DependencyPropertyChangedEventArgs e)
{
    Indicator ind = o as Indicator;
    if (ind != null)
    {
        ind.OnValueChanged((double)e.NewValue, (double)e.OldValue);
    }
}
protected virtual void OnValueChanged(double newVal, double oldVal) { }

As you can see, the change handler calls the onValueChanged virtual method. This will be overridden in the derived classes to properly update the indicators.

The last important code to talk about is the MeasureOverride method. I overloaded this method so that I can set the owner of the indicator automatically. After the indicator control is instantiated and the layout process starts, the Owner property of the indicator will be set. The definition of this method can be seen in the code below:

protected override Size MeasureOverride(Size availableSize)
{
    //the main purpose of this override is to set the owner for the 
    //indicator. The actual measuring calculation will be done in 
    //the derived classes
    DependencyObject parent = base.Parent;
    while (parent != null)
    {
        Scale scale = parent as Scale;
        if (scale != null)
        {
            this.Owner = scale;
            break;
        }
        FrameworkElement el = parent as FrameworkElement;
        if (el != null)
        {
            parent = el.Parent;
        }
    }
    return base.MeasureOverride(availableSize);
}

As you can see from the code above, the method tries to set the indicator’s owner recursively. The Owner property is set only if the parent is a Scale type.

The BarIndicator class

Another base class that is used to build some of the indicators is the BarIndicator class. This class adds the properties that are specific to bar indicators. Bar indicators are represented by a solid path. In the case of a linear scale, the indicator will be represented by a rectangle. In the case of a radial scale, the indicator will be represented by a circle segment. The specific properties are the bar thickness and the bar brush. These two properties can be seen in the image below.

As you can see from the diagram, this class is also abstract. This is because a bar indicator’s shape depends on the type of the scale in which the indicator is used. The two properties are dependency properties with change handlers attached. The change handlers are shown in the code below:

private static void BarThicknessPropertyChanged(DependencyObject o, 
                    DependencyPropertyChangedEventArgs e)
{
    BarIndicator ind = o as BarIndicator;
    if (ind != null)
    {
        ind.OnBarThicknesChanged((int)e.NewValue, (int)e.OldValue);
    }
}
private static void BarBrushPropertyChanged(DependencyObject o, 
                    DependencyPropertyChangedEventArgs e)
{
}
protected virtual void OnBarThicknesChanged(int newVal, int oldVal) { }

As you can see, when the thickness changes, the code calls the virtual OnBarThicknessChanged method. This will be used in the derived classes to properly update the indicator. Since the Brush is freezable and gets updated automatically every time the property changes, the bar brush change handler does nothing.

The LinearBarIndicator class

This class is the first concrete class that I’m going to talk about. It is the bar indicator that can be used for linear scales. The class derives from the BarIndicator base class and defines no additional properties. Since this is a concrete Control class, I also added a default template for this indicator in the generic.xaml file. The default indicator template for the LinearBarIndicator can be seen in the listing below:

<Style TargetType="loc:LinearBarIndicator" >
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="loc:LinearBarIndicator">
                <Rectangle Fill="{TemplateBinding BarBrush}"></Rectangle>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

As you can see, the template is trivial. It doesn’t even have a part name that can be used in the code-behind. I did this because I wanted the indicator to be very simple. If you want a linear indicator that is more complex, you could add a path with a part name instead of the rectangle and handle the path in the code-behind. One such example would be if you wanted you linear indicator to look like a sine function.

The code-behind for this indicator has only a few methods. The first one I’m going to talk about is the OnValueChanged override. The definition for this method can be seen in the listing below:

protected override void OnValueChanged(double newVal, double oldVal)
{
    //every time the value changes set the width and the hight of
    //the indicator.
    //setting these properties will trigger the measure and arrange passes
    LinearScale scale = Owner as LinearScale;
    if (scale != null)
    {
        Size sz = GetIndicatorSize(scale);
        Width = sz.Width;
        Height = sz.Height;
    }
}

The first thing the method does is to check if the owner is a LinearScale. If it is, it calculates the indicator’s desired size using the GetIndicatorSize helper method and sets the Width and Height properties of the control. Setting these properties will trigger the measure and arrange passes again. This is necessary so that we can redraw the indicator to the correct size.

The code for the GetindicatorSize method can be seen in the listing below:

private Size GetIndicatorSize(LinearScale owner)
{
    //gets the size of the indicator based on the current value and the
    //owner dimensions
    double width = 0, height = 0;
    if (owner.Orientation == Orientation.Horizontal)
    {
        height = BarThickness;
        width = GetExtent(owner.ActualWidth, Value, 
                          owner.Maximum, owner.Minimum);
    }
    else
    {
        width = BarThickness;
        height = GetExtent(owner.ActualHeight, Value, 
                           owner.Maximum, owner.Minimum);
    }
    return new Size(width, height);
}

//gets the length the indicator should have
private double GetExtent(double length, double value, double max, double min)
{
    return length * (value - min) / (max - min);
}

This method gets the desired size of the indicator by making use of another helper method that is also presented. By specifying the entire available length, the current value of the indicator, and the scale range, the GetExtent helper method determines the length the indicator should have. This method doesn’t specify whether this length should be the width or the height of the control. This is determined in the GetIndicatorSize method based on the Orientation property of the scale that owns the indicator.

The last two methods are the MeasureOverride and ArrangeOverride methods. The definition for the MeasureOverride method can be seen in the listing below.

protected override Size MeasureOverride(Size availableSize)
{
    //call the base version to set the owner
    base.MeasureOverride(availableSize);
    Size size = new Size();
    //get the desired size of the indicator based on the owner size
    LinearScale owner = Owner as LinearScale;
    if (owner != null)
    {
        size = GetIndicatorSize(owner);
    }
    return size;
}

As you know, this method is used to determine the desired size of the control on which it is overridden. In this implementation, I first call the base implementation so that the owner of the control can be set. Next, I check if the owner is a LinearScale, and if it is, I call the GetIndicatorSize helper method to get the desired size. At the end, I return either a size of 0 or the result from the GetIndicatorSize method.

The ArrangeOverride method will be used to position the indicator in the available size. This positioning will depend on the orientation and on the tick placement in the scale that owns the indicator. The definition can be seen below:

protected override Size ArrangeOverride(Size arrangeBounds)
{
    //with every arrange pass the size of the indicator should 
    //be set again. this is important if the orientation is
    //vertical as the start position changes every time the value changes
    //so the indicator should be rearranged
    LinearScale scale = Owner as LinearScale;
    Size sz = base.ArrangeOverride(arrangeBounds);
    if (scale != null)
    {
        //reset the indicator size after each arrange phase
        sz = GetIndicatorSize(scale);
        Width = sz.Width;
        Height = sz.Height;
        Point pos = scale.GetIndicatorOffset(this);
        TranslateTransform tt = new TranslateTransform();
        tt.X = pos.X;
        tt.Y = pos.Y;
        this.RenderTransform = tt;
    }
    return sz;
}

In this method, the indicator is arranged by using a TranslateTransform. In order to get the offset position at which to place the indicator, the method calls an internal LinearScale function. Another important thing to note here is that I reset the width and height of the indicator. This needs to be done because every time this method is called, the indicator should have a new size (even though the Value property remains unchanged). I didn’t present the GetIndicatorOffset method in the previous article because I wanted to talk about it here. This method gets the offset the indicator should be placed at depending on the scale orientation and on the tick placement. The definition of this method can be seen below:

internal Point GetIndicatorOffset(Indicator ind)
{
    //get's the offset at which the indicator is placed inside the owner
    Point pos = new Point();
    if (Orientation == Orientation.Horizontal)
    {

        if (TickPlacement == LinearTickPlacement.TopLeft)
        {
            pos.X = 0;
            pos.Y = GetLabels().Max(p => p.DesiredSize.Height) + 
              GetTicks().Max(p => p.DesiredSize.Height) + RangeThickness + 5;
        }
        else
        {
            pos.X = 0;
            pos.Y = ActualHeight - ind.DesiredSize.Height - 
              (GetLabels().Max(p => p.DesiredSize.Height) + 
               GetTicks().Max(p => p.DesiredSize.Height) + RangeThickness + 7);
        }
    }
    else
    {
        if (TickPlacement == LinearTickPlacement.TopLeft)
        {
            pos.X = GetLabels().Max(p => p.DesiredSize.Width) + 
              GetTicks().Max(p => p.DesiredSize.Width) + RangeThickness + 6;
            pos.Y = ActualHeight - ind.DesiredSize.Height;
        }
        else
        {
            pos.X = ActualWidth - ind.DesiredSize.Width - 
             (GetLabels().Max(p => p.DesiredSize.Width) + 
              GetTicks().Max(p => p.DesiredSize.Width) + RangeThickness + 6);
            pos.Y = ActualHeight - ind.DesiredSize.Height;
        }
    }
    return pos;
}

The RadialBarIndicator class

This class is the other class that derives from the BarIndicator base class. This will represent the bar indicator for radial scales. The default template for the RadialBarIndicator can be seen in the listing below:

<Style TargetType="loc:RadialBarIndicator">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="loc:RadialBarIndicator">
                <Path x:Name="PART_BAR" Fill="{TemplateBinding BarBrush}"/>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

The indicator is represented by a path that will be set in the code-behind every time the value changes. The shape of the indicator needs to be changed very often. The first place this happens is when the owner changes. To handle this, I have overridden the UpdateIndicatorsOverride method. The code can be seen in the listing below:

protected override void UpdateIndicatorOverride(Scale owner)
{
    base.UpdateIndicatorOverride(owner);
    RadialScale scale = Owner as RadialScale;
    if (scale != null)
    {
        SetIndicatorGeometry(scale, Value);
    }
}

The same needs to happen when the value changes or when the bar thickness changes. The definitions for these methods can be seen in the listing below:

protected override void OnValueChanged(double newVal, double oldVal)
{
    RadialScale scale = Owner as RadialScale;
    if (scale != null)
    {
        SetIndicatorGeometry(scale, Value);
    }
}

protected override void OnBarThicknesChanged(int newVal, int oldVal)
{
    base.OnBarThicknesChanged(newVal, oldVal);
    RadialScale scale = Owner as RadialScale;
    if (scale != null)
    {
        SetIndicatorGeometry(scale, Value);
    }
}

All these methods use a private helper that creates the necessary geometry. The definition for this helper method can be seen in the listing below:

private void SetIndicatorGeometry(RadialScale scale, double value)
{
    if (thePath != null)
    {
        double min = scale.MinAngle;
        double max = scale.GetAngleFromValue(Value);
        if (scale.SweepDirection == SweepDirection.Counterclockwise)
        {
            min = -min;
            max = -max;
        }
        double rad = scale.GetIndicatorRadius();
        if (rad > BarThickness)
        {
            Geometry geom = RadialScaleHelper.CreateArcGeometry(
                              min, max, rad, BarThickness, scale.SweepDirection);
            //stop the recursive loop. only set a new geometry
            //if it is different from the current one
            if (thePath.Data == null || thePath.Data.Bounds != geom.Bounds)
                thePath.Data = geom;
        }
    }
}

The first thing the method does is to determine the minimum and maximum angles of the indicator. This is done by calling an internal scale method called GetAngleFromValue. This method can be seen below:

internal double GetAngleFromValue(double value)
{
    //ANGLE=((maxa-mina)*VAL+mina*maxv-maxa*minv)/(maxv-minv)
    double angle = ((MaxAngle - MinAngle) * value + MinAngle * 
           Maximum - MaxAngle * Minimum) / (Maximum - Minimum);
    return angle;
}

The next thing that needs to be done is to calculate the radius of the indicator. This is done by calling the GetindicatorRadius internal scale method. The definition for this method can be seen below:

internal double GetIndicatorRadius()
{
    double maxRad = RadialScaleHelper.GetRadius(RadialType, 
           new Size(ActualWidth, ActualHeight), MinAngle, MaxAngle, SweepDirection);
    return maxRad - GetLabels().Max(p => p.DesiredSize.Height) - 
           GetTicks().Max(p => p.DesiredSize.Height) - RangeThickness - 3;
}

As you can see, the method delegates to the RadialScaleHelper in order to get the maximum allowed radius. It then subtracts from the maximum the label, tick, and range heights.

The shape of the indicator is than created by using the CreateArcGeometry method that was described in the previous article.

The last remaining methods are the MeasureOverride and the ArrangeOverride methods. These will be used to calculate the desired size of the indicator and to arrange it. The definition for the MeasureOverride method can be seen in the listing below:

protected override Size MeasureOverride(Size availableSize)
{
    //call the base version to set the parent
    base.MeasureOverride(availableSize);
    //return all the available size
    double width = 0, height = 0;
    if (!double.IsInfinity(availableSize.Width))
        width = availableSize.Width;
    if (!double.IsInfinity(availableSize.Height))
        height = availableSize.Height;
    RadialScale scale = Owner as RadialScale;
    if (scale != null)
    {
        //every time a resize happens the indicator needs to be redrawn
        SetIndicatorGeometry(scale, Value);
    }
    return new Size(width, height);
}

The method first calls the base version in order to set the owner. After this, the method calculates and sets the indicator geometry by using the helper method described above. The definition of the ArrangeOverride method can be seen below:

protected override Size ArrangeOverride(Size arrangeBounds)
{
    TranslateTransform tt = new TranslateTransform();
    RadialScale scale = Owner as RadialScale;
    if (scale != null)
    {
        //calculate the geometry again. the first time this
        //was done the owner had a size of (0,0)
        //and so did the indicator. Once the owner has
        //the correct size (measureOveride has been called)
        //i should re-calculate the shape of the indicator
        SetIndicatorGeometry(scale, Value);
        Point center = scale.GetIndicatorOffset();
        tt.X = center.X;
        tt.Y = center.Y;
        RenderTransform = tt;
    }
    return base.ArrangeOverride(arrangeBounds);
}

As you can see, the center of the scale is determined and the indicator is offset by using a TranslateTransform. The GetIndicatorOffset internal scale method can be seen below:

internal Point GetIndicatorOffset()
{
    return RadialScaleHelper.GetCenterPosition(RadialType, 
           new Size(ActualWidth, ActualHeight), MinAngle, MaxAngle, SweepDirection);
}

This method delegates to the GetCenterPosition helper method that I talked about in the second article of the series.

The NeedleIndicator class

The last indicator I implemented for this library was the needle indicator for radial scales. This is another concrete indicator class. This class derives from the Indicator base class and defines no additional properties. The default template for this control can be seen in the listing below:

<Style TargetType="loc:NeedleIndicator">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="loc:NeedleIndicator">
                <Path x:Name="PART_Needle" 
                   Data="M7,0 L0,10 L5,10 L5,70 L8,70 L8,10 L13,10 Z" Fill="White" />
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

The default template is a Path that defines an arrow. This can be seen in the image below:

The first method that I’m going to talk about here is the ArrangeOverride method. This method will be used to arrange the needle indicator by using a set of three transforms.

  • A scale transform – to draw the needle all the way near the scale no matter the size of the scale
  • A rotate transform – to rotate the needle base on the Value property
  • A translate transform – to position the needle based on the radial type property

The definition for this method can be seen in the listing below:

protected override Size ArrangeOverride(Size finalSize)
{
    Size size = base.ArrangeOverride(finalSize);
    //arrange the indicator in the center
    SetIndicatorTransforms();
    RadialScale scale = Owner as RadialScale;
    if (scale != null)
    {
        Point center = scale.GetIndicatorOffset();
        TransformGroup tg = RenderTransform as TransformGroup;
        if (tg != null)
        {
            //add a scale in order to make the needle to feet exactly inside the range
            ScaleTransform st = tg.Children[0] as ScaleTransform;
            double rad = scale.GetIndicatorRadius();
            if (st != null && DesiredSize.Height != 0 && 
                !double.IsInfinity(DesiredSize.Height) && rad != 0)
            {
                //factor is the radius devided by the height
                double factor = rad / ( DesiredSize.Height);
                st.ScaleX = factor;
                st.ScaleY = factor;
            }
            TranslateTransform tt = tg.Children[2] as TranslateTransform;
            if (tt != null)
            {
                tt.X = center.X - DesiredSize.Width / 2;
                tt.Y = center.Y - DesiredSize.Height;
            }
        }
    }
    return DesiredSize;
}

The first thing this method does is to set the RenderTransform property of the indicator. This is done in the SetIndicatorTransforms method. This method sets the RenderTransform property only if the property has its default value which is a matrix transform. This can be seen in the listing below:

private void SetIndicatorTransforms()
{
    if (RenderTransform is MatrixTransform)
    {
        TransformGroup tg = new TransformGroup();
        TranslateTransform tt = new TranslateTransform();
        RotateTransform rt = new RotateTransform();
        ScaleTransform st = new ScaleTransform();

        tg.Children.Add(st);
        tg.Children.Add(rt);
        tg.Children.Add(tt);

        this.RenderTransformOrigin = new Point(0.5, 1);
        this.RenderTransform = tg;
    }
}

The next step is to scale the indicator based on the current size of the owner. This is done by dividing the radius of the owner to the height of the indicator. The last step in the ArrangeOverride method is to calculate the corresponding offset and to set the translate transform.

Another important helper method is the SetIndicatorAngle method. This is used to modify the indicator’s rotate transform based on the current value of the Value property. The definition can be seen below:

private void SetIndicatorAngle(RadialScale scale, double value)
{
    double angle = scale.GetAngleFromValue(Value);
    if (scale.SweepDirection == SweepDirection.Counterclockwise)
    {
        angle = -angle;
    }
    //rotate the needle
    TransformGroup tg = RenderTransform as TransformGroup;
    if (tg != null)
    {
        RotateTransform rt = tg.Children[1] as RotateTransform;
        if (rt != null)
        {
            rt.Angle = angle;
            Debug.WriteLine("angle changed to " + angle);
        }
    }
}

This method is called in the value change handler. The definition for the OnValueChanged handler can be seen in the image below:

protected override void OnValueChanged(double newVal, double oldVal)
{
    RadialScale scale = Owner as RadialScale;
    if (scale != null)
    {
        SetIndicatorAngle(scale, Value);
    }
}

The method is also called when the indicator owner changes. The definition for the UpdateindicatorsOverride can be seen below:

protected override void UpdateIndicatorOverride(Scale owner)
{
    base.UpdateIndicatorOverride(owner);
    SetIndicatorTransforms();
    RadialScale scale = owner as RadialScale;
    if(scale!=null)
    {
        SetIndicatorAngle(scale, Value);
    }
}

Final thoughts

This article talked about only a few types of indicators. There are certainly many others that can be implemented. Two such examples are a marker indicator and a needle indicator for linear scales. The marker indicator could be implemented for both the linear and radial scales. This indicator would show a custom data template at the specified value with a possible label to also show the value. The needle indicator for the linear scale could show a line with an arrow and a label at the specified value. The image below shows how the needle indicator for the linear scale could look:

There are, of course, many other customization and extension possibilities.

Please feel free to post your comments and suggestions. Also, if you want to vote for this article, please vote for the first one in the series :).

History

  • Created on Monday, March 21, 2011.
推荐.NET配套的通用数据层ORM框架:CYQ.Data 通用数据层框架
新浪微博粉丝精灵,刷粉丝、刷评论、刷转发、企业商家微博营销必备工具"