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

Wind Meter - Custom WPF Control

0.00/5 (No votes)
18 Mar 2013 1  
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 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