Click here to Skip to main content
Click here to Skip to main content

Wind Meter - Custom WPF Control

, 18 Mar 2013
Rate this:
Please Sign up or sign in to vote.
This article describes how to make a custom WPF control that indicates wind speed and direction.

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:

<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.

 <!-- 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.

 /// <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:

 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)

Share

About the Author

Niel M.Thomas
Software Developer (Senior)
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:
Working with application development in a major Danish company that produce medical equipment.

Comments and Discussions

 
QuestionMy vpte of 5 PinprofessionalMike Hankey3-Jul-13 15:13 
GeneralMy vote of 5 PinmemberMihai MOGA12-Apr-13 18:59 
GeneralMy vote of 5 PinmemberPrasad Khandekar10-Apr-13 21:52 
GeneralMy vote of 5 PinmemberUday P.Singh7-Apr-13 20:17 
GeneralMy vote of 5 PinmvpFlorian Rappl6-Apr-13 4:46 
GeneralMy vote of 5 PinmentorWayne Gaylard30-Mar-13 5:49 
QuestionWords cannot express how cool and great this is PinmemberSøren Madsen Denmark,Assens19-Mar-13 0:47 
Fantastic job, and article Niel!
Best regards,
Søren Madsen
Denmark

GeneralMy vote of 5 PinmemberSoMad18-Mar-13 23:38 
GeneralMy vote of 5 PinmemberPrasad Khandekar18-Mar-13 7:56 
GeneralMy vote of 5 PinmemberAbinash Bishoyi18-Mar-13 5:00 
GeneralMy vote of 4 PinmemberKim Togo17-Mar-13 22:28 
GeneralMessage Automatically Removed Pinmemberthefiloe17-Mar-13 21:50 
GeneralRe: My vote of 1 PinmemberPetr Kohout18-Mar-13 0:26 
GeneralMy vote of 5 PinmemberMember 42472517-Mar-13 21:36 
GeneralMy vote of 5 PinmemberRavi Bhavnani17-Mar-13 18:54 

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

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

| Advertise | Privacy | Mobile
Web01 | 2.8.140827.1 | Last Updated 18 Mar 2013
Article Copyright 2013 by Niel M.Thomas
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid