Click here to Skip to main content
15,885,084 members
Articles / Desktop Programming / WPF

Wind Meter - Custom WPF Control

Rate me:
Please Sign up or sign in to vote.
4.93/5 (37 votes)
18 Mar 2013CPOL2 min read 40.9K   1.4K   53   15
This article describes how to make a custom WPF control that indicates wind speed and direction.

Image 1

Introduction

One late night, as I was preparing a course on how to create custom WPF controls, I was (also) watching the weather forecast and discovered that they used a somewhat special indicator to display wind speed and direction and thought that it would make and excellent inspired example for the course.

Background

I was aiming to demonstrate an example that includes dynamically changing animations over time e.g. binding the properties of the animation. Even if this seems trivial there are only few proper examples on the internet that demonstrates this behavior as most animations are "fixed" e.g. simple transitions with static values.

Idea and Graphics

The main idea with this control is having an easy to read visual indication of the wind direction and speed and that the indication can be given for day and night time.

The different layouts and behaviors of the control is seen below:

WindMeter layouts

There are several animation of the wind meter.

The animation of WindMeter

  • The fan will rotate to indicate the speed.
  • The pointer will wiggle "in the wind" to indicate precision.
  • The pointer will rotate to indicate the wind direction.
  • The change between layouts will fade in and out.

Using the Code

To use the wind meter just reference the control as done in the demo application and add the control to your markup like this:

XML
<Window x:Class="DemoApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:demoApp="clr-namespace:DemoApp"
xmlns:controls="clr-namespace:NMT.Wpf.Controls;assembly=NmtUiLib"
Title="MainWindow" Height="350" Width="525">
	<Grid>
		<controls:WindMeter Margin="124,119,205,36" Wind="10" Direction="45"/>
	</Grid>
</Window>

The wind meter have the following properties:

WindMeter class

  • Direction
    (int)(degrees) - rotation is clockwise, the default direction is 0 which points NW.
  • DirectionOffset(int)(degrees) - offsets the Direction, default is 0.
  • Display(enum) - Fan, Day, Night, default is DisplayType.Fan.
  • Shadow (Color) - the color of the shadow, can be used to make the pointer visible when places on dark background in Night mode, default is black.
  • Wiggle(bool) - Let the wind meter wiggle in the wind, default true.
  • WiggleDegrees(int)(degrees) - the wiggle degrees, default is 10.
  • Wind(int)(m/s) - wind speed in meters pr. second, default is 0.

The Template

Here is the template for the wind meter, the design elements are omitted.

XML
 <!-- WindMeterStyle -->
