65.9K
CodeProject 正在变化。 阅读更多。
Home

从头开始创建一个数字增减控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (37投票s)

2012年12月29日

CPOL

28分钟阅读

viewsIcon

81768

downloadIcon

3745

展示了在 WPF 中创建功能齐全的 NumericUpDown 控件的整个过程。

引言

在 CodeProject 潜伏了仿佛永恒的时间之后,我决定是时候开始为这个伟大的网站做出贡献了。

这是我写的第一篇文章,但我可以诚实地说,我已经尽了最大的努力写好它。如果我有什么做得不好或解释不清楚的地方,请告诉我,以便我改进这篇文章,并可能提高我的技能。  

关于本文性质的说明

我真的不知道这算是一个教程还是一个分步指南。我想两者兼而有之吧。我猜我把它写成了一个分步指南,面向中级程序员,并穿插了面向初学者的教程解释。代码都在这里,不过,可下载的源代码中没有任何未解释的惊喜。

我希望您能完整地阅读这篇文章,它从来都不是那种“来选择你喜欢的功能”的指南,这些功能往往相互依赖。此外,它们的实现顺序并非按重要性或有用性排列——一些明显缺失的功能,例如 TextBox 中的默认文本,在最后才实现,因为它们不是很有趣或很重要,只是完成控件所必需的(它们可以等待)。 

您至少需要 .NET Framework 3.5,但建议使用 .NET Framework 4.0 或更高版本(即 4.5)。

您将学到什么  

您将学习如何创建名为 NumericUpDown 的自定义控件,并附带 Generic.xaml 中的默认外观和使用该控件所有方面的 Demo 应用程序(如下所示)。  

图 1 - 演示应用程序的屏幕截图,显示了我们将要创建的 NumericUpDown。所有按钮都与控件的各个方面相关。

除了上面明显的功能之外,这个 NumericUpDown 还支持

  • 通过按 Esc 取消键入的未确认更改
  • 通过按 Enter 确认键入的更改  
  • 使用键盘箭头或 PageUp/PageDown 增加和减少值
  • 当侧面的两个按钮被右键单击时,将值归零  

创建基本控件  

我们将需要一个名为 CustomControls 的解决方案,其中包含两个项目

  • CustomControl(一个 WPF 自定义控件库
  • Demo(一个 WPF 应用程序

我假设您下载了源代码并使用了附带的 Demo 源代码。将其作为 Demo 应用程序的基础,并在实现 individual 方法后,简单地取消 MainWindow.xaml.cs 中的注释。

打开 CustomControl1.cs 并将其重命名为 NumericUpDown.cs。您的代码应如下所示

namespace CustomControls
{
    public class NumericUpDown : Control
    {
        static NumericUpDown()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof (NumericUpDown),
                                                     new FrameworkPropertyMetadata(
                                                         typeof (NumericUpDown)));
        }
    }
} 

OverrideMetadata 是负责在 Generic.xaml 中应用默认主题的函数,所以我们确实想保留它。

说到 Generic.xaml,现在我们来修改它。这个模板实际上只是一个 TextBox 和两个 RepeatButtons,带有一些自定义图形。最重要的部分是名称。

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:CustomControls">
    <Style TargetType="{x:Type local:NumericUpDown}">
        <Setter Property="HorizontalAlignment" Value="Center" />
        <Setter Property="HorizontalContentAlignment" Value="Right" />
        <Setter Property="VerticalAlignment" Value="Center" />
        <Setter Property="BorderBrush" Value="Gray" />
        <Setter Property="BorderThickness" Value="1" />
        <Setter Property="Width" Value="100" />
        <Setter Property="Height" Value="26" />
        <Setter Property="Focusable" Value="False" />
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:NumericUpDown}">
                    <Border Background="{TemplateBinding Background}"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}"
                            Focusable="False">
                        <Grid Width="{TemplateBinding Width}"
                              Height="{TemplateBinding Height}"
                              VerticalAlignment="Center"
                              Focusable="False">
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="*" />
                                <ColumnDefinition Width="Auto" />
                            </Grid.ColumnDefinitions>

                            <TextBox x:Name="PART_TextBox"
                                     VerticalAlignment="Center"
                                     HorizontalContentAlignment="Right" />

                            <Grid Grid.Column="1">
                                <Grid.RowDefinitions>
                                    <RowDefinition Height="*" />
                                    <RowDefinition Height="*" />
                                </Grid.RowDefinitions>
                                <RepeatButton x:Name="PART_IncreaseButton"
                                              Grid.Row="0"
                                              Width="20"
                                              Margin="0, 1, 2, 0">
                                    <Path Margin="1"
                                          Data="M 0 20 L 35 -20 L 70 20 Z"
                                          Fill="#FF202020"
                                          Stretch="Uniform" />
                                </RepeatButton>
                                <RepeatButton x:Name="PART_DecreaseButton"
                                              Grid.Row="1"
                                              Width="20"
                                              Margin="0, 0, 2, 1">
                                    <Path Margin="1"
                                          Data="M 0 0 L 35 40 L 70 0 Z"
                                          Fill="#FF202020"
                                          Stretch="Uniform" />
                                </RepeatButton>
                            </Grid>
                        </Grid>
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

正如您所看到的,TextBox 和两个 RepeatButtons 的名称都以“PART_”开头。我稍后会解释这些奇怪名称的用途。目前,重要的是上面的模板绘制的 NumericUpDown 与第一个屏幕截图中显示的几乎相同。我认为除了 RepeatButtons 中的 Path.Data 变量之外,其他任何东西都不应该令人惊讶。如果您以前从未使用 Path 绘制过自定义图形,这里是必要的信息。

以下是 NumericUpDown 组成的层:

图 2 - 显示了构成 NumericUpDown 控件的不同层。

将它们联系起来

我们有一个骨架 NumericUpDown 类,它没有任何有趣的功能,并且在 Generic.xaml 中有一个模板,由前面提到的 OverrideMetadata 方法应用。一切似乎都很顺利,对吗?嗯,唯一的问题是,我们无法访问 TextBox 和两个 RepeatButtons!它们没有像人们预期的那样作为变量添加到代码中,那么我们应该如何获取它们的引用呢?嗯,这里就需要 TemplatePartAttribute 了。正如链接所说,TemplatePartAttributes 基本上是宣传我们期望在模板中拥有的元素。我们需要三个部分,所以我们也需要三个属性

[TemplatePart(Name = "PART_TextBox", Type = typeof(TextBox))]
[TemplatePart(Name = "PART_IncreaseButton", Type = typeof(RepeatButton))]
[TemplatePart(Name = "PART_DecreaseButton", Type = typeof(RepeatButton))] 

您使用的名称不重要(尽管它们确实必须与 XAML 中的名称匹配,显然),但最好以 PART_ 开头模板部件名称以表明它们的用途。 现在,仅仅宣传这个控件有三个模板部件是不够的,我们需要获取一个可以使用的引用——这有点复杂。

我们不能仅仅在构造函数中“附加”(获取对)这些模板部分,因为如果有人应用了不同的模板,这些引用将失效。相反,我们需要在模板应用的那一刻进行操作。OnApplyTemplate() 非常适合此目的。重写该方法并如下修改它

public override void OnApplyTemplate()
{
    base.OnApplyTemplate();
    AttachToVisualTree();
}

AttachToVisualTree() 只是一个方便的方法,用于调用执行实际附加的三个实际方法。它们是

private void AttachToVisualTree()
{
   AttachTextBox();
   AttachIncreaseButton();
   AttachDecreaseButton();
}

不言自明,不是吗?让我们逐一检查这些函数,并将这三个模板部分与代码隐藏关联起来。

附加模板部件

获取 TextBox 的引用就像调用 GetTemplateChild() 并传入模板部件的名称(在本例中为“PART_TextBox”)一样简单。现在就来做。

protected TextBox TextBox;

private void AttachTextBox()
{
    var textBox = GetTemplateChild("PART_TextBox") as TextBox;

    // A null check is advised
    if (textBox != null)
    {
        TextBox = textBox;
    }
}

此时,附加 RepeatButtons 是轻而易举的事。

protected RepeatButton IncreaseButton;

private void AttachIncreaseButton()
{
    var increaseButton = GetTemplateChild("PART_IncreaseButton") as RepeatButton;
    if (increaseButton != null)
    {
        IncreaseButton = increaseButton;
        IncreaseButton.Focusable = false;
    }
} 
protected RepeatButton DecreaseButton;

