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

Wind Meter - Custom WPF Control

By , 18 Mar 2013
 

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)

About the Author

Niel M.Thomas
Software Developer (Senior)
Denmark Denmark
Member
Name: Niel Morgan Thomas
Born: 1970 in Denmark
Education:
Dataengineer from Odense Technical University.
More than 15 years in IT-business.
Current employment:
Working with application development in a major Danish company that produce medical equipment.

Sign Up to vote   Poor Excellent
Add a reason or comment to your vote: x
Votes of 3 or less require a comment

Comments and Discussions

 
Hint: For improved responsiveness ensure Javascript is enabled and choose 'Normal' from the Layout dropdown and hit 'Update'.
You must Sign In to use this message board.
Search this forum  
    Spacing  Noise  Layout  Per page   
GeneralMy vote of 5memberMihai MOGA12 Apr '13 - 18:59 
GeneralMy vote of 5memberPrasad Khandekar10 Apr '13 - 21:52 
GeneralMy vote of 5memberUday P.Singh7 Apr '13 - 20:17 
GeneralMy vote of 5mvpFlorian Rappl6 Apr '13 - 4:46 
GeneralMy vote of 5mentorWayne Gaylard30 Mar '13 - 5:49 
QuestionWords cannot express how cool and great this ismemberSøren Madsen Denmark,Assens19 Mar '13 - 0:47 
Fantastic job, and article Niel!
Best regards,
Søren Madsen
Denmark

GeneralMy vote of 5memberSoMad18 Mar '13 - 23:38 
GeneralMy vote of 5memberPrasad Khandekar18 Mar '13 - 7:56 
GeneralMy vote of 5memberAbinash Bishoyi18 Mar '13 - 5:00 
GeneralMy vote of 4memberKim Togo17 Mar '13 - 22:28 
GeneralMessage Automatically Removedmemberthefiloe17 Mar '13 - 21:50 
GeneralRe: My vote of 1memberPetr Kohout18 Mar '13 - 0:26 
GeneralMy vote of 5memberMember 42472517 Mar '13 - 21:36 
GeneralMy vote of 5memberRavi Bhavnani17 Mar '13 - 18:54 

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

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