<Style x:Key="WindMeterStyle" TargetType="{x:Type local:WindMeter}">
  <Setter Property="Background" Value="Transparent" />
  <Setter Property="ClipToBounds" Value="False" />
  <Setter Property="Template">
  <Setter.Value>
    <ControlTemplate TargetType="{x:Type local:WindMeter}">
    <Grid>
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="10*" />
        <ColumnDefinition Width="5*" />
        <ColumnDefinition Width="70*" />
        <ColumnDefinition Width="5*" />
        <ColumnDefinition Width="10*" />
      </Grid.ColumnDefinitions>
      <Grid.RowDefinitions>
        <RowDefinition Height="10*" />
        <RowDefinition Height="5*" />
        <RowDefinition Height="70*" />
        <RowDefinition Height="5*" />
        <RowDefinition Height="10*" />
      </Grid.RowDefinitions>
      <!-- PART_pointer_border -->
      <Border x:Name="PART_pointer_border" Grid.Column="0" Grid.Row="0" 
          Grid.ColumnSpan="5" Grid.RowSpan="5" RenderTransformOrigin="0.5,0.5">
        <Border.Effect>
          <DropShadowEffect Opacity=".3" Direction="320" ShadowDepth="3" 
              Color="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Shadow}" />
        </Border.Effect>
        <!-- PART_pointer -->
        <Rectangle x:Name="PART_pointer" Grid.Column="0" Grid.Row="0" Grid.ColumnSpan="5" Grid.RowSpan="5" 
            Fill="{StaticResource Pointer}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" 
            RenderTransformOrigin="0.5,0.5">
          <Rectangle.Triggers>
            <EventTrigger RoutedEvent="Rectangle.Loaded">
              <BeginStoryboard>
                <Storyboard x:Name="PART_pointer_storyboard">
                  <DoubleAnimation x:Name="PART_pointer_animation" 
                    Storyboard.TargetName="PART_pointer"
                    Storyboard.TargetProperty="(Rectangle.RenderTransform).(RotateTransform.Angle)" 
                    To="0" From="0" Duration="0:0:.5">
                    <DoubleAnimation.EasingFunction>
                      <CubicEase EasingMode="EaseInOut"></CubicEase>
                    </DoubleAnimation.EasingFunction>
                  </DoubleAnimation>
                </Storyboard>
              </BeginStoryboard>
            </EventTrigger>
          </Rectangle.Triggers>
          <Rectangle.RenderTransform>
            <RotateTransform Angle="-135" />
          </Rectangle.RenderTransform>
        </Rectangle>
        <Border.Triggers>
          <EventTrigger RoutedEvent="Border.Loaded">
            <BeginStoryboard>
              <Storyboard x:Name="PART_wiggle_storyboard">
                <DoubleAnimation x:Name="PART_wiggle_animation" 
                    Storyboard.TargetName="PART_pointer_border"
                    Storyboard.TargetProperty="(Border.RenderTransform).(RotateTransform.Angle)" 
                    To="0" From="0" Duration="0:0:0" RepeatBehavior="Forever" AutoReverse="True">
                  <DoubleAnimation.EasingFunction>
                    <ElasticEase Oscillations="2" Springiness=".5" />
                  </DoubleAnimation.EasingFunction>
                </DoubleAnimation>
              </Storyboard>
            </BeginStoryboard>
          </EventTrigger>
        </Border.Triggers>
        <Border.RenderTransform>
          <RotateTransform />
        </Border.RenderTransform>
      </Border>
      <Border Grid.Column="1" Grid.Row="1" Grid.ColumnSpan="3" Grid.RowSpan="3">
        <Border.Effect>
          <DropShadowEffect Opacity=".3" Direction="320" ShadowDepth="3" Color="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Shadow}" />
        </Border.Effect>
        <!-- PART_fan -->
        <Rectangle x:Name="PART_fan" Fill="{StaticResource Fan}" 
            HorizontalAlignment="Stretch" VerticalAlignment="Stretch" RenderTransformOrigin="0.5,0.5">
        <Rectangle.Triggers>
          <EventTrigger RoutedEvent="Rectangle.Loaded">
            <BeginStoryboard>
              <Storyboard x:Name="PART_fan_storyboard">
                <DoubleAnimation x:Name="PART_fan_animation" 
                    Storyboard.TargetName="PART_fan"
                    Storyboard.TargetProperty="(Rectangle.RenderTransform).(RotateTransform.Angle)" 
                    To="0" From="360" Duration="0:0:0" RepeatBehavior="Forever">
                </DoubleAnimation>
              </Storyboard>
            </BeginStoryboard>
          </EventTrigger>
        </Rectangle.Triggers>
        <Rectangle.RenderTransform>
          <RotateTransform />
        </Rectangle.RenderTransform>
      </Rectangle>
    </Border>
    <Viewbox Stretch="Fill" Grid.Column="2" Grid.Row="2">
      <Label x:Name="PART_numeric" FontSize="48" Content="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Wind}" Opacity="0"/>
    </Viewbox>
    </Grid>
    <ControlTemplate.Triggers>
      <Trigger Property="Display" Value="Day">
        <Trigger.EnterActions>
          <BeginStoryboard>
            <Storyboard>
              <DoubleAnimation To="0" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="PART_fan" Duration="0:0:1"/>
              <DoubleAnimation To="1" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="PART_numeric" Duration="0:0:1"/>
            </Storyboard>
          </BeginStoryboard>
        </Trigger.EnterActions>
      <Trigger.ExitActions>
        <BeginStoryboard>
          <Storyboard>
            <DoubleAnimation To="1" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="PART_fan" Duration="0:0:1"/>
            <DoubleAnimation To="0" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="PART_numeric" Duration="0:0:1"/>
          </Storyboard>
        </BeginStoryboard>
      </Trigger.ExitActions>
      <Setter Property="Fill" TargetName="PART_pointer" Value="{StaticResource PointerDay}" />
      <Setter Property="Foreground" TargetName="PART_numeric" Value="White"></Setter>
    </Trigger>
    <Trigger Property="Display" Value="Night">
      <Trigger.EnterActions>
        <BeginStoryboard>
          <Storyboard>
            <DoubleAnimation To="0" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="PART_fan" Duration="0:0:1"/>
            <DoubleAnimation To="1" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="PART_numeric" Duration="0:0:1"/>
          </Storyboard>
        </BeginStoryboard>
      </Trigger.EnterActions>
      <Trigger.ExitActions>
        <BeginStoryboard>
          <Storyboard>
            <DoubleAnimation To="1" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="PART_fan" Duration="0:0:1"/>
            <DoubleAnimation To="0" Storyboard.TargetProperty="Opacity" Storyboard.TargetName="PART_numeric" Duration="0:0:1"/>
          </Storyboard>
        </BeginStoryboard>
      </Trigger.ExitActions>
        <Setter Property="Fill" TargetName="PART_pointer" Value="{StaticResource PointerNight}" />
        <Setter Property="Foreground" TargetName="PART_numeric" Value="Yellow"></Setter>
       </Trigger>
      </ControlTemplate.Triggers>
     </ControlTemplate>
   </Setter.Value>
 </Setter>