private void AttachDecreaseButton()
{
    var decreaseButton = GetTemplateChild("PART_DecreaseButton") as RepeatButton;
    if (decreaseButton != null)
    {
        DecreaseButton = decreaseButton;
        DecreaseButton.Focusable = false;
    }
}

别忘了存储所有三个引用,我们稍后会用到它们。另请注意,我们如何将 Focusable 属性设置为 false,它们不能接收任何焦点。

创建基本功能

为了显示和修改值,我们需要

  • 创建一个依赖属性
  • Value - 保存实际的 Decimal 数字,执行所有强制转换和通知
  • 创建命令
  • _minorIncreaseValueCommand - 表示增加值的命令
  • _minorDecreaseValueCommand - 表示减少值的命令
  • 将命令绑定到使用它们的模板部件

要在一个屏幕上显示一个数字,需要做很多事情,那我们开始吧。

创建依赖属性

NumericUpDown 控件就是关于改变 Value,对吗?可惜,不是。尽管强制转换(纠正)逻辑将在 Value 内部,但我们必须使 TextBoxValue 内部数字的当前表示保持同步。让我们从创建 Value 开始

#region Value

public static readonly DependencyProperty ValueProperty =
    DependencyProperty.Register("Value", typeof (Decimal), typeof (NumericUpDown),
                                new PropertyMetadata(0m, OnValueChanged, CoerceValue));

public Decimal Value
{
    get { return (Decimal) GetValue(ValueProperty); }
    set { SetValue(ValueProperty, value); }
}

private static void OnValueChanged(DependencyObject element,
                                    DependencyPropertyChangedEventArgs e)
{
}

private static object CoerceValue(DependencyObject element, object baseValue)
{
    var value = (Decimal) baseValue;

    return value;
}

#endregion

这看起来相当复杂,但实际上并不那么糟糕——大部分只是样板代码。ValueProperty 基本上是 Value 属性实例的所有元数据的字典(我们可以通过 get/set 访问器访问),正如 Robert Rossney 编写的精彩描述中解释的那样——请务必阅读。正如我确信您已经注意到的那样,上面的属性指定了三个参数作为 PropertyMetadata

第一个参数是 DependencyProperty 创建时的默认值,在本例中为 0m(0 decimal)。第二个是 PropertyChangedCallback 委托,仅当属性真正更改时才会被调用(当设置相同的值时不会触发)。最后一个是 CoerceValueCallback 委托,当 Value 设置时会被调用。它允许您将输入强制(纠正)为可用值,然后将其分配给属性。

创建命令

此时您可能会问,为什么不直接对所有事情都使用事件处理程序呢?嗯,事件处理程序实际上会更复杂和冗长。稍后您就会明白。我们需要两个命令——一个用于增加 Value,一个用于减少 Value

using System.Windows.Input;

private readonly RoutedUICommand _minorIncreaseValueCommand =
    new RoutedUICommand("MinorIncreaseValue", "MinorIncreaseValue", typeof(NumericUpDown));

private readonly RoutedUICommand _minorDecreaseValueCommand =
    new RoutedUICommand("MinorDecreaseValue", "MinorDecreaseValue", typeof(NumericUpDown)); 

请参阅此处快速参考构造函数的参数。我们还需要指定什么“触发”这些命令。两个 RepeatButtons 中的每一个都有一个 Command 属性——非常适合我们的需求。让我们立即分配命令。

private void AttachIncreaseButton()
{
    var increaseButton = GetTemplateChild("PART_IncreaseButton") as RepeatButton;
    if (increaseButton != null)
    {
        IncreaseButton = increaseButton;
        IncreaseButton.Focusable = false;
        IncreaseButton.Command = _minorIncreaseValueCommand;
    }
}

private void AttachDecreaseButton()
{
    var decreaseButton = GetTemplateChild("PART_DecreaseButton") as RepeatButton;
    if (decreaseButton != null)
    {
        DecreaseButton = decreaseButton;
        DecreaseButton.Focusable = false;
        DecreaseButton.Command = _minorDecreaseValueCommand;
    }
}

命令已创建,并在需要时触发,但它们仍然什么也不做。我们需要告诉命令它们的回调是什么——它们在触发时调用的函数是什么。为此,我们创建一个名为 AttachCommands() 的新函数。在这个函数中,我们将最终附加随着我们添加更多功能而需要的所有各种命令。

private void AttachCommands()
{
    CommandBindings.Add(new CommandBinding(_minorIncreaseValueCommand, (a, b) => IncreaseValue()));
    CommandBindings.Add(new CommandBinding(_minorDecreaseValueCommand, (a, b) => DecreaseValue()));
} 

那么我们刚才做了什么?首先,我们创建了一个 CommandBinding,它将每个命令绑定到其各自的函数,即 IncreaseValue()DecreaseValue(),然后将这些命令绑定添加到我们的 NumericUpDown 控件的 CommandBindings 集合中。最后剩下的事情是创建我们调用的两个函数——IncreaseValue()DecreaseValue()

private void IncreaseValue()
{
    Value++;
}

private void DecreaseValue()
{
    Value--;
}

请理解这些函数远未完成。我们将随着程序的进展添加更多内容,但为了获得最基本的功能(增加和减少 Value),我们至少需要这么多。

现在,我们仍然需要做一件事才能在 TextBox 中显示内容。我们可能已经将命令绑定到增加和减少 Value,但没有任何东西更新 TextBox,所以它仍然是空的。获取 Value 并创建我们可以使用的字符串表示的最佳位置是我们在之前创建的 CoerceValue() 方法。如下更改 CoerceValue()

using System.Globalization;

private static object CoerceValue(DependencyObject element, object baseValue)
{
    var control = (NumericUpDown) element;
    var value = (Decimal) baseValue;
    
    control.TextBox.Text = value.ToString(CultureInfo.CurrentCulture);

    return value;
} 

由于这些方法是静态的,我们必须获取对当前 NumericUpDown 的引用,并处理该引用的变量。

别忘了从 OnApplyTemplate() 调用 AttachCommands()

public override void OnApplyTemplate()
{
    base.OnApplyTemplate();
    AttachToVisualTree();
    AttachCommands();
}

如果您现在编译并启动,您仍然会看到一个空的 NumericUpDown。但是,如果您按下侧面的两个按钮,您将看到一个数字。

添加输入绑定

通过小 RepeatButtons 操作 Value 很好,但这还不够。如果我们可以使用键盘箭头,那就太棒了。谢天谢地,由于命令的存在,这是最容易的事情。我们所要做的就是以某种方式在按下相应的键盘箭头时触发 _minorIncreaseValueCommand_minorDecreaseValueCommand - 这就是 InputBindings 发挥作用的地方。我们只需将 InputBinding 注册到我们的命令,每次按下键盘箭头时都会触发该命令。如下修改 AttachCommands()

private void AttachCommands()
{
    CommandBindings.Add(new CommandBinding(_minorIncreaseValueCommand, (a, b) => IncreaseValue()));
    CommandBindings.Add(new CommandBinding(_minorDecreaseValueCommand, (a, b) => DecreaseValue()));

    CommandManager.RegisterClassInputBinding(typeof(TextBox), new KeyBinding(_minorIncreaseValueCommand, new KeyGesture(Key.Up)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox), 
      new KeyBinding(_minorDecreaseValueCommand, new KeyGesture(Key.Down)));
}

就是这样!如果您现在编译并启动,您只需按键盘箭头即可增加/减少值(不要忘记 TextBox 必须获得焦点)。


更新

正如用户 Ichters 所指出的那样,通过 CommandManager 注册输入绑定在单个窗口上使用多个 NumericUpDowns 时将无法正常工作,导致命令仅对最后一个 NumericUpDown 生效。

我非常抱歉,但我真的没有时间通读这篇文章,更改每一个出现的地方,并检查它是否编译/工作,然后对可下载的源代码进行相同的操作。我所能做的就是建议您按如下方式注册命令

TextBox.InputBindings.Add(new KeyBinding(_minorIncreaseValueCommand, new KeyGesture(Key.Up)));
TextBox.InputBindings.Add(new KeyBinding(_minorDecreaseValueCommand, new KeyGesture(Key.Down)));

我猜所有其他命令都应该以相同的方式注册,我无法做出任何保证。

祝您好运!


键盘输入

任何 NumericUpDown 控件都不会缺少直接将值输入到 TextBox 的功能。遗憾的是,这不仅仅是每次 TextBox.Text 更改时将其解析为 Decimal 并将其分配给 Value 的简单问题,我们只有在用户真正完成编辑 TextBox 时才需要更新 Value

