Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

A WPF Rotary Control

0.00/5 (No votes)
23 Aug 2019 2  
This article presents a highly configurable rotary dial control.

Introduction

This article presents a custom rotary dial control with configurable properties including labels, major ticks and major tick increments. It includes a simple demonstration application with examples of the rotary dial control in use:

Image 1

The top three dials use a circular position indicator.

The top left hand dial has a continuous range of values from 0 to 1, with a major tick every 0.2 units and black labels. The dial is 150 units wide.

The top central dial has integral values from 0 to 100 with a major tick every 20 units and black labels. The dial is 200 units wide.

The top right hand dial has integral values from 0 to 50 with a major tick every 5 units and black labels. The dial is 100 units wide.

The bottom four dials use needle pointers, and have coloured arcs.

The bottom left hand dial is constructed from two dial controls. The top dial has a continuous range of values from 0 to 80, with a major tick every 20 units and black labels. The bottom dial has a continuous range of values from 0 to 1.0, with a major tick every 0.1 units and black labels.

The bottom central left and right dials have a continuous range of values from 0 to 100, with a major tick every 10 units and white labels. Each dial is 200 units wide. The left central dial has labels outside the tick marks, whereas the right central dial has labels inside the tick marks.

The bottom right hand dial spans an arc rather than a full circle.

Background

You will need to understand C# and the basics of WPF.

Designing a Rotary Dial

My design goal was to make the rotary dial as customisable as possible without making it unduly complex. To achieve this aim, a rotary control is constructed from a series of concentric circles or dials, some of which are imaginary and serve to layout components such as labels and tick marks.

The labels are placed around the edge of an imaginary label dial whose radius is set by the LabelDialRadius dependency property. The font size is set by the FontSize dependency property and the font colour is set by the Foreground dependency property.

The major ticks are placed around the edge of an imaginary major label dial whose radius is set by the MajorTickDialRadius dependency property. The length of each major tick is set by the MajorTickLength dependency property.

The minor ticks are placed around the edge of an imaginary minor label dial whose radius is set by the MinorTickDialRadius dependency property. The length of each minor tick is set by the MinorTickLength dependency property.

The angular position of the first value on the dial is set by the StartAngleInDegrees dependency property.

The angular position of the last value on the dial is set by the EndAngleInDegrees dependency property.

Both angles are relative to the 12 o'clock position and in a clock wise direction.

There are two ways to add coloured segments, or complete circles, to a rotary control.

The first way to add coloured segments is to use the Segments dependency property. This holds an array of RotaryControlSegment objects. Each defines a segment with an associated colour and angle. The segments are contiguous, and start at the first value. All segments have the same outer radius, set by SegmentRadius, and the same thickness, set by SegmentRadius.

The second more flexible way to add coloured segments is to use the Arcs dependency property. Each arc has its own radius, start angle, arc angle and thickness.

There is an inner dial which can be used to represent a knob. Its radius is set by the InnerDialRadius dependency property and its colour is set by the InnerDialFill dependency property.

The pointer style is set by the PointerType dependency property. Supported values include a circular pointer, representing a position indicator on a knob, and an arrow pointer representing a needle pointer. For all pointers, apart from the circle, the length, width and fill can be set.

Many of the properties have sensible default values and do not need to be explicitly defined.

Using the Code

It is easy to create a simple dial with a central knob and position indicator as follows:

Image 2

The above is created using the following XAML code:

<view:RotaryControl Grid.Row="0" Grid.Column="3"
x:Name="_dialTemperature" Value="{Binding Temperature,
Mode=TwoWay}" FontBrush="Black" FontSize="20"
Foreground="Black" Background="Transparent">
    <view:RotaryControl.MinimumValue>20</view:RotaryControl.MinimumValue>
    <view:RotaryControl.NumberOfMajorTicks>9</view:RotaryControl.NumberOfMajorTicks>
    <view:RotaryControl.MajorTickIncrement>10</view:RotaryControl.MajorTickIncrement>
    <view:RotaryControl.MajorTickBrush>White</view:RotaryControl.MajorTickBrush>
    <view:RotaryControl.NumberOfMinorTicks>4</view:RotaryControl.NumberOfMinorTicks>
    <view:RotaryControl.MinorTickBrush>White</view:RotaryControl.MinorTickBrush>
    <view:RotaryControl.OuterDialFill>SteelBlue</view:RotaryControl.OuterDialFill>
    <view:RotaryControl.OuterDialBorder>Transparent</view:RotaryControl.OuterDialBorder>
    <view:RotaryControl.OuterDialBorderThickness>1</view:RotaryControl.OuterDialBorderThickness>
    <view:RotaryControl.InnerDialRadius>60</view:RotaryControl.InnerDialRadius>
    <view:RotaryControl.PointerFill>
        <LinearGradientBrush EndPoint="0.5,0" StartPoint="0.5,1">
            <GradientStop Color="#DDDDDD" Offset="0"/>
            <GradientStop Color="#BBBBBB" Offset="1.0"/>
        </LinearGradientBrush>
    </view:RotaryControl.PointerFill>
</view:RotaryControl>

The above defines a rotary dial with grey central control, 9 major ticks on a slate blue background, a major tick increment of 10, a black 20 point font for the labels, and a transparent background. The Value is bound to the Temperature property in the view model.

By default, the control is 200 units wide.

To resize the control, use a LayoutTransform:

<view:RotaryControl.LayoutTransform>
    <ScaleTransform  ScaleX="2" ScaleY="2"/>
</view:RotaryControl.LayoutTransform>

When you resize the control, you will of course have to adjust the font size accordingly. The advantage of using a layout transform, rather than implementing scaling in code, is that the scaling is uniform, and requires far less background code.

To create a dial with integral values, bind the Value dependency property to an integer/long property in the view model. For continuous values, bind Value to a double property in the view model.

You can if you prefer to create a control with a needle pointer as follows:

Image 3

The above is created using the following XAML code:

<view:RotaryControl Grid.Row="1" Grid.Column="5"
FontBrush="White" FontSize="10"
Foreground="Black" Background="Transparent" >
    <view:RotaryControl.PointerFill>
        <LinearGradientBrush EndPoint="0.5,0" StartPoint="0.5,1">
            <GradientStop Color="#DDDDDD" Offset="0"/>
            <GradientStop Color="#AAAAAA" Offset="1.0"/>
        </LinearGradientBrush>
    </view:RotaryControl.PointerFill>

    <view:RotaryControl.OuterDialFill>
        <LinearGradientBrush EndPoint="0.5,0" StartPoint="0.5,1">
            <GradientStop Color="Black" Offset="0"/>
            <GradientStop Color="Gray" Offset="0.5"/>
            <GradientStop Color="Black" Offset="1.0"/>
        </LinearGradientBrush>
    </view:RotaryControl.OuterDialFill>
    <view:RotaryControl.OuterDialBorder>
        <LinearGradientBrush EndPoint="0.5,0" StartPoint="0.5,1">
            <GradientStop Color="Gray" Offset="0"/>
            <GradientStop Color="White" Offset="0.5"/>
            <GradientStop Color="Gray" Offset="1.0"/>
        </LinearGradientBrush>
    </view:RotaryControl.OuterDialBorder>
    <view:RotaryControl.OuterDialBorderThickness>3</view:RotaryControl.OuterDialBorderThickness>

    <view:RotaryControl.InnerDialRadius>0</view:RotaryControl.InnerDialRadius>
    <view:RotaryControl.InnerDialFill>
        <LinearGradientBrush EndPoint="0.5,0" StartPoint="0.5,1">
            <GradientStop Color="White" Offset="0"/>
            <GradientStop Color="White" Offset="0.5"/>
            <GradientStop Color="White" Offset="1.0"/>
        </LinearGradientBrush>
    </view:RotaryControl.InnerDialFill>

    <view:RotaryControl.LabelDialRadius>48</view:RotaryControl.LabelDialRadius>

    <view:RotaryControl.MajorTickDialRadius>65.5</view:RotaryControl.MajorTickDialRadius>
    <view:RotaryControl.MajorTickLength>6</view:RotaryControl.MajorTickLength>
    <view:RotaryControl.NumberOfMajorTicks>11</view:RotaryControl.NumberOfMajorTicks>
    <view:RotaryControl.MajorTickIncrement>10</view:RotaryControl.MajorTickIncrement>
    <view:RotaryControl.MajorTickBrush>White</view:RotaryControl.MajorTickBrush>
    <view:RotaryControl.NumberOfMinorTicks>4</view:RotaryControl.NumberOfMinorTicks>
    <view:RotaryControl.MinorTickBrush>White</view:RotaryControl.MinorTickBrush>

    <view:RotaryControl.StartAngleInDegrees>210</view:RotaryControl.StartAngleInDegrees>
    <view:RotaryControl.EndAngleInDegrees>150</view:RotaryControl.EndAngleInDegrees>

    <view:RotaryControl.PointerAxleFill>
        <LinearGradientBrush EndPoint="0.5,0" StartPoint="0.5,1">
            <GradientStop Color="Gray" Offset="0"/>
            <GradientStop Color="White" Offset="0.5"/>
            <GradientStop Color="Gray" Offset="1.0"/>
        </LinearGradientBrush>
    </view:RotaryControl.PointerAxleFill>
    <view:RotaryControl.PointerLength>45</view:RotaryControl.PointerLength>
    <view:RotaryControl.PointerWidth>2</view:RotaryControl.PointerWidth>
    <view:RotaryControl.PointerType>standard</view:RotaryControl.PointerType>

    <view:RotaryControl.SegmentThickness>5</view:RotaryControl.SegmentThickness>
    <view:RotaryControl.SegmentRadius>35</view:RotaryControl.SegmentRadius>
    <view:RotaryControl.Segments>
        <x:Array Type="{x:Type view:RotaryControlSegment}" >
            <view:RotaryControlSegment Fill="YellowGreen" AngleInDegrees="210"/>
            <view:RotaryControlSegment Fill="Gold" AngleInDegrees="30"/>
            <view:RotaryControlSegment Fill="Orange" AngleInDegrees="30"/>
            <view:RotaryControlSegment Fill="Crimson" AngleInDegrees="30"/>
        </x:Array>
    </view:RotaryControl.Segments>
</view:RotaryControl>

Although the above is quite verbose, it should be relatively easy to understand. The length is due to the large number of properties allowing a high degree of customization. The coloured segments are defined by a segments dependency property. This is an array with four RotaryControlSegment objects each defining a coloured segment. The segments share the same radius and thickness.

You also create a dial that subtends an arc as follows:

Image 4

The above is created using the following XAML code:

