风速计 - 自定义 WPF 控件






4.93/5 (37投票s)
本文档描述了如何创建一个自定义 WPF 控件,用于指示风速和风向。
引言
在一个深夜,我正在准备一门关于如何创建自定义 WPF 控件的课程时,同时也在观看天气预报,我发现他们使用了一种比较特殊的指示器来显示风速和风向,并认为这将是一个极好的课程示例。
背景
我的目标是演示一个包含随时间动态变化的动画的示例,例如绑定动画的属性。即使这看起来很简单,但在互联网上只有很少的正确示例来演示这种行为,因为大多数动画都是“固定”的,例如具有静态值的简单过渡。
想法和图形
这个控件的主要想法是提供一个易于阅读的视觉指示,显示风向和风速,并且该指示可以用于白天和夜晚。
控件的不同布局和行为如下所示
风速计有几个动画。
- 风扇将旋转以指示风速。
- 指针将在“风中”摆动,以指示精度。
- 指针将旋转以指示风向。
- 布局之间的切换将淡入淡出。
Using the Code
要使用风速计,只需像在演示应用程序中一样引用该控件,并将该控件添加到您的标记中,如下所示
<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>
风速计具有以下属性
方向
(int)(degrees) - 旋转是顺时针方向,默认方向为 0,指向西北。
DirectionOffset
(int)(degrees) - 偏移方向,默认值为 0。
Display
(enum) - 风扇、白天、夜晚,默认值为DisplayType.Fan
。
Shadow
(Color) - 阴影的颜色,可用于在夜间模式下使指针在深色背景上可见,默认值为黑色。
Wiggle
(bool) - 让风速计在风中摆动,默认值为true
。
WiggleDegrees
(int)(degrees) - 摆动角度,默认值为 10。
Wind
(int)(m/s) - 风速,单位为米/秒,默认值为 0。
模板
这是风速计的模板,设计元素已省略。
<!-- 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>
风速计类
这是相应的风速计类的代码。
/// <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
}
关注点
动画非常灵活,我特别喜欢缓动函数,但是绑定到动画需要一些额外的工作。例如,我是否应该使用属性回调方法来更改动画设置,仅仅因为我必须先停止故事板才能接受更改后的值。例如
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.
}
但是,这需要您在控件的 C# 类 OnApplyTemplate
中引用模板部件,即使最初看起来像是一种不好的做法,但很快您会觉得这很正常。
历史
这是第一个版本。