有三种方法可以检测用户是否已完成编辑 TextBox

  1. NumericUpDown 失去焦点
  2. 用户使用键盘箭头或侧面的按钮来增加/减少 Value
  3. 用户按下回车键

在处理各个情况之前,我们需要创建一个方法,将文本输入转换为我们可以使用的 Decimal 数字。让我们创建一个方法来完成这项工作

private Decimal ParseStringToDecimal(String source)
{
    Decimal value;
    Decimal.TryParse(source, out value);

    return value;
}

此函数只是将 source 解析为数字。TryParse() 方法很棒,因为无论我们尝试解析什么,它总是返回一个数字(它从不抛出异常)。

让我们处理第一个也是最简单的情况 - NumericUpDown 失去焦点。为此,我们需要拦截 LostFocus 事件,如下所示

private void AttachTextBox()
{
    var textBox = GetTemplateChild("PART_TextBox") as TextBox;

    // A null check is advised
    if (textBox != null)
    {
        TextBox = textBox;
        TextBox.LostFocus += TextBoxOnLostFocus;
    }
}

创建 TextBoxOnLostFocus() 方法,并用 TextBox 的内容更新 Value

private void TextBoxOnLostFocus(object sender, RoutedEventArgs routedEventArgs)
{
    Value = ParseStringToDecimal(TextBox.Text);
} 

如果 TextBox 失去焦点,Value 将自动用 TextBox 的内容更新,然后 TextBox.TextValueString 将设置为 Value。过程如下所示

图 3 - 显示了当 NumericUpDown 失去焦点时发生的情况(数字 401 只是一个随机选择的数字)。CoerceValue() 之后的所有过程在此处完全多余,但稍后变得必不可少——请勿移除。

现在我们已经处理了第一种情况,让我们继续第二种。在我们增加或减少一个数字之前,我们必须确保我们是在最新的数字上进行操作,它始终在 TextBox 中(即使 Value 通过程序设置,OnValueChanged() 也会立即更新 TextBox)。最简单的做法是简单地获取 TextBox 当前内容的 Decimal 表示并对其进行操作,如下所示

private void IncreaseValue()
{
    // Get the value that's currently in the TextBox.Text
    var value = ParseStringToDecimal(TextBox.Text);

    value++;

    Value = value;
} 
private void DecreaseValue()
{
    // Get the value that's currently in the _textBox.Text
    var value = ParseStringToDecimal(TextBox.Text);

    value--;
            
    Value = value;
} 

我们不知道用户是否更改了 TextBox 中的数字,或者它只是连续的增加/减少,使用了未更改的先前 Value,所以我们总是获取最新的 Decimal 表示并对其进行操作。

为了处理第三种情况,我们需要处理回车键。我们需要一个新的命令来执行此操作

private readonly RoutedUICommand _updateValueStringCommand =
    new RoutedUICommand("UpdateValueString", "UpdateValueString", typeof (NumericUpDown));

该命令必须绑定到其 InputBinding

private void AttachCommands()
{
    CommandBindings.Add(new CommandBinding(_minorIncreaseValueCommand, (a, b) => IncreaseValue()));
    CommandBindings.Add(new CommandBinding(_minorDecreaseValueCommand, (a, b) => DecreaseValue()));

    CommandManager.RegisterClassInputBinding(typeof(TextBox), new KeyBinding(_minorIncreaseValueCommand, new KeyGesture(Key.Up)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox), new KeyBinding(_minorDecreaseValueCommand, new KeyGesture(Key.Down)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox), 
      new KeyBinding(_updateValueStringCommand, new KeyGesture(Key.Enter)));
} 

这里没有什么好奇怪的,这和我们之前做过两次的事情一样。唯一剩下的就是告诉命令做什么。那么,它到底应该做什么呢?看下面的代码

private void AttachCommands()
{
    CommandBindings.Add(new CommandBinding(_minorIncreaseValueCommand, (a, b) => IncreaseValue()));
    CommandBindings.Add(new CommandBinding(_minorDecreaseValueCommand, (a, b) => DecreaseValue()));  
    CommandBindings.Add(new CommandBinding(_updateValueStringCommand, (a, b) =>
                                           {
                                               Value = ParseStringToDecimal(TextBox.Text);
                                           }));

    CommandManager.RegisterClassInputBinding(typeof(TextBox), new KeyBinding(_minorIncreaseValueCommand, new KeyGesture(Key.Up)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox), new KeyBinding(_minorDecreaseValueCommand, new KeyGesture(Key.Down)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox), new KeyBinding(_updateValueStringCommand, new KeyGesture(Key.Enter)));
}

当您按下回车键时,Value 会被赋予 TextBox.Text 中内容的最新 Decimal 表示。如果您键入一些随机乱码或格式错误的数字,如 "--4",TryParse() 方法在 ParseStringToDecimal() 内部将只返回 0,因此任何无效数字都无法进入 Value

移除焦点

每次点击其中一个 RepeatButton 时,我们需要从 TextBox 中移除焦点,而不使按钮可获得焦点(以消除难看的键盘焦点光晕)。我们需要拦截 RepeatButtons 上的鼠标左键单击,如下所示: 

private void AttachIncreaseButton()
{
    var increaseButton = GetTemplateChild("PART_IncreaseButton") as RepeatButton;
    if (increaseButton != null)
    {
        IncreaseButton = increaseButton;
        IncreaseButton.Focusable = false;
        IncreaseButton.Command = _minorIncreaseValueCommand;
        IncreaseButton.PreviewMouseLeftButtonDown += (sender, args) => RemoveFocus();
    }
}

private void AttachDecreaseButton()
{
    var decreaseButton = GetTemplateChild("PART_DecreaseButton") as RepeatButton;
    if (decreaseButton != null)
    {
        DecreaseButton = decreaseButton;
        DecreaseButton.Focusable = false;
        DecreaseButton.Command = _minorDecreaseValueCommand;
        IncreaseButton.PreviewMouseLeftButtonDown += (sender, args) => RemoveFocus();
    }
}

RemoveFocus() 方法非常简单。如下创建并填写

private void RemoveFocus()
{
    // Passes focus here and then just deletes it
    Focusable = true;
    Focus();
    Focusable = false;
}

这就是全部了。焦点被传递到 NumericUpDown,然后通过关闭焦点能力将其“销毁”。

添加 MaxValue 和 MinValue 边界

现在是实现 Value 所能包含的数字限制的绝佳时机。顾名思义,MaxValue 表示 Value 可以拥有的最大数字,而 MinValue 则恰好相反。

我们将需要两个 DependencyProperties。让我们先从 MaxValue 开始

public static readonly DependencyProperty MaxValueProperty =
    DependencyProperty.Register("MaxValue", typeof (Decimal), typeof (NumericUpDown),
                                new PropertyMetadata(1000000000m, OnMaxValueChanged,
                                                        CoerceMaxValue));
public Decimal MaxValue
{
    get { return (Decimal) GetValue(MaxValueProperty); }
    set { SetValue(MaxValueProperty, value); }
}

private static void OnMaxValueChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
}

private static object CoerceMaxValue(DependencyObject element, Object baseValue)
{
    var maxValue = (Decimal) baseValue;

    return maxValue;
}

这里没有什么令人惊讶的,所以我们马上添加另一个

public static readonly DependencyProperty MinValueProperty =
    DependencyProperty.Register("MinValue", typeof (Decimal), typeof (NumericUpDown),
                                new PropertyMetadata(0m, OnMinValueChanged,
                                                        CoerceMinValue));

public Decimal MinValue
{
    get { return (Decimal) GetValue(MinValueProperty); }
    set { SetValue(MinValueProperty, value); }
}

private static void OnMinValueChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
}

private static object CoerceMinValue(DependencyObject element, Object baseValue)
{
    var minValue = (Decimal) baseValue;

    return minValue;
}

现在我们有了这两个基本的 DependencyProperties,我们可以开始构建一个健壮的边界系统了。正如我确信您知道的,当边界的两个对立端相遇时,一个应该“推动”另一个——如下图所示

图 4 - 显示了一个基本边界系统。请记住,此图中的所有数字都是随机选择的,以展示问题。

那么我们到底要做什么才能实现这个功能呢?实际上非常简单。我们只需要确保如果一个边界越过另一个边界,我们也移动它。为了实现这一点,您只需如下编辑 OnMaxValueChanged()OnMinValueChanged()

