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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (26投票s)

2010年11月3日

CPOL

11分钟阅读

viewsIcon

119428

downloadIcon

2987

描述如何创建继承自基控件的控件。

引言

Microsoft WPF 控件集合中一个显著的缺失就是数字增减控件。我相信几乎所有 WPF 开发者都和我在 2010 年 Visual Studio 发布时,微软没有将这个重要控件包含进去感到同样沮丧(2010 年 Visual Studio 只新增了四个控件)。我在网上看到了很多版本的数字增减(微调器)控件,但大多数都需要自定义才能获得与实现不同的外观和感觉。

背景

为了提供我期望在这种控件中看到的灵活性,它不能被创建为UserControl或基本的ControlTemplate。无论如何,要实现按钮和箭头键的功能,都需要大量的代码。我看到了一些继承自ControlTextBox控件的实现,但这以为着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仅在需要时才获取属性。出于某种原因,CursorClick事件能够保持属性。另一个我遇到麻烦的事件是当我尝试使用Border作为按钮时。

代码中最关键的部分是重写ArrangeOverride方法。该方法负责在分配的空间中定位控件。在此方法中,如果WidthHeight的比例小于某个值(我选择了 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的属性。基本上,我希望BackgroundForegroundBorder能从TextBox继承。此外,我希望Button的内容是一个多边形,代表向上或向下的箭头。我的第一个迭代只是使用了两个基本的按钮,这足以获得我对TextBox所需的功能。不幸的是,当Button的边框属性改变时,它的边框不会改变。这意味着我必须想出其他办法。

我的最初想法是改用代码创建的边框,然后附加鼠标事件来模拟按钮的MouseOverMouseDown事件。不幸的是,这并没有奏效,因为事件没有被触发。

我想尽量减少此控件所需的 XAML 量。因为我查看过的其他选项没有提供我想要的外观和感觉,这意味着我必须创建一个ControlTemplate作为TextBox在 XAML 中的资源,用于Button

好吧,这确实奏效了,但方式与我想象的不同。我最初尝试在代码中创建BorderPolygon(用于箭头)并将其分配给Button的内容。出于某种原因,我无法以编程方式设置TextBox的内容(也许我错过了什么),所以我退回了一步。我最初将Border放在模板中,然后在其中放置内容,但这仍然使我在代码中设置内容时遇到问题,所以我最终将箭头用的Polygon定义在一个Border中,该Border又在ControlTemplate中。然后,Border可以继承Button的边框ThicknessBackground属性。这奏效了,除了我需要自定义Border内部尺寸的Polygon

因此,为了获得箭头多边形,我需要访问Border的属性来确定PolygonPoint的位置。我最初尝试使用一个派生自IValueConverter接口的类创建Binding,并将Border作为Converter参数传递。这不起作用,因为PropertyChanged事件仅在属性更改时触发,而对于Border,这仅在控件加载时发生,此时控件的大小仍然为零。这使得唯一的选择是使用IMultiValueConverter,并将BorderHeightWidthBorderThickness属性包含在参数中。我还需要另一条信息来为每个按钮创建箭头:箭头的方向(向上或向下)。因此,还需要另一个属性,一个布尔值。我决定使用ButtonDataContext将此信息传递给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>

关于控件模板的一个重要说明是,它包含在具有TargetTypeButtonStyle中,TargetType必须重新应用于ControlTemplate,否则编译器将抱怨IsPressed

IMultiValueConverter类只有一个我处理过的小细节,那就是检查BorderHeightWidth是否为 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的属性(如BackgroundForegroundBorderBrushBorderThickness),或者使用默认值(CornerRadius、按钮按下或鼠标悬停时的Background)。我曾尝试使用一个单独的类来包含按钮属性,以便可以使用“Buttons.”加上属性名来设置它们,但 WPF 似乎不支持包含属性的属性,所以我最终只是在属性名前加上“Button.”前缀。

按钮有自己的DataContext,我为这个DataContext使用了一个特殊的类,它继承了InotifyPropertyChanged接口。它有一个指向基础NumericUpDownTextBox类的指针,并且该类中的属性只有属性 Get。每个 Get 要么返回按钮特定的属性,要么返回TextBox的属性,要么返回默认值(如果按钮特定的属性未设置)。NumericUpDownTextBox的按钮特定属性处理更改事件,并调用按钮DataContext类的public NotifyPropertyChanged方法来触发按钮DataContextPropertyChangedEventHandler。这工作得非常干净。

用户输入

另一个重要的细节是处理用户输入的代码。用户可以通过三种方式更改值:键盘的数字键(和负号)、键盘的向上和向下箭头键,以及两个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:修复了MaxValueMinValueValue的更新问题。MaxValueMinValue现在是动态应用的。在TextBox中更改值并选项卡到下一个控件后,Value现在是正确的。还为ValueMinValueMaxValue的测试窗体添加了TextBox
  • 2010/12/7:源代码已更新
    对代码进行了以下更改:
    1. 将“ValueDependencyProperty的类型更改为int?而不是int,并将其初始值设置为null。这对于能够将控件的值初始化为“0”是必需的。最初默认值为0,这意味着“Value”的值没有变化,因此文本不会更新。
    2. 当控件失去焦点时,它现在初始化为0,或者初始化为MinValueMaxValue(如果0不在这些值之间)。
    3. 经过多次尝试后,添加了重复按钮功能。发现可以捕获预览事件,但不能捕获其他RoutedEvent。使用System.Windows.Timer来生成延迟和间隔,并在PreviewMouseUpPreviewMouseDown事件上初始化和处理计时器。对于MouseDownMouseUp,这并不重要,因为Button已经是使用者了,但无法捕获MouseEnterMouseLeave事件,并且这些事件没有预览。因此,我无法使用事件来确定鼠标是否在按钮上,而必须在每个计时器事件中检查鼠标位置来实现重复功能。如果您查看滚动条,您会发现滚动条仅在鼠标悬停在ScrollBar上的MouseDown事件之后滚动,并在鼠标离开时停止。
    4. 还在组织方面进行了一些其他更改。令人惊讶的是,代码行数几乎与之前一样。
  • 2011/5/17:源代码已更新
    1. 此更新增加了对鼠标滚轮的支持,这要归功于 AndreyA。
© . All rights reserved.