</Style>

The Wind Meter Class

Here is the code of the corresponding wind meter class.

C#
 /// <summary>
/// WindMeter Custom Control
/// </summary>
[TemplatePart(Name = "PART_pointer", Type = typeof(Rectangle))]
[TemplatePart(Name = "PART_fan", Type = typeof(Rectangle))]
[TemplatePart(Name = "PART_pointer_border", Type = typeof(Border))]
[TemplatePart(Name = "PART_fan_storyboard", Type = typeof(Storyboard))]
[TemplatePart(Name = "PART_fan_animation", Type = typeof(DoubleAnimation))]
[TemplatePart(Name = "PART_pointer_storyboard", Type = typeof(Storyboard))]
[TemplatePart(Name = "PART_pointer_animation", Type = typeof(DoubleAnimation))]
[TemplatePart(Name = "PART_wiggle_storyboard", Type = typeof(Storyboard))]
[TemplatePart(Name = "PART_wiggle_animation", Type = typeof(DoubleAnimation))]
public class WindMeter : Control
{
  #region -- Declares --

  private Rectangle partFan, partPointer;
  private Storyboard fanStoryBoard, pointerStoryBoard, wiggleStoryBoard;
  private DoubleAnimation fanAnimation, pointerAnimation, wiggleAnimation;

  /// <summary>
  /// Display type, Fan, Day, Night
  /// </summary>
  public enum DisplayType
{
  Fan,
  Day,
  Night
}

  #endregion

  #region -- Properties --

  public static readonly DependencyProperty DirectionOffsetProperty =
    DependencyProperty.Register("DirectionOffset", typeof (int), typeof (WindMeter), new PropertyMetadata(default(int),
    (o, e) => ((WindMeter)o).ChangeDirection(((WindMeter)o).Direction, ((WindMeter)o).Direction)));

  public int DirectionOffset
  {
    get { return (int) GetValue(DirectionOffsetProperty); }
    set { SetValue(DirectionOffsetProperty, value); }
  } 

  public static readonly DependencyProperty WiggleDegreesProperty =
    DependencyProperty.Register("WiggleDegrees", typeof(int), typeof(WindMeter), new PropertyMetadata(10, 
    (o, e) => ((WindMeter)o).ChangeWiggle()));

  public int WiggleDegrees
  {
    get { return (int)GetValue(WiggleDegreesProperty); }
    set { SetValue(WiggleDegreesProperty, value); }
  }

  public static readonly DependencyProperty WiggleProperty =
    DependencyProperty.Register("Wiggle", typeof(bool), typeof(WindMeter), new PropertyMetadata(true, 
    (o, e) => ((WindMeter)o).ChangeWiggle()));

  public bool Wiggle
  {
    get { return (bool)GetValue(WiggleProperty); }
    set { SetValue(WiggleProperty, value); }
  }

  public static readonly DependencyProperty ShadowProperty =
    DependencyProperty.Register("Shadow", typeof(Color), typeof(WindMeter), new PropertyMetadata(Colors.Black));

  public Color Shadow
  {
    get { return (Color)GetValue(ShadowProperty); }
    set { SetValue(ShadowProperty, value); }
  }

  public static readonly DependencyProperty DisplayProperty =
    DependencyProperty.Register("Display", typeof(DisplayType), typeof(WindMeter), new PropertyMetadata(DisplayType.Fan,
    (o, e) => ((WindMeter)o).ChangeDisplay((DisplayType)e.NewValue)));

  public DisplayType Display
  {
    get { return (DisplayType)GetValue(DisplayProperty); }
    set { SetValue(DisplayProperty, value); }
  }

  public static readonly DependencyProperty WindProperty =
    DependencyProperty.Register("Wind", typeof(int), typeof(WindMeter), new PropertyMetadata(default(int),
    (o, e) => ((WindMeter)o).ChangeWind((int)e.OldValue, (int)e.NewValue)));

  public int Wind
  {
    get { return (int)GetValue(WindProperty); }
    set { SetValue(WindProperty, value); }
  }

  public static readonly DependencyProperty DirectionProperty =
    DependencyProperty.Register("Direction", typeof(int), typeof(WindMeter), new PropertyMetadata(0,
    (o, e) => ((WindMeter)o).ChangeDirection((int)e.OldValue, (int)e.NewValue)));

  public int Direction
  {
    get { return (int)GetValue(DirectionProperty); }
    set { SetValue(DirectionProperty, value); }
  }

  #endregion

  #region -- Constructor --

  /// <summary>
  /// Initializes a new instance of the <see cref="WindMeter"/> class.
  /// </summary>
  public WindMeter()
  {
    var res = (ResourceDictionary)Application.LoadComponent(new Uri("/NmtUiLib;component/Themes/WindMeterStyle.xaml", UriKind.Relative));
    Style = res["WindMeterStyle"] as Style;
  }

  #endregion

  #region -- Public Methods --

  public void ChangeDisplay(DisplayType type)
  {
    if (fanStoryBoard == null) return;
    if (type != DisplayType.Fan)
     fanStoryBoard.Stop();
    else
      fanStoryBoard.Begin();
  }

  public void ChangeWind(int oldValue, int newValue)
  {
    if (partFan == null) return;
      fanStoryBoard.Stop();
    if (newValue > 0)
    {
      fanAnimation.Duration = new Duration(TimeSpan.FromMilliseconds((int)(20000.0 / newValue)));
      fanStoryBoard.Begin();
    }
    ChangeWiggle();
  }

  public void ChangeWiggle()
  {
    if (wiggleAnimation == null) return;
      wiggleStoryBoard.Stop();
    if (!Wiggle || Wind <= 0) return;
    wiggleAnimation.From = WiggleDegrees /2; 
    wiggleAnimation.To = 0;
    wiggleAnimation.Duration = new Duration(TimeSpan.FromMilliseconds(10000.0 / Wind)); // Depending on wind speed
    wiggleStoryBoard.Begin();
  }

  public void ChangeDirection(int oldValue, int newValue)
  {
    if (partPointer == null) return;
    pointerStoryBoard.Stop();
    pointerAnimation.To = newValue + DirectionOffset;
    pointerAnimation.From = oldValue + DirectionOffset;
    pointerStoryBoard.Begin();
  }

  public override void OnApplyTemplate()
  {
    base.OnApplyTemplate();
    // Reference template parts
    partFan = GetTemplateChild("PART_fan") as Rectangle;
    partPointer = GetTemplateChild("PART_pointer") as Rectangle;
    pointerAnimation = GetTemplateChild("PART_pointer_animation") as DoubleAnimation;
    pointerStoryBoard = GetTemplateChild("PART_pointer_storyboard") as Storyboard;
    fanAnimation = GetTemplateChild("PART_fan_animation") as DoubleAnimation;
    fanStoryBoard = GetTemplateChild("PART_fan_storyboard") as Storyboard;
    wiggleAnimation = GetTemplateChild("PART_wiggle_animation") as DoubleAnimation;
    wiggleStoryBoard = GetTemplateChild("PART_wiggle_storyboard") as Storyboard;
    //
    // Startup & initialize
    ChangeDirection(0, Direction);
    ChangeWind(0, Wind);
    ChangeWiggle();
    ChangeDisplay(Display);
  }

  #endregion
}

Points of Interest

Animations are quite versatile and I especially like the easing functions, but binding to animations requires some extra work. For instance, do I use a property call back method to change settings on the animations simply because I have to stop the storyboard first before changed values are accepted. Example:

C#
 public static readonly DependencyProperty WindProperty =
  DependencyProperty.Register("Wind", typeof(int), typeof(WindMeter), new PropertyMetadata(default(int),
  (o, e) => ((WindMeter)o).ChangeWind((int)e.OldValue, (int)e.NewValue)));
...
public void ChangeWind(int oldValue, int newValue)
{
  if (partFan == null) return;
  fanStoryBoard.Stop();
  if (newValue > 0)
  {
    fanAnimation.Duration = new Duration(TimeSpan.FromMilliseconds((int)(20000.0 / newValue)));
    fanStoryBoard.Begin();
  }
  ChangeWiggle(); //Wiggle is depending on Wind speed.
}

However, this requires you to reference the template parts in the controls C# class OnApplyTemplate and even though it initially seems like bad practice it will soon feel OK.

History

This is the first release.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect
Denmark Denmark
Name: Niel Morgan Thomas
Born: 1970 in Denmark
Education:
Dataengineer from Odense Technical University.
More than 20 years in IT-business.
Current employment:
Cloud architect at University College Lillebaelt

Comments and Discussions

 
QuestionMy vpte of 5 Pin
Mike Hankey3-Jul-13 15:13
mveMike Hankey3-Jul-13 15:13 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA12-Apr-13 18:59
professionalȘtefan-Mihai MOGA12-Apr-13 18:59 
GeneralMy vote of 5 Pin
Prasad Khandekar10-Apr-13 21:52
professionalPrasad Khandekar10-Apr-13 21:52 
GeneralMy vote of 5 Pin
Uday P.Singh7-Apr-13 20:17
Uday P.Singh7-Apr-13 20:17 
GeneralMy vote of 5 Pin
Florian Rappl6-Apr-13 4:46
professionalFlorian Rappl6-Apr-13 4:46 
GeneralMy vote of 5 Pin
Wayne Gaylard30-Mar-13 5:49
professionalWayne Gaylard30-Mar-13 5:49 
QuestionWords cannot express how cool and great this is Pin
Søren Madsen Denmark,Assens19-Mar-13 0:47
Søren Madsen Denmark,Assens19-Mar-13 0:47 
GeneralMy vote of 5 Pin
SoMad18-Mar-13 23:38
professionalSoMad18-Mar-13 23:38 
GeneralMy vote of 5 Pin
Prasad Khandekar18-Mar-13 7:56
professionalPrasad Khandekar18-Mar-13 7:56 
GeneralMy vote of 5 Pin
Abinash Bishoyi18-Mar-13 5:00
Abinash Bishoyi18-Mar-13 5:00 
GeneralMy vote of 4 Pin
Kim Togo17-Mar-13 22:28
professionalKim Togo17-Mar-13 22:28 
Cool UI control! Smile | :)
GeneralMessage Closed Pin
17-Mar-13 21:50
thefiloe17-Mar-13 21:50 
GeneralRe: My vote of 1 PinPopular
Petr Kohout18-Mar-13 0:26
Petr Kohout18-Mar-13 0:26 
GeneralMy vote of 5 Pin
Member 42472517-Mar-13 21:36
Member 42472517-Mar-13 21:36 
GeneralMy vote of 5 Pin
Ravi Bhavnani17-Mar-13 18:54
professionalRavi Bhavnani17-Mar-13 18:54 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.