private static void OnMaxValueChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;
    var maxValue = (Decimal) e.NewValue;

    // If maxValue steps over MinValue, shift it
    if (maxValue < control.MinValue)
    {
        control.MinValue = maxValue;
    }
}

private static void OnMinValueChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;
    var minValue = (Decimal) e.NewValue;

    // If minValue steps over MaxValue, shift it
    if (minValue > control.MaxValue)
    {
        control.MaxValue = minValue;
    }
} 

我们有边界,让我们在每次 Value 更改时应用它们。让我们创建一个方便的函数来为我们完成这项工作

private void CoerceValueToBounds(ref Decimal value)
{
    if (value < MinValue)
    {
        value = MinValue;
    }
    else if (value > MaxValue)
    {
        value = MaxValue;
    }
}

此方法返回一个保持在 <MinValue; MaxValue> 区间内的值。

让我们在强制 Value 时使用此方法

private static object CoerceValue(DependencyObject element, object baseValue)
{
    var control = (NumericUpDown) element;
    var value = (Decimal) baseValue;

    control.CoerceValueToBounds(ref value);
    
    control.TextBox.Text = value.ToString(CultureInfo.CurrentCulture);

    return value;
}

在它按计划工作之前还有一些工作要做。如下修改 IncreaseValue()DecreaseValue()

private void IncreaseValue()
{
    // Get the value that's currently in the TextBox.Text
    var value = ParseStringToDecimal(TextBox.Text);

    // Coerce the value to min/max
    CoerceValueToBounds(ref value);

    // Only change the value if it has any meaning
    if (value <= MaxValue)
    {
        value++;
    }

    Value = value;
} 
private void DecreaseValue()
{
    // Get the value that's currently in the TextBox.Text
    var value = ParseStringToDecimal(TextBox.Text);

    // Coerce the value to min/max
    CoerceValueToBounds(ref value);

    // Only change the value if it has any meaning
    if (value >= MinValue)
    {
        value--;
    }

    Value = value;
}

这里发生了很多事情。首先,我们获取 TextBox 的内容并从中提取一个数字。然后将该数字强制转换为 MaxValueMinValue 边界。到目前为止,没有什么应该令人惊讶。

if 语句确保我们不会在不合理的情况下尝试更改 value。如果 MinValue0Value 也是 0,则尝试减少 Value 没有意义。最后,我们简单地分配 Value

最后,我们需要确保如果 MaxValueMinValue 发生变化,Value 会立即更新(如果它突然超出边界)。但是有一个问题,想象一下 MinValue4MaxValue7Value 已设置为 2,并考虑下图

图 5 - 显示了“期望值”如何影响依赖属性。

虽然此功能可能在其他地方有用,但对我们的用途来说是高度不希望的。我们可以很容易地摆脱它,如下面两个修改后的方法所示

private static void OnMaxValueChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;
    var maxValue = (Decimal) e.NewValue;

    // If maxValue steps over MinValue, shift it
    if (maxValue < control.MinValue)
    {
        control.MinValue = maxValue;
    }

    if (maxValue <= control.Value)
    {
        control.Value = maxValue;
    }
}

private static void OnMinValueChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;
    var minValue = (Decimal) e.NewValue;

    // If minValue steps over MaxValue, shift it
    if (minValue > control.MaxValue)
    {
        control.MaxValue = minValue;
    }

    if (minValue >= control.Value)
    {
        control.Value = minValue;
    }
} 

现在,每次其中一个值改变时,Value 都会在必要时自动纠正以保持在边界内。如果边界(MinValueMaxValue)的改变导致 Value 超出范围,Value 将被明确设置为它本来会被强制转换到的数字。当 Value 被明确设置时,其期望值也会设置为相同的数字,所以即使您稍后放宽边界,Value 也不会移动(因为它已经处于期望值)。这是一个非常适合我们需求的巧妙技巧。

现在这个过程是这样的。向 TextBox 的反向赋值不再无用,而是必不可少的。

图 6 - 显示了 TextBox 失去焦点后发生的情况。强制转换 Value 并将其重新分配给 TextBox 的过程在此时非常重要。

添加小数位数

最后我们来到了指定 TextBox 显示多少小数位数的能力。

一如既往,我们必须从 DependencyProperty 开始

public static readonly DependencyProperty DecimalPlacesProperty =
    DependencyProperty.Register("DecimalPlaces", typeof (Int32), typeof (NumericUpDown),
                                new PropertyMetadata(0, OnDecimalPlacesChanged,
                                                        CoerceDecimalPlaces));

public Int32 DecimalPlaces
{
    get { return (Int32) GetValue(DecimalPlacesProperty); }
    set { SetValue(DecimalPlacesProperty, value); }
}

private static void OnDecimalPlacesChanged(DependencyObject element,
                                            DependencyPropertyChangedEventArgs e)
{
}

private static object CoerceDecimalPlaces(DependencyObject element, Object baseValue)
{
    var decimalPlaces = (Int32) baseValue;

    return decimalPlaces;
} 

现在我们有了 DependencyProperty,让我们思考一下。我们究竟要如何改变小数位数呢?好吧,这一切都归结为 CoerceValue() 方法中的一行代码

control.TextBox.Text = value.ToString(CultureInfo.CurrentCulture); 

上面的 ToString() 所做的是根据 CurrentCulture 的规则将 value 中的 Decimal 数字格式化为 String。试想一下,您可以用多少种不同的方式以正确的格式输入一个值。

  • 1,000 (一千)
  • 1,000,000.000 (一百万)
  • 1.5 (一又二分之一)

上面这些数字有什么共同点?文化!它们都根据 en-US 文化格式进行格式化。接下来看看这些值

  • 1 000 (一千)
  • 1 000 000,000 (一百万)
  • 1,5 (一又二分之一)

糟糕,事情变得复杂了。看来像丹麦、捷克共和国和俄罗斯这样的国家使用这种奇怪的方式来分隔数字,甚至使用逗号而不是句点作为小数点。

“你为什么要提起这个?”你可能会问。好吧,因为如果你只为美国人编程 NumericUpDown,而来自(比方说)捷克共和国的人试图使用它们,他们就会很难理解为什么他们的小数逗号会消失(因为它被视为千位分隔符)。

我相信还有更多文化具有更奇怪的文化格式,那么我们应该如何处理所有这些呢?好吧,如上所述,我们可以访问 CultureInfo.CurrentCulture 来获取大部分相关信息。正如 CurrentCulture 这个名字所暗示的,它提供了当前执行我们应用程序的计算机的文化信息。

CurrentCulture 不仅仅是这样。当我们在格式化值时,我们还可以用它让我们的生活变得更轻松。怎么做?好吧,CurrentCulture 实际上指定了当数字解析为 String 时,有多少小数位是可见的!我们所要做的就是获取 CurrentCulture 的副本,修改其小数位数,然后使用它而不是 CultureInfo.CurrentCulture

让我们首先为我们自己的文化信息创建一个字段

protected readonly CultureInfo Culture;

现在我们需要创建 CultureInfo.CurrentCulture 的副本。最好的位置是实例构造函数。如下创建并修改它

public NumericUpDown()
{
    Culture = (CultureInfo) CultureInfo.CurrentCulture.Clone();
}

默认情况下(至少在我的系统上),CultureInfo.CurrentCulture 指定 2 位小数。我们必须覆盖此行为,并使用我们的 DecimalPlaces DependencyProperty。恰好,构造函数也是实现此目的的地方,所以让我们进一步修改它,如下所示

public NumericUpDown()
{
    Culture = (CultureInfo) CultureInfo.CurrentCulture.Clone();

    Culture.NumberFormat.NumberDecimalDigits = DecimalPlaces;
} 

不过,有一个小小的复杂之处——小数位数不能是负数,并且受架构限制,最多只能有 28 位小数。因此,我们必须强制转换 DecimalPlaces 中的输入。如下修改 CoerceDecimalPlaces()

private static object CoerceDecimalPlaces(DependencyObject element, Object baseValue)
{
    var decimalPlaces = (Int32) baseValue;

    if (decimalPlaces < 0)
    {
        decimalPlaces = 0;
    }
    else if (decimalPlaces > 28)
    {
        decimalPlaces = 28;
    }

    return decimalPlaces;
}

任何不符合这两个限制的数字在分配给 DecimalPlaces 属性之前都会被强制转换。

剩下的就是每次 DecimalPlaces 更改时更新我们的 Culture 变量。如下修改 OnDecimalPlacesChanged()