<view:RotaryControl Grid.Row="1" Grid.Column="7" FontBrush="Black"
      FontSize="12" Foreground="Black" Background="Transparent"
      Value="{Binding Pressure, Mode=TwoWay}" >
    <view:RotaryControl.PointerType>rectangle</view:RotaryControl.PointerType>
    <view:RotaryControl.PointerFill>
        <LinearGradientBrush EndPoint="0.5,0" StartPoint="0.5,1">
            <GradientStop Color="Black" Offset="0"/>
            <GradientStop Color="#AAAAAA" Offset="1.0"/>
        </LinearGradientBrush>
    </view:RotaryControl.PointerFill>
    <view:RotaryControl.PointerLength>50</view:RotaryControl.PointerLength>
    <view:RotaryControl.PointerWidth>3</view:RotaryControl.PointerWidth>
    <view:RotaryControl.PointerAxleFill>Black</view:RotaryControl.PointerAxleFill>
    <view:RotaryControl.PointerAxleRadius>4</view:RotaryControl.PointerAxleRadius>

    <view:RotaryControl.OuterDialFill>Transparent</view:RotaryControl.OuterDialFill>
    <view:RotaryControl.OuterDialBorderThickness>0
    </view:RotaryControl.OuterDialBorderThickness>

    <view:RotaryControl.InnerDialRadius>10</view:RotaryControl.InnerDialRadius>
    <view:RotaryControl.InnerDialFill>White</view:RotaryControl.InnerDialFill>

    <view:RotaryControl.LabelDialRadius>77</view:RotaryControl.LabelDialRadius>
    <view:RotaryControl.MinimumValue>0</view:RotaryControl.MinimumValue>

    <view:RotaryControl.StartAngleInDegrees>210
    </view:RotaryControl.StartAngleInDegrees>
    <view:RotaryControl.EndAngleInDegrees>330</view:RotaryControl.EndAngleInDegrees>

    <view:RotaryControl.MajorTickDialRadius>61
    </view:RotaryControl.MajorTickDialRadius>
    <view:RotaryControl.MajorTickLength>8</view:RotaryControl.MajorTickLength>
    <view:RotaryControl.MajorTickWidth>1</view:RotaryControl.MajorTickWidth>
    <view:RotaryControl.NumberOfMajorTicks>6</view:RotaryControl.NumberOfMajorTicks>
    <view:RotaryControl.MajorTickIncrement>1</view:RotaryControl.MajorTickIncrement>
    <view:RotaryControl.MajorTickBrush>Black</view:RotaryControl.MajorTickBrush>

    <view:RotaryControl.MinorTickDialRadius>55
    </view:RotaryControl.MinorTickDialRadius>
    <view:RotaryControl.MinorTickLength>2</view:RotaryControl.MinorTickLength>
    <view:RotaryControl.NumberOfMinorTicks>2</view:RotaryControl.NumberOfMinorTicks>
    <view:RotaryControl.MinorTickBrush>Black</view:RotaryControl.MinorTickBrush>

    <view:RotaryControl.SegmentThickness>15</view:RotaryControl.SegmentThickness>
    <view:RotaryControl.SegmentRadius>67</view:RotaryControl.SegmentRadius>
    <view:RotaryControl.Segments>
        <x:Array Type="{x:Type view:RotaryControlSegment}" >
            <view:RotaryControlSegment Fill="YellowGreen" AngleInDegrees="60"/>
            <view:RotaryControlSegment Fill="Gold" AngleInDegrees="30"/>
            <view:RotaryControlSegment Fill="Orange" AngleInDegrees="20"/>
            <view:RotaryControlSegment Fill="Crimson" AngleInDegrees="10"/>
            <view:RotaryControlSegment Fill="White" AngleInDegrees="10"/>
        </x:Array>
    </view:RotaryControl.Segments>

    <view:RotaryControl.Arcs>
        <x:Array Type="{x:Type view:RotaryControlArc}" >
            <view:RotaryControlArc Fill="Black" StartAngleInDegrees="180"
            AngleInDegrees="180" Radius="6" Thickness="1"
            Stroke="Black" StrokeThickness="0"/>
            <view:RotaryControlArc Fill="Black" StartAngleInDegrees="0"
            AngleInDegrees="180" Radius="6" Thickness="1"
            Stroke="Black" StrokeThickness="0"/>

            <view:RotaryControlArc Fill="White" StartAngleInDegrees="200"
            AngleInDegrees="10" Radius="67"
            Thickness="35" StrokeThickness="0"/>

            <view:RotaryControlArc Fill="White" StartAngleInDegrees="200"
            AngleInDegrees="140" Radius="90" Thickness="23"
            Stroke="Black" StrokeThickness="0"/>
            <view:RotaryControlArc Fill="White" StartAngleInDegrees="200"
            AngleInDegrees="140" Radius="52" Thickness="42"
            Stroke="Black" StrokeThickness="0"/>
        </x:Array>

    </view:RotaryControl.Arcs>
