WPF IntegerUpDown 控件 - 适应和克服
不仅仅是编写代码,更是为了适应他人的代码以满足自己的需求
引言
2021.02.25 - 请务必阅读更新部分,了解此代码相关的破坏性更改。
在理想的世界里,当你下载一篇文章的源代码时,你会期望它在很大程度上能如宣传的那样工作,并且你可以几乎不用修改或只做少量修改就能在你的项目中直接使用。这是理想世界。但伙计,世界并非完美,你很可能需要调整一些东西才能让它在你的应用程序中可用。我说的不是小的格式更改,而是对代码的核心/主要功能的直接修改。这篇文章的主要目的就是让你体验这个过程。目标读者是那些刚接触编程,并且期望在 CodeProject 这样的网站上能得到手把手指导的人。现实是,根本就没有“手把手指导”。至少,大多数情况下是没有的。

我下载下面引用的文章是因为我正在使用 XCeed WPF Toolkit,以便使用其中包含的 IntegerUpDown 控件。那个工具包非常好,里面有很多不错的控件,但考虑到我只用了一个小控件,我不觉得它那 1.3MB 的编译程序集大小是值得的。所以我去寻找一个数字上下控制控件。虽然 NuGet 上有一些控件可用,但它们要么有我不想处理的依赖项,要么根本无法正常工作,所以我来到 CodeProject 看看有什么可用的,然后找到了 NumberBox 控件。
建立基线
在下载了这篇文章(发布于 2011 年)的代码后 - WPF 用户控件 - NumericBox[^] - 我立即将 NumberBox 代码复制到我的应用程序的“NumberBox”文件夹中,并将 MainWindow XAML 代码复制到我的 MainWindow.XAML 文件中。
<Window x:Class="WpfApp1_2020.MainWindow"
...
clr-namespace:NumericBox"
Title="MainWindow" Height="250" Width="400" >
<nb:NumericBox x:Name="numberBox" Background="Orange"
Value="10.50000" ValueFormat="0.000"
Increment="0.5" Minimum="-100" Maximum="100"
ValueChanged="NumberBox_ValueChanged" Width="75"/ >
当我编译并运行代码时,它似乎如宣传的那样工作(基线已建立!),但需要进行一些更改才能适应我的特定需求。我将 NumberBox 文件夹复制到一个名为 IntegerUpDown 的新文件夹中,并开始对其进行修改。
使其成为我的
我做的第一件事是更改用户控件的布局和外观,使按钮更好地适应与其相邻的 `TextBox` 的高度。特别是,我修复了在设计器中按钮覆盖文本框的问题,将 `TextBox` 的边框更改为黑色,并将按钮放入一个具有 `RowDefinitions` 的网格中,使按钮占据可用高度的 50%。我还将箭头多边形的 `Fill` 属性设置为绑定到按钮的前景色,并将按钮本身更改为 `RepeatButton`。
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<TextBox x:Name="PART_NumericTextBox" Grid.Column="0" BorderBrush="Black" Margin="0,0,0.2,0"
PreviewTextInput="numericBox_PreviewTextInput"
MouseWheel="numericBox_MouseWheel" />
</Grid>
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<RepeatButton x:Name="PART_IncreaseButton" Grid.Row="0" Margin="0,0,0,0.1"
BorderBrush="Black" BorderThickness="0.75" Width="13"
Foreground="Black" Background="#cecece"
Style="{DynamicResource UpDownButtonStyle}"
Click="increaseBtn_Click" >
<RepeatButton.Content>
<Polygon StrokeThickness="0.5" Stroke="Transparent"
Points="0,0 -2,5 2,5" Stretch="Fill"
Fill="{Binding RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType=RepeatButton}, Path=Foreground}" />
</RepeatButton.Content>
</RepeatButton>
<RepeatButton x:Name="PART_DecreaseButton" Grid.Row="1" Margin="0,0.1,0,0" Width="13"
BorderBrush="Black" BorderThickness="0.75"
Foreground="Black" Background="#cecece"
Style="{DynamicResource UpDownButtonStyle}"
Click="decreaseBtn_Click" >
<RepeatButton.Content>
<Polygon StrokeThickness="0.5" Stroke="Transparent"
Points="-2,0 2,0 0,5 " Stretch="Fill"
Fill="{Binding RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType=RepeatButton}, Path=Foreground}" />
</RepeatButton.Content>
</RepeatButton>
</Grid>
</Grid>
为了支持 XAML 中新定义的 `RepeatButton`,我接下来所做的就是从代码中移除计时器和按钮左键按下预览事件,因为重复按钮已经处理了这些。我还移除了所有支持的 Popup 和 Menu 代码,因为我并不特别需要这些东西,而且它们对我来说没有任何用处。(由于代码已被移除,这里没有可展示的内容。因为它已被移除。)
我的下一个任务是将它从 `double` 类型转换为 `int` 类型。这涉及到更改 `Minimum`、`Maximum`、`Increment`、`Value` 和 `ValueChangedEvent` 属性。
//===========================================================
public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register("Minimum", typeof(int), typeof(IntegerUpDown), new PropertyMetadata(int.MinValue, OnMinimumChanged));
private static void OnMinimumChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
IntegerUpDown numericBoxControl = new IntegerUpDown();
numericBoxControl.minimum = (int)args.NewValue;
}
public int Minimum
{
get { return (int)this.GetValue(MinimumProperty); }
set { this.SetValue(MinimumProperty, value); }
}
//===========================================================
public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register("Maximum", typeof(int), typeof(IntegerUpDown), new PropertyMetadata(int.MaxValue, OnMaximumChanged));
private static void OnMaximumChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
IntegerUpDown numericBoxControl = new IntegerUpDown();
numericBoxControl.maximum = (int)args.NewValue;
}
public int Maximum
{
get { return (int)this.GetValue(MaximumProperty); }
set { this.SetValue(MaximumProperty, value); }
}
//===========================================================
public static readonly DependencyProperty IncrementProperty =
DependencyProperty.Register("Increment", typeof(int), typeof(IntegerUpDown), new PropertyMetadata(1, OnIncrementChanged));
private static void OnIncrementChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
IntegerUpDown numericBoxControl = new IntegerUpDown();
numericBoxControl.increment = (int)args.NewValue;
}
public int Increment
{
get { return (int)this.GetValue(IncrementProperty); }
set { this.SetValue(IncrementProperty, value); }
}
//===========================================================
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(int), typeof(IntegerUpDown), new PropertyMetadata(new Int32(), OnValueChanged));
private static void OnValueChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
IntegerUpDown numericBoxControl = (IntegerUpDown)sender;
numericBoxControl.value = (int)args.NewValue;
numericBoxControl.PART_NumericTextBox.Text = numericBoxControl.value.ToString(numericBoxControl.ValueFormat);
numericBoxControl.OnValueChanged((int)args.OldValue, (int)args.NewValue);
}
public int Value
{
get { return (int)this.GetValue(ValueProperty); }
set { this.SetValue(ValueProperty, value); }
}
//===========================================================
public static readonly DependencyProperty ValueFormatProperty =
DependencyProperty.Register("ValueFormat", typeof(string), typeof(IntegerUpDown), new PropertyMetadata("0", OnValueFormatChanged));
private static void OnValueFormatChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
IntegerUpDown numericBoxControl = new IntegerUpDown();
numericBoxControl.valueFormat = (string)args.NewValue;
}
public string ValueFormat
{
get { return (string)this.GetValue(ValueFormatProperty); }
set { this.SetValue(ValueFormatProperty, value); }
}
//===========================================================
public static readonly RoutedEvent ValueChangedEvent =
EventManager.RegisterRoutedEvent("ValueChanged", RoutingStrategy.Direct, typeof(RoutedPropertyChangedEventHandler<int>), typeof(IntegerUpDown));
public event RoutedPropertyChangedEventHandler<int> ValueChanged
{
add { this.AddHandler (ValueChangedEvent, value); }
remove { this.RemoveHandler(ValueChangedEvent, value); }
}
private void OnValueChanged(int oldValue, int newValue)
{
RoutedPropertyChangedEventArgs<int> args = new RoutedPropertyChangedEventArgs<int>(oldValue, newValue);
args.RoutedEvent = IntegerUpDown.ValueChangedEvent;
this.RaiseEvent(args);
}
</int></int></int></int>
在“原样”使用该控件时,我注意到手动输入文本的功能并不太好。如果你将光标放在文本末尾,然后开始输入数字字符,新输入的字符最终会添加到值的“开头”。这需要修复现有的 `numericBox_TextInput` 事件处理程序。我还将事件处理程序重命名为 `numericBox_PreviewTextInput`,因为它实际上处理的是这个事件。
旧代码
private void numericBox_TextInput(object sender, TextCompositionEventArgs e)
{
try
{
double tempValue = Double.Parse(PART_NumericTextBox.Text);
if (!(tempValue < Minimum || tempValue > Maximum)) Value = tempValue;
}
catch (FormatException)
{
}
}
新代码
正如你所见,在保证值不超出指定的最小/最大范围的同时,也充分考虑了插入符号位置的验证。
private void numericBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
TextBox textbox = sender as TextBox;
int caretIndex = textbox.CaretIndex;
try
{
int newvalue;
// see if the text will parse to an integer
bool error = !int.TryParse(e.Text, out newvalue);
string text = textbox.Text;
if (!error)
{
// we have a valid integer, so insert the new text at the
//caret's position
text = text.Insert(textbox.CaretIndex, e.Text);
// check the string again to make sure it still parses
error = !int.TryParse(text, out newvalue);
if (!error)
{
// we're good, so make sure the value is in the
// specified min/max range
error = (newvalue < this.Minimum || newvalue > this.Maximum);
}
}
if (error)
{
// play the error sound
SystemSounds.Hand.Play();
// reset the caret index to where it was when we entered
// this method
textbox.CaretIndex = caretIndex;
}
else
{
// set the textbox text (this will set the caret index to 0)
this.PART_NumericTextBox.Text = text;
// put the caret at the END of the inserted text
textbox.CaretIndex = caretIndex+e.Text.Length;
// set the Value to the new value
this.Value = newvalue;
}
}
catch (FormatException)
{
}
e.Handled = true;
}
接下来,我想改进由按钮点击事件调用的增加/减少方法。
旧代码
//=============================================================
private void IncreaseValue()
{
Value += Increment;
if (Value < Minimum || Value > Maximum) Value -= Increment;
}
//=============================================================
private void DecreaseValue()
{
Value -= Increment;
if (Value < Minimum || Value > Maximum) Value += Increment;
}
新代码
//=============================================================
private void IncreaseValue()
{
Value = Math.Min(this.Maximum, this.Value + this.Increment);
}
//=============================================================
private void DecreaseValue()
{
Value = Math.Max(this.Minimum, this.Value - this.Increment);
}
最后,`ApplyTemplate` 方法需要一些工作来与所有先前的更改保持一致。
旧代码
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
Button btn = GetTemplateChild("PART_IncreaseButton") as Button;
if (btn != null)
{
btn.Click += increaseBtn_Click;
btn.PreviewMouseLeftButtonDown += increaseBtn_PreviewMouseLeftButtonDown;
btn.PreviewMouseLeftButtonUp += increaseBtn_PreviewMouseLeftButtonUp;
}
btn = GetTemplateChild("PART_DecreaseButton") as Button;
if (btn != null)
{
btn.Click += decreaseBtn_Click;
btn.PreviewMouseLeftButtonDown += decreaseBtn_PreviewMouseLeftButtonDown;
btn.PreviewMouseLeftButtonUp += decreaseBtn_PreviewMouseLeftButtonUp;
}
TextBox tb = GetTemplateChild("PART_NumericTextBox") as TextBox;
if (tb != null)
{
PART_NumericTextBox = tb;
PART_NumericTextBox.Text = Value.ToString(ValueFormat);
PART_NumericTextBox.PreviewTextInput += numericBox_TextInput;
PART_NumericTextBox.MouseWheel += numericBox_MouseWheel;
}
System.Windows.Controls.Primitives.Popup popup = GetTemplateChild("PART_Popup") as System.Windows.Controls.Primitives.Popup;
if (popup != null)
{
PART_Popup = popup;
PART_Popup.MouseLeftButtonDown += optionsPopup_MouseLeftButtonDown;
}
tb = GetTemplateChild("PART_IncrementTextBox") as TextBox;
if (tb != null)
{
PART_IncrementTextBox = tb;
PART_IncrementTextBox.KeyDown += incrementTB_KeyDown;
}
MenuItem mi = GetTemplateChild("PART_MenuItem") as MenuItem;
if (mi != null)
{
PART_MenuItem = mi;
PART_MenuItem.Click += MenuItem_Click;
}
btn = null;
mi = null;
tb = null;
popup = null;
}
新代码
你可以看到,移除菜单和弹出组件确实减少了大量代码(并且请注意,我不得不将 `Button` 改为 `RepeatButton`)。public override void OnApplyTemplate()
{
base.OnApplyTemplate();
RepeatButton btn = GetTemplateChild("PART_IncreaseButton") as RepeatButton;
if (btn != null)
{
btn.Click += increaseBtn_Click;
}
btn = GetTemplateChild("PART_DecreaseButton") as RepeatButton;
if (btn != null)
{
btn.Click += decreaseBtn_Click;
}
TextBox tb = GetTemplateChild("PART_NumericTextBox") as TextBox;
if (tb != null)
{
PART_NumericTextBox = tb;
PART_NumericTextBox.Text = Value.ToString(ValueFormat);
PART_NumericTextBox.PreviewTextInput += numericBox_PreviewTextInput;
PART_NumericTextBox.MouseWheel += numericBox_MouseWheel;
}
btn = null;
tb = null;
}
示例应用程序
为了进行比较,我将原始控件和我的控件版本都放在了应用程序的主窗口中。
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"
VerticalAlignment="Center" Grid.Row="1">
<TextBlock Text="Original version: " VerticalAlignment="Center" />
<nb:NumericBox x:Name="numberBox" Background="Orange"
Value="10.50000" ValueFormat="0.000"
Increment="0.5" Minimum="-100" Maximum="100"
ValueChanged="NumberBox_ValueChanged" Width="75"/>
<Border Background="Black" BorderBrush="Red" BorderThickness="1"
Width="90" Margin="15,0,0,0" Padding="5,0" >
<TextBlock Text="{Binding Path=NudValue,Mode=OneWay}"
VerticalAlignment="Center" Foreground="Yellow" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"
VerticalAlignment="Center" Grid.Row="2" Margin="0,20,0,0">
<TextBlock Text="Modified version: " VerticalAlignment="Center" />
<nud2:IntegerUpDown x:Name="numberBox2" Increment="10" Minimum="1"
Maximum="100000"
ValueChanged="NumberBox2_ValueChanged" Width="75"/>
<Border Background="Black" BorderBrush="Red" BorderThickness="1"
Width="90" Margin="15,0,0,0" Padding="5,0" >
<TextBlock Text="{Binding Path=NudValue2,Mode=OneWay}"
VerticalAlignment="Center" Foreground="Yellow" />
</Border>
</StackPanel>
更新 - 2021.02.25
报告了手动文本输入(和其他小问题)的几个问题,因此我完全拆解了这部分代码并重新构建。主要问题是预览输入事件与值更改事件在“拉锯战”。这导致了 InputPreview 事件处理程序的完全重构,并添加了 TextChanged 事件处理程序。我还将 ValueChanged 事件与文本框解耦了。
最后,在进行上述更改的过程中,我不得不为控件添加了一些属性。
- AllowManualEdit - 一个标志,指示用户是否应该能够手动编辑该值。建议 - 如果将此属性设置为
false
,则将 Increment 设置为 "1"。默认值为true
。
- SilentError - 一个标志,指示输入错误是否会播放错误声音。默认值为
false
。
行为修改
手动向文本框输入文本时,有一些行为你应该注意
- 通常,输入任何非数字或非连字符的字符都属于错误。
- 如果你输入的连字符不是文本框的第一个字符,则属于错误。
- 如果你的编辑操作导致文本框完全变为空,则 XAML 中指定的 Value 将显示在文本框中。
- 如果文本框只包含一个连字符,则 Value 将被无声地设置为 XAML 中指定的初始 Value,但在文本框失去焦点之前不会在文本框中显示。
- 如果你在文本框中除第一个字符位置外的任何地方输入连字符,则属于错误。
说实话?手动输入文本处理起来很麻烦(在**任何**上下文中),而且对开发者来说充满危险。我不得不说,我不知道我是否消除了所有与手动文本输入相关的 bug,并且直到代码经过其他几个人测试之前,你应该预期会遇到一些小问题。如果你确实遇到了任何问题,不要只在下面的评论中报告,而是尝试修复它(你是一名程序员,对吧?),然后报告问题是什么,以及你是如何修复的。我会采取措施评估问题和修复方案,并更新文章和 ZIP 下载文件。
注意:手动文本输入的问题是添加新 `AllowManualEdit` 属性的驱动力。
代码中的更改已在 IntegerUpDown.cs 文件中完整注释。
样式更改
在原始文章中,我曾提到该控件中使用的样式会依赖于 `PresentationFramework.Aero`。这让我非常恼火,所以我采取措施消除了这种依赖——我痛恨无谓的依赖。我还“修复”了按钮的样式,使其在鼠标悬停时背景和前景色发生变化。如果你不喜欢我选择的颜色,可以通过更改 XAML 文件中控件 `Resources` 部分的颜色定义,轻松地将其更改为符合你的要求。
关注点
需要注意的是,绑定到目标属性的方式需要不同。所需的方法并没有太困扰我,所以我也没去处理。当你将控件放在表单上时,你需要处理 `ValueChanged` 事件,并使用该事件处理程序来设置你的目标属性(而不是直接绑定到它)。我确信有简单的解决方法,但我时间不够了,不得不按原样实现控件。你可以查看示例应用程序来了解我的意思。
新代码要求你添加对 `PresentationFramework.Aero` 的引用,但这可以通过重构控件中的 Resources 部分来消除。
我没有将控件放入 DLL,因为我认为你可能已经有了自己的 WPF 相关 DLL,其中包含你通常经常使用的其他内容。但我确实为它分配了自己的命名空间,并将其放在一个文件夹中,以便于复制到你喜欢的程序集中。
结束语
对于所有潜在的文章作者,我的建议是尽可能编写能够用于企业级项目的代码。像我一样,那些正在寻找快速/简单解决方案的人几乎没有时间去摆弄你的代码,特别是如果他们不得不修复你本可以/应该发现并减轻的 bug。我理解 bug 是必要之恶,但我在原始代码中遇到的手动文本输入问题,老实说本不应该发生。大多数下载你代码的人,如果遇到问题,会直接删除它,或者更糟糕的是,他们会缠着你要求修复或提问,而永远不会考虑自己修复问题,更不用说在你遇到/修复问题时通知你了。我并不是说每个人都会这样做,但令人担忧的是,大多数人都会这样做,这只会给所有人带来糟糕的体验。
总之,写出更好的代码,并在文章中清楚地说明使用标准和代码的意图。至少这样,你的读者就能确切地知道代码会做什么。没人喜欢惊喜。
对于可能正在观看的文章消费者来说,大多数情况下,你得自己摸索代码并自行修复/更改。如果你遇到问题,你的最佳策略是在“问题与解答”或合适的论坛中发帖提问。原因是 CodeProject 目前不会在有人在文章评论区发布新问题时通知文章作者。根据文章的年龄(本文引用的文章已有 10 年历史),作者可能不会主动监控评论区,甚至可能永远看不到你的问题。
历史
- 2021.02.25 - 认真尝试修复手动文本输入问题,移除了 PresentationFramework.Aero 的依赖,并将按钮样式更改为鼠标悬停时高亮显示。还添加了两个新属性(详情请参阅更新部分)。
- 2021.02.22 - 首次发布。