private static void OnDecimalPlacesChanged(DependencyObject element,
                                            DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;
    var decimalPlaces = (Int32) e.NewValue;

    control.Culture.NumberFormat.NumberDecimalDigits = decimalPlaces;
} 

变量 decimalPlaces 已在强制转换方法中检查过,因此此赋值没有问题。

正如我所说,所有格式化都由一个函数完成,即 ToString()。目前,它的使用方式如下

control.TextBox.Text = value.ToString(CultureInfo.CurrentCulture);  

我们已经知道必须用我们的 Culture 变量替换 CultureInfo.CurrentCulture,但这还不够。我们还必须指定一个格式,通过它(duh)格式化值。在我们的例子中,它是 "F",代表 Fixed-point(定点)。您可以在这里阅读更多关于格式的信息。

要指定格式并使用我们自己的 Culture,请如下修改 CoerceValue() 方法

private static object CoerceValue(DependencyObject element, object baseValue)
{
    var control = (NumericUpDown) element;
    var value = (Decimal) baseValue;

    control.CoerceValueToBounds(ref value);

    if (control.TextBox != null)
    {
        control.TextBox.Text = value.ToString("F", control.Culture);
    }

    return value;
}  

太棒了,Value 从此完全按照我们自己的规则格式化。 别忘了添加空值检查,因为此方法首次运行时 TextBox 可能不存在。

最后剩下的一件事是每次 DecimalPlaces 更改时更新 Value。如下修改 OnDecimalPlacesChanged()

private static void OnDecimalPlacesChanged(DependencyObject element,
                                            DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;
    var decimalPlaces = (Int32) e.NewValue;

    control.Culture.NumberFormat.NumberDecimalDigits = decimalPlaces;

    control.InvalidateProperty(ValueProperty);
} 

InvalidateProperty() 对我们来说是新的。它所做的只是告诉 Value 使自己失效(再次运行 CoerceValue() 方法)。这为什么好呢?好吧,我们的格式化函数(ToString())就在那里。当它格式化 value 时,它使用我们更新的 Culture 变量,带有不同的小数位数——这正是我们所需要的。

限制小数位数

限制小数位数肯定会派上用场,所以我们来实现它。

出乎意料的是,它都始于两个 DependencyPropertiesMaxDecimalPlacesMinDecimalPlaces

public static readonly DependencyProperty MaxDecimalPlacesProperty =
    DependencyProperty.Register("MaxDecimalPlaces", typeof(Int32), typeof(NumericUpDown),
                                new PropertyMetadata(28, OnMaxDecimalPlacesChanged,
                                                        CoerceMaxDecimalPlaces));

public Int32 MaxDecimalPlaces
{
    get { return (Int32)GetValue(MaxDecimalPlacesProperty); }
    set { SetValue(MaxDecimalPlacesProperty, value); }
}

private static void OnMaxDecimalPlacesChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
}

private static object CoerceMaxDecimalPlaces(DependencyObject element, Object baseValue)
{
    var maxDecimalPlaces = (Int32) baseValue;

    return maxDecimalPlaces;
} 
public static readonly DependencyProperty MinDecimalPlacesProperty =
    DependencyProperty.Register("MinDecimalPlaces", typeof(Int32), typeof(NumericUpDown),
                                new PropertyMetadata(0, OnMinDecimalPlacesChanged,
                                                        CoerceMinDecimalPlaces));

public Int32 MinDecimalPlaces
{
    get { return (Int32)GetValue(MinDecimalPlacesProperty); }
    set { SetValue(MinDecimalPlacesProperty, value); }
}

private static void OnMinDecimalPlacesChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
}

private static object CoerceMinDecimalPlaces(DependencyObject element, Object baseValue)
{
    var minDecimalPlaces = (Int32) baseValue;

    return minDecimalPlaces;
} 

MaxDecimalPlaces 默认设置为 28MinDecimalPlaces 默认设置为 0,两者都将使用 PropertyChangedCallbackCoerceValueCallback

首先,我们添加明显的强制转换——MaxDecimalPlaces 不能超过 28MinDecimalPlaces 必须大于 0。我们基本上只是将强制转换从 DecimalPlaces 属性转移到这两个属性中。如下修改 CoerceMaxDecimalPlaces()

private static object CoerceMaxDecimalPlaces(DependencyObject element, Object baseValue)
{
    var maxDecimalPlaces = (Int32)baseValue;
    var control = (NumericUpDown) element;

    if (maxDecimalPlaces > 28)
    {
        maxDecimalPlaces = 28;
    }
    else if (maxDecimalPlaces < 0)
    {
        maxDecimalPlaces = 0;
    }
    else if (maxDecimalPlaces < control.MinDecimalPlaces)
    {
        control.MinDecimalPlaces = maxDecimalPlaces;
    }

    return maxDecimalPlaces;
} 

这里没什么特别的,只需注意第二个 if 语句,确保 MaxDecimalPlaces 不会越过另一个边界(因为否则我们可能会通过 MaxDecimalPlacesMinDecimalPlaces 推到负数)。

让我们继续下一个

private static object CoerceMinDecimalPlaces(DependencyObject element, Object baseValue)
{
    var minDecimalPlaces = (Int32)baseValue;
    var control = (NumericUpDown) element;

    if (minDecimalPlaces < 0)
    {
        minDecimalPlaces = 0;
    }
    else if (minDecimalPlaces > 28)
    {
        minDecimalPlaces = 28;
    }
    else if (minDecimalPlaces > control.MaxDecimalPlaces)
    {
        control.MaxDecimalPlaces = minDecimalPlaces;
    }

    return minDecimalPlaces;
}

再次注意第二个 if 语句。否则,一切都如你所料。

在继续之前,让我们从 DecimalPlaces 中删除(现在已过时的)边界检查,并用我们新的边界系统替换它们。如下修改 CoerceDecimalPlaces()

private static object CoerceDecimalPlaces(DependencyObject element, Object baseValue)
{
    var decimalPlaces = (Int32) baseValue;
    var control = (NumericUpDown) element;

    if (decimalPlaces < control.MinDecimalPlaces)
    {
        decimalPlaces = control.MinDecimalPlaces;
    }
    else if (decimalPlaces > control.MaxDecimalPlaces)
    {
        decimalPlaces = control.MaxDecimalPlaces;
    }

    return decimalPlaces;
} 

代码应该很直观,我们只是检查任意边界末端,而不是硬编码的 280

DecimalPlaces 实际上会利用期望值,所以没有必要避免它。在这种情况下,我们可以在每次任一边界末端改变时使 DecimalPlaces 失效。如下修改 OnMaxDecimalPlaces()OnMinDecimalPlacesChanged()

private static void OnMaxDecimalPlacesChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;

    control.InvalidateProperty(DecimalPlacesProperty);
} 
private static void OnMinDecimalPlacesChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;

    control.InvalidateProperty(DecimalPlacesProperty);
}

就是这样。现在每次 DecimalPlaces 更改时,Value 都会以正确的小数位数更新,并且每次任一小数边界末端更改时,DecimalPlaces 都会更新,从而更新 Value(原谅冗余的解释)。这是一个幸福的大循环。

截断溢出的小数位

我们有一个具有一些很酷功能的 NumericUpDown,但请考虑一个小的场景。如果 DecimalPlaces0,但某个用户在 TextBox 中输入“0.01”怎么办?好吧,Value 会设置为 0.01!您看不到它,因为 DecimalPlaces 会影响格式化以不显示小数位,但它们确实存在。而且,如果您将某个东西绑定到 Value,当您发现它保留了小数部分并自豪地持有数字 0.01 时,您会大吃一惊。

解决方案?去除不属于的小数位。听起来很简单,但并非如此,因为我们必须以某种方式计算 Value 所拥有的小数位数。几乎唯一的方法是创建文本表示并找出小数点后有多少个数字。下面的方法就是这样做的

using System.Linq;

public Int32 GetDecimalPlacesCount(String valueString)
{
    return valueString.SkipWhile(c => c.ToString(Culture)
            != Culture.NumberFormat.NumberDecimalSeparator).Skip(1).Count();
}

这个丑陋的 LINQ 恶魔子嗣实际上找到了第一个小数点,然后计算后面的所有字符。如果您不知道 LINQ 是什么,可以查阅 dotnetperls 作为起点。

我们需要实现的第二个方法是从一个 Decimal 数字中移除任意小数位数。请看下面的方法

private Decimal TruncateValue(String valueString, Int32 decimalPlaces)
{
    var endPoint = valueString.Length - (decimalPlaces - DecimalPlaces);
    var tempValueString = valueString.Substring(0, endPoint);

    return Decimal.Parse(tempValueString, Culture);
}

