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:
There are several animation of the wind meter.
- 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:
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.
<!---->
<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>
<!---->
<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>
<!---->
<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>
<!---->
<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.
[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;
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 --
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)); 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();
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;
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(); }
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.