</view:RotaryControl>

The Code

The dial is implemented as a WPF UserControl:

<UserControl x:Class="WpfRotaryControlDemo.View.RotaryControl"

             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"

             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 

             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 

             mc:Ignorable="d" 

             d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.Resources>
        <ResourceDictionary>
            <LinearGradientBrush x:Key="InnerDialFillResource">
                <LinearGradientBrush.StartPoint>0.5,1.0</LinearGradientBrush.StartPoint>
                <LinearGradientBrush.EndPoint>0.5,0.0</LinearGradientBrush.EndPoint>
                <GradientStop Color="#BBBBBB" Offset="0"/>
                <GradientStop Color="#DDDDDD" Offset="1.0"/>
            </LinearGradientBrush>
        </ResourceDictionary>
    </UserControl.Resources>
    <Grid Name="_grid" Width="200" 

    Height="200" Background="Transparent">

        <Ellipse x:Name="_ellipseOuterDial" Width="150" 

        Height="150" Stroke="Gainsboro" 

        StrokeThickness="4" Fill="SteelBlue" />

        <Ellipse x:Name="_ellipseInnerDial" 

        Width="100" Height="100" Panel.ZIndex="98"/>

        <Ellipse Name="_pointerCircle" Width="20" 

        Height="20" Stroke="Gainsboro" StrokeThickness="0" Panel.ZIndex="99">
            <Ellipse.RenderTransform>
                <TransformGroup>
                    <TranslateTransform x:Name="_markerTranslation" X="35" Y="0"/>
                </TransformGroup>
            </Ellipse.RenderTransform>
            <Ellipse.Fill>
                <LinearGradientBrush EndPoint="0.5,0" StartPoint="0.5,1">
                    <GradientStop Color="Red" Offset="0"/>
                    <GradientStop Color="DarkRed" Offset="1.0"/>
                </LinearGradientBrush>
            </Ellipse.Fill>
        </Ellipse>

        <Path Name="_pointerStandard" Stroke="Red" 

        StrokeThickness="0" Fill="Red" Panel.ZIndex="100">
            <Path.Data>
                <PathGeometry>
                    <PathFigure StartPoint="100,100">
                        <LineSegment Point="100,98" x:Name="_pointerTopLeft"/>
                        <LineSegment Point="140,98" x:Name="_pointerTopRight"/>
                        <LineSegment Point="150,100" x:Name="_pointerTip"/>
                        <LineSegment Point="140,102" x:Name="_pointerBottomRight"/>
                        <LineSegment Point="100,102" x:Name="_pointerBottomLeft"/>
                        <LineSegment Point="100,100"/>
                    </PathFigure>
                </PathGeometry>
            </Path.Data>
        </Path>

        <Path Name="_pointerArrow" Stroke="Red" 

        StrokeThickness="0" Fill="Red" Panel.ZIndex="100">
            <Path.Data>
                <PathGeometry>
                    <PathFigure StartPoint="100,100">
                        <LineSegment Point="100,98" x:Name="_pointerArrowTopLeft"/>
                        <LineSegment Point="150,100" x:Name="_pointerArrowTip"/>
                        <LineSegment Point="100,102" x:Name="_pointerArrowBottomLeft"/>
                        <LineSegment Point="100,100"/>
                    </PathFigure>
                </PathGeometry>
            </Path.Data>
        </Path>

        <Path Name="_pointerRectangle" Stroke="Red" 

        StrokeThickness="0" Fill="Red" Panel.ZIndex="100">
            <Path.Data>
                <PathGeometry>
                    <PathFigure StartPoint="100,100">
                        <LineSegment Point="100,98" x:Name="_pointerRectangleTopLeft"/>
                        <LineSegment Point="150,98" x:Name="_pointerRectangleTopRight"/>
                        <LineSegment Point="150,102" x:Name="_pointerRectangleBottomRight"/>
                        <LineSegment Point="100,102" x:Name="_pointerRectangleBottomLeft"/>
                        <LineSegment Point="100,100"/>
                    </PathFigure>
                </PathGeometry>
            </Path.Data>
        </Path>

        <Path Name="_pointerAxle" Stroke="Black" 

        StrokeThickness="0" Fill="Black" Panel.ZIndex="101">
            <Path.Data>
                <PathGeometry>
                    <PathFigure StartPoint="100,97" x:Name="_pointerPathFigure">
                        <ArcSegment Point="100,103" Size="3,3" 

                        SweepDirection="Clockwise" IsLargeArc="True" 

                        x:Name="_pointerAxleArc1"/>
                        <ArcSegment Point="100,97" Size="3,3" 

                        SweepDirection="Clockwise" IsLargeArc="True" 

                        x:Name="_pointerAxleArc2"/>
                    </PathFigure>
                </PathGeometry>
            </Path.Data>
        </Path>

    </Grid>