此方法只是从末尾删除不需要的字符(不需要的小数位),然后将更改后的字符串解析回 Decimal。虽然不是最干净的解决方案,但它适用于所有可能的数字。

让我们使用这两个方法,这样您就可以看到它们是如何协同工作的。如下修改 CoerceValue()

private static object CoerceValue(DependencyObject element, object baseValue)
{
    var control = (NumericUpDown) element;
    var value = (Decimal) baseValue;

    control.CoerceValueToBounds(ref value);

    // Get the text representation of Value
    var valueString = value.ToString(control.Culture);

    // Count all decimal places
    var decimalPlaces = control.GetDecimalPlacesCount(valueString);

    if (decimalPlaces > control.DecimalPlaces)
    {
        // Remove all overflowing decimal places
        value = control.TruncateValue(valueString, decimalPlaces);
    }

    if (control.TextBox != null)
    {
        control.TextBox.Text = value.ToString("F", control.Culture);
    }

    return value;
}

其中最难的部分无疑是这两个方法,所以其余的应该轻而易举。首先,我们获取 value 的文本表示。这里不需要指定格式,因为包含小数点的数字会自动格式化。然后我们计算小数位数,并检查 value 是否拥有的小数位数多于允许的位数(由 DecimalPlaces 属性决定)。如果多于,我们移除不属于的小数位,最后将值格式化到 TextBox 中。

动态小数点 

现在,如果 DecimalPlaces0 且用户输入 3.14,则输入将被截断为 3。如果我们想允许用户自己决定小数位数,以便如果他希望操作具有两位小数的数字,他只需通过输入具有该位数小数的数字来更改它,那该怎么办?听起来很酷,让我们来研究一下。

首先,我们需要实现一个 DependencyProperty,它将决定用户是否允许通过输入更改 DecimalPlaces,或者输入是否被截断。我们将此属性命名为 IsDecimalPointDynamic

public static readonly DependencyProperty IsDecimalPointDynamicProperty =
    DependencyProperty.Register("IsDecimalPointDynamic", typeof(Boolean), typeof(NumericUpDown),
                                new PropertyMetadata(false));

public Boolean IsDecimalPointDynamic
{
    get { return (Boolean)GetValue(IsDecimalPointDynamicProperty); }
    set { SetValue(IsDecimalPointDynamicProperty, value); }
}

每次分配 Value 时都会检查此属性。如下修改 CoerceValue()

private static object CoerceValue(DependencyObject element, object baseValue)
{
    var control = (NumericUpDown) element;
    var value = (Decimal) baseValue;

    control.CoerceValueToBounds(ref value);

    // Get the text representation of Value
    var valueString = value.ToString(control.Culture);

    // Count all decimal places
    var decimalPlaces = control.GetDecimalPlacesCount(valueString);

    if (decimalPlaces > control.DecimalPlaces)
    {
        if (control.IsDecimalPointDynamic)
        {
            // Assigning DecimalPlaces will coerce the number
            control.DecimalPlaces = decimalPlaces;

            // If the specified number of decimal places is still too much
            if (decimalPlaces > control.DecimalPlaces)
            {
                value = control.TruncateValue(valueString, control.DecimalPlaces);   
            }
        }
        else
        {
            // Remove all overflowing decimal places
            value = control.TruncateValue(valueString, decimalPlaces);
        }
    }
    else if (control.IsDecimalPointDynamic)
    {
        control.DecimalPlaces = decimalPlaces;
    }

    if (control.TextBox != null)
    {
        control.TextBox.Text = value.ToString("F", control.Culture);
    }

    return value;
}

我们检查 DecimalPlaces 属性是否可以动态更改。如果可以,一旦我们知道 valueString 有多少小数位,就必须立即分配 DecimalPlaces。为什么?因为我们需要知道 valueString 的小数位数是否超过 MinDecimalPlacesMaxDecimalPlaces(通过分配 DecimalPlaces,数字会被强制转换,还记得吗?)。如果不是,很好——我们直接进行值格式化。另一方面,如果是,我们需要截断溢出的小数位(这次是截断到 DecimalPlaces 中的强制转换值)。如果小数位较少,则第二个 if 语句会负责更改 DecimalPlaces

在它正确工作之前,我们需要做的最后一件事如下所示

private static void OnDecimalPlacesChanged(DependencyObject element,
                                            DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;
    var decimalPlaces = (Int32) e.NewValue;

    control.Culture.NumberFormat.NumberDecimalDigits = decimalPlaces;

    if (control.IsDecimalPointDynamic)
    {
        control.IsDecimalPointDynamic = false;
        control.InvalidateProperty(ValueProperty);
        control.IsDecimalPointDynamic = true;
    }
    else
    {
        control.InvalidateProperty(ValueProperty);
    }
}

为什么要进行这样的检查?好吧,假设用户输入“3.14”,IsDecimalPointDynamic 设置为 trueDecimalPoint 没有限制。在这种情况下,DecimalPlaces 会愉快地设置为 23.14 中的小数位数),一切看起来都正常。然而,事实并非如此。如果您随后尝试以任何方式更改 DecimalPlacesValue 将覆盖您的所有尝试。

DecimalPlaces 的每次更改都会使 Value 失效(如果您还记得,在 OnDecimalPlacesChanged() 中调用了 InvalidateProperty()),因此,当 value 经过其 CoerceValue() 方法时,它会注意到 IsDecimalPointDynamic 为 true,并将 DecimalPlaces 分配为 2TextBox 中数字的小数位数),从而覆盖之前设置的任何 DecimalPlaces。呼,这有点令人困惑。

添加千位分隔符

千位分隔符对于较大的数字当然非常有用。如果您不知道千位分隔符是什么,只需快速瞥一眼这里就足够了。

让我们从一个名为 IsThousandSeparatorVisibleDependendyProperty 开始

public static readonly DependencyProperty IsThousandSeparatorVisibleProperty =
    DependencyProperty.Register("IsThousandSeparatorVisible", typeof(Boolean), typeof(NumericUpDown),
                                new PropertyMetadata(false, OnIsThousandSeparatorVisibleChanged));

public Boolean IsThousandSeparatorVisible
{
    get { return (Boolean)GetValue(IsThousandSeparatorVisibleProperty); }
    set { SetValue(IsThousandSeparatorVisibleProperty, value); }
}

private static void OnIsThousandSeparatorVisibleChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
}

我们将使用 PropertyChangedCallback 来使 Value 失效(它会重新格式化 TextBox,带上(或不带)千位分隔符)。

如下修改 OnIsThousandSeparatorVisibleChanged()

private static void OnIsThousandSeparatorVisibleChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown)element;

    control.InvalidateProperty(ValueProperty);
} 

现在我们必须根据这个 DependencyProperty 实际更改格式类型。如下修改 CoerceValue()

private static object CoerceValue(DependencyObject element, object baseValue)
{
    var control = (NumericUpDown) element;
    var value = (Decimal) baseValue;

    control.CoerceValueToBounds(ref value);

    // Get the text representation of Value
    var valueString = value.ToString(control.Culture);

    // Count all decimal places
    var decimalPlaces = control.GetDecimalPlacesCount(valueString);

    if (decimalPlaces > control.DecimalPlaces)
    {
        if (control.IsDecimalPointDynamic)
        {
            // Assigning DecimalPlaces will coerce the number
            control.DecimalPlaces = decimalPlaces;

            // If the specified number of decimal places is still too much
            if (decimalPlaces > control.DecimalPlaces)
            {
                value = control.TruncateValue(valueString, control.DecimalPlaces);   
            }
        }
        else
        {
            // Remove all overflowing decimal places
            value = control.TruncateValue(valueString, decimalPlaces);
        }
    }
    else if (control.IsDecimalPointDynamic)
    {
        control.DecimalPlaces = decimalPlaces;
    }

    // Change formatting based on this flag
    if (control.IsThousandSeparatorVisible)
    {
        if (control.TextBox != null)
        {
            control.TextBox.Text = value.ToString("N", control.Culture);
        }
    }
    else
    {
        if (control.TextBox != null)
        {
            control.TextBox.Text = value.ToString("F", control.Culture);
        }
    }

    return value;
} 

是的,没错,添加千位分隔符的秘诀是将格式从 "F" 更改为 "N" - Number。同样,不要忘记空值检查。

添加次要和主要增量  

