WPF 微调器自定义控件






4.97/5 (13投票s)
一个WPF自定义微调器控件。
引言
在我之前关于一个简单TimeSpan
UserControl
的文章中,我提到我会使用滑块来表示小时、分钟和秒,因为WPF 4.0中没有足够的微调器类型控件。为了解决这个问题并满足那种迫切的需求,我决定自己写一个。
然而,我再次从一个UserControl
开始,但后来想到我会将其重构回一个自定义控件库,本文就是重构的结果。
关于自定义控件与用户控件的简要说明
自定义控件和用户控件都相似,但用途略有不同
用户控件
- 派生类继承自
UserControl
。 - 由一个XAML文件和一个代码隐藏文件组成。
- 不能被样式化/模板化,即
UserControl
的消费者不能更改其样式。 - 使用多个现有控件来创建一个新控件。
自定义控件
- 派生类继承自
Control
。 - 由一个代码文件和Themes/Generic.xaml中的默认样式组成。
- 可以被样式化/模板化。
- 用于构建自定义控件库。
简而言之,如果我们想创建一个可重用且可由消费者主题化的控件,我们需要选择后者,即自定义Control
。
SpinnerControl
从头开始,我们将在Visual Studio (2010) 中创建一个新的自定义控件库,它为我们提供了具有静态构造函数的基本自定义控件代码
static SpinnerControl()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(SpinnerControl),
new FrameworkPropertyMetadata(typeof(SpinnerControl)));
}
以及用于我们通用控件主题的默认Themes\Generic.xaml文件。
我们的SpinnerControl
包含两个部分:控件的内容,即Value
和值的范围,以及控件的主题,因此我们将在下面分别描述每个部分。
要求
我们希望微调器控件做几件事
- 响应鼠标点击
- 当鼠标按钮按住时重复
- 响应键盘
- 显示格式化值
- 尊重上下限
- 以均匀的步长增加/减少
通过列出我们的需求,我们可以粗略了解我们需要编写哪些代码。
布局
很简单,使用Grid
为控件提供了最灵活的起点。所以我们将追求一种相当传统的设计
这是一个单行网格,有两列
- 第一列包含一个标准
TextBox
控件 - 第二列包含一个进一步的网格,有一个列和两行,包含两个按钮
我们稍后将回到此作为通用主题的实现。
SpinnerControl 实现
我们将使用典型的.NET控件属性名称作为自定义控件属性名称,这会产生一套一致且直接的名称
Value
:包含控件的值。Minimum
:设置控件可以达到的最小值。Maximum
:设置控件可以达到的最大值。Change
(例如SliderControl
上的SmallChange
):Value
增加或减少时的步长。
我们还公开了
DecimalPlaces
:我们希望显示的十进制位数FormattedValue
:格式化为所需十进制位数的Value
。
所有这些属性都是正常的Control
属性,通过创建带有回调到强制函数和OnChanged
函数的DependencyProperty
的常规“模式”公开,例如,对于Value
[Category("SpinnerControl")]
public decimal Value
{
get { return (decimal)GetValue(ValueProperty); }
set { SetValue(ValueProperty, value); }
}
private static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(decimal), typeof(SpinnerControl),
new FrameworkPropertyMetadata(DefaultValue,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue
));
值得指出的是,setter 是一个最小实现:尽管OnValueChanged
可以在Value
的 setter 中调用,但在纯XAML绑定期间这不会被调用,因为上面的 setter 仅在过程代码中调用:XAML直接调用SetValue(ValueProperty, value)
!我们稍后会回到这个属性。
另请注意,我们为每个属性使用Category
属性,以便我们的自定义控件属性在Visual Studio的属性窗格中分组显示
有一个只读属性:FormattedValue
是一个特例,因为我们显然不希望控件的消费者更改此值,我们只是想将其返回给他们,以便他们可以显示它等等。因此,我们只希望为这个属性生成一个getter。查阅MSDN文档,我们可以看到方法RegisterAttachedReadOnly
为我们提供了此功能
此方法返回类型
DependencyPropertyKey
,而RegisterAttached
返回类型DependencyProperty
。通常,表示只读属性的键不会公开,因为这些键可以通过调用SetValue(DependencyPropertyKey, Object)
来设置依赖属性值。您的类设计将影响您的需求,但通常建议将任何DependencyPropertyKey
的访问和可见性限制在类或应用程序逻辑中设置该依赖属性所必需的代码部分。还建议您通过将DependencyPropertyKey.DependencyProperty
的值作为公共静态只读字段公开在您的类上,来公开只读依赖属性的依赖属性标识符。[原文如此]
所以对于FormattedValue
,我们简单地有
/// <summary>
/// Dependency property identifier for the formatted value with limited
/// write access to the underlying read-only dependency property: we
/// can only use SetValue on this, not on the property itself.
/// </summary>
private static readonly DependencyPropertyKey FormattedValuePropertyKey =
DependencyProperty.RegisterAttachedReadOnly(
"FormattedValue", typeof(string), typeof(SpinnerControl),
new PropertyMetadata(DefaultValue.ToString()));
/// <summary>
/// The dependency property for the formatted value.
/// </summary>
private static readonly DependencyProperty FormattedValueProperty =
FormattedValuePropertyKey.DependencyProperty;
/// <summary>
/// Returns the formatted version of the value, with the specified
/// number of DecimalPlaces.
/// </summary>
public string FormattedValue
{
get
{
return (string)GetValue(FormattedValueProperty);
}
}
因此,此控件的消费者可以将属性FormattedValue
作为只读字符串访问,将Value
作为读写数值访问。
底层值
由于我们将重复多次将一个小的Change
添加到数字中,因此控件内部可能会发生舍入错误,所以我们选择使用decimal
来表示底层控件值。使用Round
和NumberFormatInfo
,我们可以准确地在控件中显示该值。
值得注意的是,与double
相比,decimal
表示效率较低,但这在一个GUI控件中,而不是计算代码中紧密绑定的循环中:因此任何效率低下几乎都不会被注意到。
由于这是我们向此控件的消费者公开的值,因此确保它默认双向绑定(例如Slider
上的Value
属性)也很有意义。我们通过使用带有适当枚举选项的FrameworkPropertyMetadata
注册ValueProperty
来实现这一点
private static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(decimal), typeof(SpinnerControl),
new FrameworkPropertyMetadata(DefaultValue,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnValueChanged,
CoerceValue
));
正如您所看到的,我们还添加了OnValueChanged
和CoerceValue
钩子。OnValueChanged
方法很重要,因为它提供了一个更新我们之前描述的FormattedValue
的机会。CoerceValue
总是在设置属性值之前调用:这让您有机会用您选择的值更新属性:在我们的例子中,我们希望确保值的范围在提供的最小值和最大值之间,并确保值正确四舍五入到我们指定的十进制位数。
private static object CoerceValue(DependencyObject obj, object value)
{
decimal newValue = (decimal)value;
SpinnerControl control = (SpinnerControl)obj;
// ensure that the value stays within the bounds of the minimum and
// maximum values that we define.
newValue = Math.Max(control.MinimumValue,
Math.Min(control.MaximumValue, newValue));
// then ensure the number of decimal places is correct.
newValue = Decimal.Round(newValue, control.DecimalPlaces);
return newValue;
}
Commands
为了让用户与控件交互,我们必须提供两个可以绑定的命令,它们称为IncreaseCommand
和DecreaseCommand
。这些只是以自定义控件的标准样板方式定义
public static RoutedCommand IncreaseCommand { get; set; }
public static void OnIncreaseCommand(Object sender, ExecutedRoutedEventArgs e)
{
SpinnerControl control = sender as SpinnerControl;
if (control != null)
{
control.OnIncrease();
}
}
protected void OnIncrease()
{
Value += Change;
}
public static RoutedCommand DecreaseCommand { get; set; }
public static void OnDecreaseCommand(Object sender, ExecutedRoutedEventArgs e)
{
SpinnerControl control = sender as SpinnerControl;
if (control != null)
{
control.OnDecrease();
}
}
protected void OnDecrease()
{
Value -= Change;
}
/// <summary>
/// Since we're using RoutedCommands for the increase/decrease commands, we need to
/// register them with the command manager so we can tie the events
/// to callbacks in the control.
/// </summary>
private static void InitializeCommands()
{
// create static instances
IncreaseCommand = new RoutedCommand("IncreaseCommand", typeof(SpinnerControl));
DecreaseCommand = new RoutedCommand("DecreaseCommand", typeof(SpinnerControl));
// register the command bindings - if the buttons get clicked, call these methods.
CommandManager.RegisterClassCommandBinding(typeof(SpinnerControl),
new CommandBinding(IncreaseCommand, OnIncreaseCommand));
CommandManager.RegisterClassCommandBinding(typeof(SpinnerControl),
new CommandBinding(DecreaseCommand, OnDecreaseCommand));
// lastly bind some inputs: i.e. if the user presses up/down arrow
// keys, call the appropriate commands.
CommandManager.RegisterClassInputBinding(typeof(SpinnerControl),
new InputBinding(IncreaseCommand, new KeyGesture(Key.Up)));
CommandManager.RegisterClassInputBinding(typeof(SpinnerControl),
new InputBinding(DecreaseCommand, new KeyGesture(Key.Down)));
}
我们更新静态构造函数
static SpinnerControl()
{
InitializeCommands();
DefaultStyleKeyProperty.OverrideMetadata(typeof(SpinnerControl),
new FrameworkPropertyMetadata(typeof(SpinnerControl)));
}
以便我们将命令注册到控件。虽然OnDecreaseCommand
和OnIncreaseCommand
方法是静态的,但它们的方法原型/声明使得它接收生成事件的对象(即SpinnerControl
)的引用,因此我们可以将对象强制转换回SpinnerControl
,并且该调用会增加/减少发起事件的控件。
事件
我们还应该向控件添加一个ValueChanged
事件。实现方式如下:
/// <summary>
/// The ValueChangedEvent, raised if the value changes.
/// </summary>
private static readonly RoutedEvent ValueChangedEvent =
EventManager.RegisterRoutedEvent("ValueChanged", RoutingStrategy.Bubble,
typeof(RoutedPropertyChangedEventHandler<decimal>), typeof(SpinnerControl));
/// <summary>
/// Occurs when the Value property changes.
/// </summary>
public event RoutedPropertyChangedEventHandler<decimal> ValueChanged
{
add { AddHandler(ValueChangedEvent, value); }
remove { RemoveHandler(ValueChangedEvent, value); }
}
然后用RoutedPropertyChangedEventArgs
作为参数调用RaiseEvent
。
SpinnerControl 布局或通用主题
如上所示,我们有
这是一个单行网格,有两列
- 第一列包含一个标准
TextBox
控件 - 第二列包含一个进一步的网格,有一个列和两行,包含两个按钮
首先,我喜欢重用(而不是“不在这里发明”),因此附件zip文件中的XAML被组织成几个文件,以便我可以在需要时在其他控件中重用用于绘制向上和向下箭头的画刷。这是通过连续使用MergedDictionaries
实现的
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Btl.Controls">
<ResourceDictionary.MergedDictionaries>
<!-- We have to use this 'component' relative notation to load the
other dictionaries at runtime -->
<ResourceDictionary Source="/Btl.Controls.MyControls;component/Themes/SpinnerControl.Generic.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
如果您以前从未见过,值得熟悉XAML的Source
表示法。
控件的布局是基于一些考虑。最初,我以为也许可以使用滚动条作为上下箭头,因为它们在视觉上接近我们的要求,我希望它不需要在XAML中进行太多编辑即可实现标准的“微调器”。只需快速浏览Google上WPF滚动条的结果,就表明它并非易事。
因此,显而易见的选择是,如果我们有两个命令,一个用于向上,一个用于向下,并且我们正在点击,那么我们只想使用两个按钮,以如上所示的方式排列。
在文章开头的需求列表中,我们看到我们希望一个按钮在按住鼠标按钮时重复向上/向下命令。如果您查看ButtonBase
类的继承层次结构,您会看到有一个RepeatButton
可以为我们完成这项工作。
XAML(为简洁起见,已省略,请参阅zip文件以获取完整版本)看起来像这样
<ResourceDictionary xmlns:my="clr-namespace:Btl.Controls">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Btl.Controls.MyControls;component/Resources/AllBrushes.xaml" />
</ResourceDictionary.MergedDictionaries>
<Style TargetType="{x:Type my:SpinnerControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type my:SpinnerControl}">
<Grid Margin="3">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
</Grid.RowDefinitions>
<TextBox Grid.Row="0" Grid.Column="0"
Text="{Binding RelativeSource={RelativeSource TemplatedParent},
Path=FormattedValue, Mode=OneWay}" IsReadOnly="True" />
<Grid Grid.Column="1" >
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<RepeatButton Grid.Row="0" Grid.Column="1"
Command="{x:Static my:SpinnerControl.IncreaseCommand}">
<RepeatButton.Content>
<Rectangle Fill="{StaticResource brush.scroll.up}" />
</RepeatButton.Content>
</RepeatButton>
<RepeatButton Grid.Row="1" Grid.Column="1"
Command="{x:Static my:SpinnerControl.DecreaseCommand}">
<RepeatButton.Content>
<Rectangle Fill="{StaticResource brush.scroll.down}" />
</RepeatButton.Content>
</RepeatButton>
</Grid>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
XAML执行以下任务
- 定义
SpinnerControl
的通用模板样式。 - 将
FormattedValue
绑定到TextBox
的Content
。请注意,绑定是OneWay
,即我们之前提到的getter。 - 将向上和向下命令绑定到向上和向下按钮。
- 使用我们的两个XAML画刷:
brush.scroll.up
和brush.scroll.down
,绘制两个矩形,它们是两个按钮的Content
。
XAML只使用SpinnerControl
的三个属性:FormattedValue
、IncreaseCommand
和DecreaseCommand
。
我认为这是一个简单的XAML示例,可能不需要进一步解释。
我也非常喜欢操作系统默认主题:因此,我希望这个控件的默认主题是微妙的,看起来像是属于标准桌面,而不是你网上随处可见的那些花哨的高对比度应用程序。
自定义主题
为了结束本文,您可以为控件应用自定义主题。话虽如此,我不喜欢花哨的颜色,我将使用一些明亮的颜色
我们如何实现这一点?在我们的MainWindow
中,我们需要将自定义主题应用到SpinnerControl
<btl:SpinnerControl Name="spinnerControl2"
Style="{StaticResource CustomSpinnerControlStyle}" />
我们显然必须创建CustomSpinnerControlStyle
(XAML再次省略,因为它相当枯燥)
<Window.Resources>
<Style x:Key="CustomSpinnerControlStyle" TargetType="{x:Type btl:SpinnerControl}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type btl:SpinnerControl}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
</Grid.RowDefinitions>
<TextBox Grid.Row="0"
Grid.Column="0"
Background="LightGreen"
BorderBrush="PaleGreen"
IsReadOnly="True"
Text="{Binding FormattedValue,
Mode=OneWay,
RelativeSource={RelativeSource TemplatedParent}}" />
<Grid Grid.Column="1"
<DockPanel>
<RepeatButton Background="LightGreen" BorderBrush="PaleGreen"
Content="+" BorderThickness="1" FontSize="12"
Command="{x:Static btl:SpinnerControl.IncreaseCommand}"
DockPanel.Dock="Right" />
<RepeatButton Background="LightGreen" BorderBrush="PaleGreen"
Content="-" BorderThickness="1" FontSize="12"
Command="{x:Static btl:SpinnerControl.DecreaseCommand}"
DockPanel.Dock="Left" />
</DockPanel>
</Grid>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
请注意我们如何改变上下按钮的布局,并改变它们的内容和颜色。然后将控件最终托管在一个主窗口中
结语
正如我一开始所说,这个控件最初是一个UserControl
,类似于我创建的用于选择TimeSpan
的控件,然后我将项目改造为自定义Control
。在撰写本文、重新阅读并检查所写内容时,我对控件进行了进一步的更改,并修订了其布局、命名约定等。
我要提到,这个控件受到了Kevin Moore/Pixel Lab的“百宝箱”中NumericUpDown
控件的影响,我通过研究它学到了很多。当然,作为微调器控件,其本质上是最简单的控件之一,并且通过采用标准.NET命名约定,任何相似之处都将是不可避免的,代码中的任何错误都属于我。
寻找控件的正确方法和类是一项在MSDN文档中上下遍历继承树的练习,我建议普通的WPF用户这样做,因为您将了解框架中其他与您正在处理的项目相关的功能。
如果您喜欢这篇文章,请记住点赞和/或在下面留言。谢谢!
参考文献
照例,参考列表
- Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries
- 高效 C#
- C# 简明手册
- Kevin Moore/Pixel Lab的百宝箱
历史
- 2012年1月16日:1.00 - 初版。
- 2012年1月17日:1.01 - 添加了
ValueChanged
事件,并重命名了属性。 - 2012年1月19日:1.02 - 修复了潜在的数据绑定过高/过低问题,并绑定了左右箭头键。