</UserControl>

The XAML above creates the basic rotary control from an outer circle for the control outline, an inner circle for the rotary knob, and various shapes for the pointers.

The tick marks and the labels are created in the CreateControl method which is called from the constructor. As far as I am aware, this cannot be done in the XAML. Each tick mark is created with a Polyline, and each annotation is created with a Label.

The control has a large number of dependency properties:

Value

The current reading. If you want to bind to this value: 

Value="{Binding CoupledValue, Mode=TwoWay}"

Note that the Mode is set to TwoWay as by default it is OneWayToSource. 

MinimumValue The minimum allowed value
FontBrush The brush used to draw the numerals around the label dial
StartAngleInDegrees The angle of the first major tick relative to the 12 o'clock position
EndAngleInDegrees The angle of the last major tick relative to the 12 o'clock position
MajorTickDialRadius The radius of the major tick dial
MajorTickLength The length of each major tick
MajorTickWidth The width of each major tick
NumberOfMajorTicks The number of major ticks (excluding the one at zero)
MajorTickIncrement The numerical increment between adjacent major ticks
MajorTickBrush The brush used to draw the major ticks
MinorTickDialRadius The radius of the minor tick dial
MinorTickLength The length of each minor tick
NumberOfMinorTicks The number of minor ticks per major tick increment
MinorTickBrush The brush used to draw the minor ticks
InnerDialRadius The radius of the inner dial. The inner dial may be used to draw a rotating knob with a circular position indicator.
InnerDialFill The brush used to fill the inner dial
OuterDialFill The brush used to fill the outer dial. The outer dial contains the other dials, the labels, the ticks and the pointer.
OuterDialBorder The brush used to fill the outer dial border.
OuterDialBorderThickness The thickness of the outer dial border.
SegmentThickness The width of the coloured segments.
SegmentRadius The radius of the segments.
Segments An optional array of contiguous coloured segments sharing the same radius and thickness.
Arcs An optional array of coloured segments each with its own starting angle, arc angle, radius and thickness.
PointerType The type of pointer. Allowed values are as follows:
  • "circle" : A circular position indicator
  • "arrow" : A triangular pointer
  • "rectangle" : A rectangular pointer
  • "standard" : A sword shaped pointer
   

By default, the control is 200 units wide. This is set in the XAML and the associated code.

Comments

It is not hard to create a compound dial control, with two or more gauges inside a circular border. The demonstration application contains an example with two semi circular gauges.

Be aware that the control is rectangular, and the invisible region will absorb mouse and keyboard events. This may create problems for semi circular dials. The solution is to ensure a good separation between them.

To change the look and feel of the dial to suit your own needs is quite straightforward assuming a basic knowledge of C# and WPF.

History

  • 3rd May, 2019: First version
  • 21st August, 2019: Second edition, the code has been largely rewritten to make it much more flexible.
  • 23st August, 2019: Minor update to the Value dependency property description. 

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here