要添加此功能,我们需要修改我们命令的两个回调。此外,这里出现了两个全新的命令

  • _majorIncreaseValueCommand
  • _majorDecreaseValueCommand

因此,如果您一直在想为什么之前的命令名称以“minor”开头,答案就在这里:次要命令使用 MinorDelta,主要命令使用 MajorDelta

首先,让我们为每个增量创建 DependencyProperties

public static readonly DependencyProperty MinorDeltaProperty =
    DependencyProperty.Register("MinorDelta", typeof(Decimal), typeof(NumericUpDown),
                                new PropertyMetadata(1m, OnMinorDeltaChanged,
                                                        CoerceMinorDelta));

public Decimal MinorDelta
{
    get { return (Decimal)GetValue(MinorDeltaProperty); }
    set { SetValue(MinorDeltaProperty, value); }
}

private static void OnMinorDeltaChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
}

private static object CoerceMinorDelta(DependencyObject element, Object baseValue)
{
    var minorDelta = (Decimal)baseValue;

    return minorDelta;
} 

这两个属性唯一不同的部分是默认值,1m10m。永远不要忘记 m 说明符,否则控件将拒绝编译!

public static readonly DependencyProperty MajorDeltaProperty =
    DependencyProperty.Register("MajorDelta", typeof(Decimal), typeof(NumericUpDown),
                                new PropertyMetadata(10m, OnMajorDeltaChanged,
                                                        CoerceMajorDelta));

public Decimal MajorDelta
{
    get { return (Decimal)GetValue(MajorDeltaProperty); }
    set { SetValue(MajorDeltaProperty, value); }
}

private static void OnMajorDeltaChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
}

private static object CoerceMajorDelta(DependencyObject element, Object baseValue)
{
    var majorDelta = (Decimal)baseValue;

    return majorDelta;
} 

现在我们有了这些属性,我们需要编写标准边界系统。如下修改 OnMinorDeltaChanged()

private static void OnMinorDeltaChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var minorDelta = (Decimal)e.NewValue;
    var control = (NumericUpDown)element;

    if (minorDelta > control.MajorDelta)
    {
        control.MajorDelta = minorDelta;
    }
} 

OnMajorDeltaChanged() 的作用也大致相同

private static void OnMajorDeltaChanged(DependencyObject element,
                                        DependencyPropertyChangedEventArgs e)
{
    var majorDelta = (Decimal) e.NewValue;
    var control = (NumericUpDown) element;

    if (majorDelta < control.MinorDelta)
    {
        control.MinorDelta = majorDelta;
    }
}

我们已经做过类似的事情了,所以真的没什么好说的。一个边界推动另一个边界,以确保它们永远不会交叉——非常简单。

我们可以立即使用这两个增量。如下修改 IncreaseValue()DecreaseValue()

private void IncreaseValue(Boolean minor)
{
    // Get the value that's currently in the _textBox.Text
    decimal value = ParseStringToDecimal(TextBox.Text);

    // Coerce the value to min/max
    CoerceValueToBounds(ref value);

    // Only change the value if it has any meaning
    if (value >= MinValue)
    {
        if (minor)
        {
            value += MinorDelta;
        }
        else
        {
            value += MajorDelta;
        }
    }

    Value = value;
}
private void DecreaseValue(Boolean minor)
{
    // Get the value that's currently in the _textBox.Text
    decimal value = ParseStringToDecimal(TextBox.Text);

    // Coerce the value to min/max
    CoerceValueToBounds(ref value);

    // Only change the value if it has any meaning
    if (value <= MaxValue)
    {
        if (minor)
        {
            value -= MinorDelta;
        }
        else
        {
            value -= MajorDelta;
        }
    }

    Value = value;
}

我们不是简单地将 Value 增加/减少 1,而是根据 minor 标志使用 MinorDeltaMajorDelta

为了提供决定使用哪个增量的布尔值,我们需要新的命令

private readonly RoutedUICommand _majorDecreaseValueCommand =
    new RoutedUICommand("MajorDecreaseValue", "MajorDecreaseValue", typeof(NumericUpDown));

private readonly RoutedUICommand _majorIncreaseValueCommand =
    new RoutedUICommand("MajorIncreaseValue", "MajorIncreaseValue", typeof(NumericUpDown));

现在我们不仅需要绑定新命令,还需要由于添加的参数而修改 IncreaseValue()DecreaseValue() 的调用方式。

private void AttachCommands()
{
    CommandBindings.Add(new CommandBinding(_minorIncreaseValueCommand, (a, b) => IncreaseValue(true)));
    CommandBindings.Add(new CommandBinding(_minorDecreaseValueCommand, (a, b) => DecreaseValue(true)));
    CommandBindings.Add(new CommandBinding(_majorIncreaseValueCommand, (a, b) => IncreaseValue(false)));
    CommandBindings.Add(new CommandBinding(_majorDecreaseValueCommand, (a, b) => DecreaseValue(false)));
    CommandBindings.Add(new CommandBinding(_updateValueStringCommand, (a, b) =>
                                           {
                                               Value = ParseStringToDecimal(TextBox.Text);
                                           }));

    CommandManager.RegisterClassInputBinding(typeof (TextBox),
                    new KeyBinding(_minorIncreaseValueCommand, new KeyGesture(Key.Up)));
    CommandManager.RegisterClassInputBinding(typeof (TextBox),
                    new KeyBinding(_minorDecreaseValueCommand, new KeyGesture(Key.Down)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox),
                    new KeyBinding(_majorIncreaseValueCommand, new KeyGesture(Key.PageUp)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox),
                    new KeyBinding(_majorDecreaseValueCommand, new KeyGesture(Key.PageDown)));
    CommandManager.RegisterClassInputBinding(typeof (TextBox),
                    new KeyBinding(_updateValueStringCommand, new KeyGesture(Key.Enter)));
}

我们不再调用不带任何参数的 IncreaseValue()DecreaseValue()。我们传递的标志正是 minor 标志,这样 IncreaseValue()DecreaseValue() 就能使用适当的增量来修改 Value。此外,请注意,两个主要命令都绑定到 PageUp/PageDown 键。您可以立即尝试,将焦点放在 NumericUpDown 上并按下其中一个键,将 Value 增加 10(默认值)。  

允许自动选择

虽然我不太喜欢这个功能,但我经常在各种用户界面中看到它。我承认,大多数时候你可能想输入一个完全不同的数字而不是编辑单个数字,所以我能理解它为什么可能有用。

一开始,总会有一个 DependencyProperty

public static readonly DependencyProperty IsAutoSelectionActiveProperty =
    DependencyProperty.Register("IsAutoSelectionActive", typeof(Boolean), typeof(NumericUpDown),
                                new PropertyMetadata(false));

public Boolean IsAutoSelectionActive
{
    get { return (Boolean)GetValue(IsAutoSelectionActiveProperty); }
    set { SetValue(IsAutoSelectionActiveProperty, value); }
}

不需要 PropertyChangedCallback CoerceValueCallback

我们还需要知道 TextBox 何时被点击,所以让我们拦截 PreviewMouseLeftButtonUp 事件。为此,如下修改 AttachTextBox() 方法

private void AttachTextBox()
{
    var textBox = GetTemplateChild("PART_TextBox") as TextBox;

    // A null check is advised
    if (textBox != null)
    {
        TextBox = textBox;
        TextBox.LostFocus += TextBoxOnLostFocus;
        TextBox.PreviewMouseLeftButtonUp += TextBoxOnPreviewMouseLeftButtonUp;
    }
}

TextBoxOnPreviewMouseLeftButtonUp() 方法(是不是很顺口?)执行以下操作

private void TextBoxOnPreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs mouseButtonEventArgs)
{
    if (IsAutoSelectionActive)
    {
        TextBox.SelectAll();
    }
}

很简单——每次 TextBox 被点击时(前提是 IsAutoSelectionActivetrue),我们都会选择 TextBox 的全部内容。第二次点击时选择会自动消失,所以它仍然允许您编辑单个数字。这是一个双赢的局面。

值环绕

环绕通过将 Value 设置到边界的末端来实现,该末端与 Value 在增加或减少时穿过的末端相反。这里没有太大的问题,理解起来应该不难。

我们需要一个 DependencyProperty 来知道我们是否可以环绕

public static readonly DependencyProperty IsValueWrapAllowedProperty =
    DependencyProperty.Register("IsValueWrapAllowed", typeof(Boolean), typeof(NumericUpDown),
                                new PropertyMetadata(false));

