继承自 TextBox 的数字增减文本框






4.83/5 (26投票s)
描述如何创建继承自基控件的控件。
引言
Microsoft WPF 控件集合中一个显著的缺失就是数字增减控件。我相信几乎所有 WPF 开发者都和我在 2010 年 Visual Studio 发布时,微软没有将这个重要控件包含进去感到同样沮丧(2010 年 Visual Studio 只新增了四个控件)。我在网上看到了很多版本的数字增减(微调器)控件,但大多数都需要自定义才能获得与实现不同的外观和感觉。
背景
为了提供我期望在这种控件中看到的灵活性,它不能被创建为UserControl
或基本的ControlTemplate
。无论如何,要实现按钮和箭头键的功能,都需要大量的代码。我看到了一些继承自Control
的TextBox
控件的实现,但这以为着TextBox
的所有正常属性都将不可用。事实证明,继承自TextBox
实际上效果非常好。继承TextBox
所需的 XAML 如下:
<TextBox x:Class="CustomControls.NumericUpDownTextBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:CustomControls"/>
在重写TextBox
时,除了TextBox
控件本身,还需要两个Button
控件(或模拟 Button 功能的控件),以便用户可以通过鼠标按钮来增加或减少TextBox
中的值。继承TextBox
可以免费获得TextBox
的功能。只需要在代码中创建两个Button
控件。这在构造函数中完成。除了创建控件,我们还需要为基础TextBox
附加事件处理程序,负责处理向上和向下键的按下,并确保输入仅限于数字(如果第一个字符是负号,并且允许负值)。
/// <summary>
/// Constructor: initializes the TextBox, creates the buttons,
/// and attaches event handlers for the buttons and TextBox
/// </summary>
public NumericUpDownTextBox()
{
InitializeComponent();
var buttons = new ButtonsProperties(this);
ButtonsViewModel = buttons;
// Create buttons
upButton = new Button()
{
Cursor = Cursors.Arrow,
DataContext = buttons,
Tag = true
};
upButton.Click += upButton_Click;
downButton = new Button()
{
Cursor = Cursors.Arrow,
DataContext = buttons,
Tag = false
};
downButton.Click += downButton_Click;
// Create control collections
controls = new VisualCollection(this);
controls.Add(upButton);
controls.Add(downButton);
// Hook up text event handlers
this.PreviewTextInput += control_PreviewTextInput;
this.PreviewKeyDown += control_PreviewKeyDown;
this.LostFocus += control_LostFocus;
}
在上面的代码中,可以看到我使用了绑定来设置按钮的属性。我曾尝试直接设置属性,但每次按钮显示时都需要重新设置,因为Button
会丢失属性。使用绑定意味着Button
仅在需要时才获取属性。出于某种原因,Cursor
和Click
事件能够保持属性。另一个我遇到麻烦的事件是当我尝试使用Border
作为按钮时。
代码中最关键的部分是重写ArrangeOverride
方法。该方法负责在分配的空间中定位控件。在此方法中,如果Width
与Height
的比例小于某个值(我选择了 1.5),我将不绘制Button
。然后调用基础ArrangeOverride
,为基础TextBox
分配矩形,然后使用Arrange
方法为Button
分配每个按钮的矩形。
/// <summary>
/// Called to arrange and size the content of a
/// System.Windows.Controls.Control object.
/// </summary>
/// <param name="arrangeSize">The computed size that is used to
/// arrange the content</param>
/// <returns>The size of the control</returns>
protected override Size ArrangeOverride(Size arrangeSize)
{
double height = arrangeSize.Height;
double width = arrangeSize.Width;
showButtons = width > 1.5 * height;
if (showButtons)
{
double buttonWidth = 3 * height / 4;
Size buttonSize = new Size(buttonWidth, height / 2);
Size textBoxSize = new Size(width - buttonWidth, height);
double buttonsLeft = width - buttonWidth;
Rect upButtonRect = new Rect(new
Point(buttonsLeft, 0), buttonSize);
Rect downButtonRect = new Rect(new
Point(buttonsLeft, height / 2), buttonSize);
base.ArrangeOverride(textBoxSize);
upButton.Arrange(upButtonRect);
downButton.Arrange(downButtonRect);
return arrangeSize;
}
else
{
return base.ArrangeOverride(arrangeSize);
}
}
GetVisualChild
只需在索引参数小于基础GetVisualChild
时传递基础GetVisualChild
,否则传递按钮。
protected override Visual GetVisualChild(int index)
{
if (index < base.VisualChildrenCount)
return base.GetVisualChild(index);
return controls[index - base.VisualChildrenCount];
}
VisualChildrenCount
只需确定按钮是否显示,并传递基础值或基础值加二。
protected override int VisualChildrenCount
{
get
{
if (showButtons)
return controls.Count + base.VisualChildrenCount;
else
return base.VisualChildrenCount;
}
}
Using the Code
使用该控件就像使用任何其他自定义控件一样:需要将命名空间的引用包含为 XAML 根元素的属性,然后使用分配给引用的名称在 XAML 的元素中定义控件。在此元素内,可以使用属性或元素内部定义的元素来设置TextBox
的所有属性。还有一些自定义属性可以设置。控制按钮外观的属性都以“Button.”开头。如果未使用这些专门的属性,则会使用TextBox
的值或默认值。还有几个其他属性用于设置最小值(MinValue
)和最大值(MaxValue
),以及一个Value
属性,该属性与TextBox
的值作为整数进行交互。
<Window x:Class="WPFControlTest.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:CustomControls"
Title="MainWindow" Height="192" Width="281">
<Grid Background="SteelBlue">
<local:NumericUpDownTextBox x:Name="textBox2" Height="25" Margin="20"
FontStyle="Italic" FontSize="10" BorderBrush="Green"
BorderThickness="2" Background="LightGray" Foreground="DarkBlue"
MinValue="100" MaxValue="1001"
HorizontalAlignment="Stretch" VerticalAlignment="Center"
ButtonBackground="BlueViolet" ButtonBorderBrush="LightGreen"
ButtonForeground="Azure" ButtonBorderThickness="1,3,1,3"
ButtonMouseOverBackground="Aquamarine"
ButtonPressedBackground="Red"/>
</Grid>
</Window>
关注点
我尝试了很多不同的实现方式来让按钮继承TextBox
的属性。基本上,我希望Background
、Foreground
和Border
能从TextBox
继承。此外,我希望Button
的内容是一个多边形,代表向上或向下的箭头。我的第一个迭代只是使用了两个基本的按钮,这足以获得我对TextBox
所需的功能。不幸的是,当Button
的边框属性改变时,它的边框不会改变。这意味着我必须想出其他办法。
我的最初想法是改用代码创建的边框,然后附加鼠标事件来模拟按钮的MouseOver
和MouseDown
事件。不幸的是,这并没有奏效,因为事件没有被触发。
我想尽量减少此控件所需的 XAML 量。因为我查看过的其他选项没有提供我想要的外观和感觉,这意味着我必须创建一个ControlTemplate
作为TextBox
在 XAML 中的资源,用于Button
。
好吧,这确实奏效了,但方式与我想象的不同。我最初尝试在代码中创建Border
和Polygon
(用于箭头)并将其分配给Button
的内容。出于某种原因,我无法以编程方式设置TextBox
的内容(也许我错过了什么),所以我退回了一步。我最初将Border
放在模板中,然后在其中放置内容,但这仍然使我在代码中设置内容时遇到问题,所以我最终将箭头用的Polygon
定义在一个Border
中,该Border
又在ControlTemplate
中。然后,Border
可以继承Button
的边框Thickness
和Background
属性。这奏效了,除了我需要自定义Border
内部尺寸的Polygon
。
因此,为了获得箭头多边形,我需要访问Border
的属性来确定Polygon
的Point
的位置。我最初尝试使用一个派生自IValueConverter
接口的类创建Binding
,并将Border
作为Converter
参数传递。这不起作用,因为PropertyChanged
事件仅在属性更改时触发,而对于Border
,这仅在控件加载时发生,此时控件的大小仍然为零。这使得唯一的选择是使用IMultiValueConverter
,并将Border
的Height
、Width
和BorderThickness
属性包含在参数中。我还需要另一条信息来为每个按钮创建箭头:箭头的方向(向上或向下)。因此,还需要另一个属性,一个布尔值。我决定使用Button
的DataContext
将此信息传递给Polygon
。我也考虑过使用Tag
属性,但认为DataContext
是一种稍微容易理解的方法。最终的ControlTemplate
如下所示:
<ControlTemplate TargetType="{x:Type Button}">
<Border Name="buttonBorder"
BorderBrush="{Binding BorderBrush}"
BorderThickness="{Binding BorderThickness}"
Background="{Binding Background}"
CornerRadius="3">
<Polygon Fill="{Binding Foreground}" >
<Polygon.Points>
<MultiBinding Converter="{StaticResource ArrowCreater}" >
</MultiBinding>
</Polygon.Points>
</Polygon>
</Border>
<ControlTemplate.Triggers>
<!--<Trigger Property="IsFocused" Value="True">
</Trigger>
<Trigger Property="IsDefaulted" Value="True">
</Trigger>-->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="buttonBorder" Property="Background"
Value="{Binding IsMouseOverBackground}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="buttonBorder" Property="Background"
</Trigger>
<!--<Trigger Property="IsEnabled" Value="False">
</Trigger>-->
</ControlTemplate.Triggers>
</ControlTemplate>
关于控件模板的一个重要说明是,它包含在具有TargetType
为Button
的Style
中,TargetType
必须重新应用于ControlTemplate
,否则编译器将抱怨IsPressed
。
IMultiValueConverter
类只有一个我处理过的小细节,那就是检查Border
的Height
或Width
是否为 0,这在Border
实际布局之前是存在的。在这些情况下,我只是返回而不处理创建箭头的代码。
internal class ArrowCreater : IMultiValueConverter
{
public object Convert(object[] values, Type targetType,
object parameter, System.Globalization.CultureInfo culture)
{
double width = (double)values[0];
double height = (double)values[1];
if ((height == 0.0) || (width == 0.0)) return null;
Thickness borderThickness = (Thickness)values[2];
bool up = (bool)values[3];
double arrowHeight = height - borderThickness.Top -
borderThickness.Bottom;
double arrowWidth = width - borderThickness.Left -
borderThickness.Right;
return CreateArrow(arrowWidth, arrowHeight, up);
}
public object[] ConvertBack(object value, Type[] targetTypes,
object parameter, System.Globalization.CultureInfo culture)
{
throw new NotImplementedException();
}
private PointCollection CreateArrow(double width,
{
double margin = height * .2;
double pointY;
double baseY;
if (isUp)
{
pointY = margin;
baseY = height - margin;
}
else
{
baseY = margin;
pointY = height - margin;
}
var pts = new PointCollection();
pts.Add(new Point(margin, baseY));
pts.Add(new Point(width / 2, pointY));
pts.Add(new Point(width - margin, baseY));
return pts;
}
}
我还为按钮添加了可选属性。如果未设置这些属性,则在可用时使用TextBox
的属性(如Background
、Foreground
、BorderBrush
、BorderThickness
),或者使用默认值(CornerRadius
、按钮按下或鼠标悬停时的Background
)。我曾尝试使用一个单独的类来包含按钮属性,以便可以使用“Buttons.”加上属性名来设置它们,但 WPF 似乎不支持包含属性的属性,所以我最终只是在属性名前加上“Button.”前缀。
按钮有自己的DataContext
,我为这个DataContext
使用了一个特殊的类,它继承了InotifyPropertyChanged
接口。它有一个指向基础NumericUpDownTextBox
类的指针,并且该类中的属性只有属性 Get。每个 Get 要么返回按钮特定的属性,要么返回TextBox
的属性,要么返回默认值(如果按钮特定的属性未设置)。NumericUpDownTextBox
的按钮特定属性处理更改事件,并调用按钮DataContext
类的public NotifyPropertyChanged
方法来触发按钮DataContext
的PropertyChangedEventHandler
。这工作得非常干净。
用户输入
另一个重要的细节是处理用户输入的代码。用户可以通过三种方式更改值:键盘的数字键(和负号)、键盘的向上和向下箭头键,以及两个Button
控件。
在此代码中,用户无法使用键盘在TextBox
中输入非数字字符,负号(‘-’)除外,并且只有当允许的最小值小于零、输入光标位于TextBox
文本的开头,并且不存在负号时,才能输入负号。另外,如果用户输入的按键明显会超出Maximum
(如果Maximum
大于零)或Minimum
(如果Minimum
小于零),则值将被固定。用于检查TextBox
文本有效性的部分需要考虑插入符号位置和选定长度。为此,StringBuilder
控件可以非常方便地确定用户输入后TextBox
文本的值。PreviewTextInput
事件用于控制用户在TextBox
中的更改。
private void control_PreviewTextInput(object sender,
TextCompositionEventArgs e)
{
// Catch any non-numeric keys
if ("0123456789".IndexOf(e.Text) < 0)
{
// else check for negative sign
if (e.Text == "-" && MinValue < 0)
{
if (this.Text.Length == 0 || (this.CaretIndex == 0 &&
this.Text[0] != '-'))
{
e.Handled = false;
return;
}
}
e.Handled = true;
}
else // A digit has been pressed
{
// We now know that have good value: check for attempting
// to put number before '-'
if (this.Text.Length > 0 && this.CaretIndex == 0 &&
this.Text[0] == '-' && this.SelectionLength == 0)
{
// Can't put number before '-'
e.Handled = true;
}
else
{
// check for what new value will be:
StringBuilder sb = new StringBuilder(this.Text);
sb.Remove(this.CaretIndex, this.SelectionLength);
sb.Insert(this.CaretIndex, e.Text);
int newValue = int.Parse(sb.ToString());
// check if beyond allowed values
if (FixValueKeyPress(newValue))
{
e.Handled = false;
}
else
{
e.Handled = true;
}
}
}
}
FixValueKeyPress
方法会检查用户输入的最终值,并强制进行更正,将插入符号留在文本末尾。如果用户输入没有问题,TextBox
将保持不变。
通过PreviewKeyDown
事件捕获键盘向上和向下箭头键的按下。
/// <summary>
/// Checks if the keypress is the up or down key, and then
/// handles keyboard input
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void control_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Down)
{
HandleModifiers(-1);
e.Handled = true;
}
else if (e.Key == Key.Up)
{
HandleModifiers(1);
e.Handled = true;
// Space key is not caught by PreviewTextInput
else if (e.Key == Key.Space)
e.Handled = true;
}
else
e.Handled = false;
}
/// <summary>
/// Checks if any of the Keyboard modifier keys are pressed that might
/// affect the change in the value of the TextBox.
/// In this case only the shift key affects the value
/// </summary>
/// <param name="value">Integer value to modify</param>
private void HandleModifiers(int value)
{
if (Keyboard.Modifiers == ModifierKeys.Shift) value *= 10;
Add(value);
}
事件处理程序会检查事件参数中的Key
是否为向上或向下键,然后使用HandleModifiers
方法(这也可以与向上/向下按钮一起使用)。此方法会接收一个整数值,该值指示按下了向上还是向下箭头。此值(绝对值为 1)然后乘以常量的值(如果按下了修饰键),然后添加到TextBox
内容的值中。请注意,在预览中也要查找空格键,因为PreviewTextInput
不会捕获空格键。当按下空格键时,我们应该检查是否有任何文本被选中,并移除选中的文本,但这似乎不值得增加复杂性。
历史
- 2010/11/22:修复了
MaxValue
、MinValue
和Value
的更新问题。MaxValue
和MinValue
现在是动态应用的。在TextBox
中更改值并选项卡到下一个控件后,Value
现在是正确的。还为Value
、MinValue
和MaxValue
的测试窗体添加了TextBox
。 - 2010/12/7:源代码已更新
对代码进行了以下更改: - 将“
Value
”DependencyProperty
的类型更改为int?
而不是int
,并将其初始值设置为null
。这对于能够将控件的值初始化为“0
”是必需的。最初默认值为0
,这意味着“Value
”的值没有变化,因此文本不会更新。 - 当控件失去焦点时,它现在初始化为
0
,或者初始化为MinValue
或MaxValue
(如果0
不在这些值之间)。 - 经过多次尝试后,添加了重复按钮功能。发现可以捕获预览事件,但不能捕获其他
RoutedEvent
。使用System.Windows.Timer
来生成延迟和间隔,并在PreviewMouseUp
和PreviewMouseDown
事件上初始化和处理计时器。对于MouseDown
和MouseUp
,这并不重要,因为Button
已经是使用者了,但无法捕获MouseEnter
和MouseLeave
事件,并且这些事件没有预览。因此,我无法使用事件来确定鼠标是否在按钮上,而必须在每个计时器事件中检查鼠标位置来实现重复功能。如果您查看滚动条,您会发现滚动条仅在鼠标悬停在ScrollBar
上的MouseDown
事件之后滚动,并在鼠标离开时停止。 - 还在组织方面进行了一些其他更改。令人惊讶的是,代码行数几乎与之前一样。
- 2011/5/17:源代码已更新
- 此更新增加了对鼠标滚轮的支持,这要归功于 AndreyA。