public Boolean IsValueWrapAllowed
{
    get { return (Boolean)GetValue(IsValueWrapAllowedProperty); }
    set { SetValue(IsValueWrapAllowedProperty, value); }
}

不需要 CoerceValueCallbackPropertyChangedCallback

我们需要包装 Value。如下修改 IncreaseValue()DecreaseValue()

private void IncreaseValue(Boolean minor)
{
    // Get the value that's currently in the _textBox.Text
    decimal value = ParseStringToDecimal(TextBox.Text);

    // Coerce the value to min/max
    CoerceValueToBounds(ref value);

    // Only change the value if it has any meaning
    if (value >= MinValue)
    {
        if (minor)
        {
            if (IsValueWrapAllowed && value + MinorDelta > MaxValue)
            {
                value = MinValue;
            } 
            else
            {
                value += MinorDelta;
            }
        }
        else
        {
            if (IsValueWrapAllowed && value + MajorDelta > MaxValue)
            {
                value = MinValue;
            }
            else
            {
                value += MajorDelta;
            }
        }
    }

    Value = value;
}
private void DecreaseValue(Boolean minor)
{
    // Get the value that's currently in the _textBox.Text
    decimal value = ParseStringToDecimal(TextBox.Text);

    // Coerce the value to min/max
    CoerceValueToBounds(ref value);

    // Only change the value if it has any meaning
    if (value <= MaxValue)
    {
        if (minor)
        {
            if (IsValueWrapAllowed && value - MinorDelta < MinValue)
            {
                value = MaxValue;
            }
            else
            {
                value -= MinorDelta;
            }
        }
        else
        {
            if (IsValueWrapAllowed && value - MajorDelta < MinValue)
            {
                value = MaxValue;
            }
            else
            {
                value -= MajorDelta;
            }
        }
    }

    Value = value;
}

就是这样。现在,每次您尝试将 Value 增加或减少超出其中一个边界时(前提是 IsValueWrapAllowedtrue),它都会被设置为相反的边界末端。

默认的 TextBox 内容

啊,是的。直到现在,NumericUpDown 总是以空值启动。我知道,我知道,这很容易补救。只需如下修改实例构造函数

public NumericUpDown()
{
    Culture = (CultureInfo) CultureInfo.CurrentCulture.Clone();
           
    Culture.NumberFormat.NumberDecimalDigits = DecimalPlaces;

    Loaded += OnLoaded;
}

是的,我们必须拦截这个事件,因为只有这样我们才能确定 TextBox 已经附加。

OnLoaded() 方法如下所示

private void OnLoaded(object sender, RoutedEventArgs routedEventArgs)
{
    InvalidateProperty(ValueProperty);
}

我们在这里所要做的就是使 Value 失效,这会自动填充 TextBox。非常简单。

右键清零

让我们实现一个我看到后立刻喜欢上的功能。基本上,您可以右键单击侧面的任何一个按钮,然后将 Value 清零。这非常方便,可以节省大量时间。

我们所要做的就是拦截两个 RepeatButtons 上的右键单击,同时移除焦点

private void AttachIncreaseButton()
{
    var increaseButton = GetTemplateChild("PART_IncreaseButton") as RepeatButton;
    if (increaseButton != null)
    {
        IncreaseButton = increaseButton;
        IncreaseButton.Focusable = false;
        IncreaseButton.Command = _minorIncreaseValueCommand;
        IncreaseButton.PreviewMouseRightButtonDown += ButtonOnPreviewMouseRightButtonDown;
        IncreaseButton.PreviewMouseLeftButtonDown += (sender, args) => RemoveFocus();
    }
}

private void AttachDecreaseButton()
{
    var decreaseButton = GetTemplateChild("PART_DecreaseButton") as RepeatButton;
    if (decreaseButton != null)
    {
        DecreaseButton = decreaseButton;
        DecreaseButton.Focusable = false;
        DecreaseButton.Command = _minorDecreaseValueCommand;
        DecreaseButton.PreviewMouseRightButtonDown += ButtonOnPreviewMouseRightButtonDown;
        DecreaseButton.PreviewMouseLeftButtonDown += (sender, args) => RemoveFocus();
    }
}

我们对这两个按钮使用相同的事件处理程序。如下创建 ButtonOnPreviewMouseRightButtonDown()

private void ButtonOnPreviewMouseRightButtonDown(object sender, MouseButtonEventArgs mouseButtonEventArgs)
{
    Value = 0;
}

就是这样。我们无需关心 Value 是否可以设置为 0。如果不能,Value 会自动强制转换。

添加取消未确认更改的功能

此功能非常有用。当您在 TextBox 中输入内容但确认时,您可以通过按 Esc 键摆脱输入的值,从而取消未确认的更改。

首先,我们需要设置我们的 TextBox 以允许撤销。如下修改 AttachTextBox()

private void AttachTextBox()
{
    var textBox = GetTemplateChild("PART_TextBox") as TextBox;

    // A null check is advised
    if (textBox != null)
    {
        TextBox = textBox;
        TextBox.LostFocus += TextBoxOnLostFocus;
        TextBox.PreviewMouseLeftButtonUp += TextBoxOnPreviewMouseLeftButtonUp;
                
        TextBox.UndoLimit = 1;
        TextBox.IsUndoEnabled = true;
    }
}

这将允许我们撤销精确地一次更改。

接下来,我们需要创建一个名为 _cancelChangesCommand 的命令,一个非常描述性的名称。如下添加它

private readonly RoutedUICommand _cancelChangesCommand =
            new RoutedUICommand("CancelChanges", "CancelChanges", typeof(NumericUpDown));

正如您可能已经知道的,现在我们需要将命令绑定到其各自的回调并为其分配一个 InputBinding。如下修改 AttachCommands()

private void AttachCommands()
{
    CommandBindings.Add(new CommandBinding(_minorIncreaseValueCommand, (a, b) => IncreaseValue(true)));
    CommandBindings.Add(new CommandBinding(_minorDecreaseValueCommand, (a, b) => DecreaseValue(true)));
    CommandBindings.Add(new CommandBinding(_majorIncreaseValueCommand, (a, b) => IncreaseValue(false)));
    CommandBindings.Add(new CommandBinding(_majorDecreaseValueCommand, (a, b) => DecreaseValue(false)));
    CommandBindings.Add(new CommandBinding(_updateValueStringCommand, (a, b) =>
                                           {
                                               Value = ParseStringToDecimal(TextBox.Text);
                                           }));
    CommandBindings.Add(new CommandBinding(_cancelChangesCommand, (a, b) => CancelChanges()));

    CommandManager.RegisterClassInputBinding(typeof (TextBox),
                    new KeyBinding(_minorIncreaseValueCommand, new KeyGesture(Key.Up)));
    CommandManager.RegisterClassInputBinding(typeof (TextBox),
                    new KeyBinding(_minorDecreaseValueCommand, new KeyGesture(Key.Down)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox),
                    new KeyBinding(_majorIncreaseValueCommand, new KeyGesture(Key.PageUp)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox),
                    new KeyBinding(_majorDecreaseValueCommand, new KeyGesture(Key.PageDown)));
    CommandManager.RegisterClassInputBinding(typeof (TextBox),
                    new KeyBinding(_updateValueStringCommand, new KeyGesture(Key.Enter)));
    CommandManager.RegisterClassInputBinding(typeof(TextBox),
                    new KeyBinding(_cancelChangesCommand, new KeyGesture(Key.Escape)));
}

_cancelChangedCommand 调用 CancelChanges() - 一个非常简单的方法,如下所示

private void CancelChanges()
{
    TextBox.Undo();
}

这个方法非常原始,我想不需要解释。

不过,我们还需要做最后一件事。如下修改 OnValueChanged()

private static void OnValueChanged(DependencyObject element,
                                    DependencyPropertyChangedEventArgs e)
{
    var control = (NumericUpDown) element;

    control.TextBox.UndoLimit = 0;
    control.TextBox.UndoLimit = 1;
}

UndoLimit 设置为 0 会擦除 TextBox 保存的任何撤销信息。为什么要这样做?因为如果我们不这样做,您将能够撤销任何更改(甚至是已确认的更改),而这不是我们需要的。擦除撤销信息后,我们将限制恢复为 1。

这个功能当然不复杂,但我发现它非常有用。

结论

就是这样。现在我们应该有一个功能齐全的 NumericUpDown 控件可以使用了。 我希望您阅读这篇文章和我写这篇文章一样愉快(实际上,校对它可能是我今年做过的最无聊的事情)。

© . All rights reserved.