如何将 WPF Textbox 扩展为自定义选择器






4.86/5 (21投票s)
本文将展示如何通过模板样式将标准WPF文本框扩展为选择器。

引言
本文旨在展示如何使用控件模板为文本框添加额外的行为,并将其外观转换为选择器。我们将为文本框控件定义一个新的基于选择器的样式,并根据所需的选择器类型应用它。这有助于我们轻松灵活地扩展现有设计,而无需进行太多更改。字段控件将显示为文本框,并根据文本框上的焦点或鼠标悬停显示选择器类型 - 这有助于保持现有控件的旧布局,并为新控件节省空间。
背景
在我正在开发的一个产品中,我们为文本框定义了格式,但对于给定的日期字段没有日期选择器功能。因此,今年,我们有了在WPF应用程序中为日期字段显示日历图标的需求。字段根据特定逻辑在运行时定义,我们动态绘制这些字段所需的控件。比方说,对于文本类型字段,我们有一个文本框绘制器,它将创建我们添加到视图中的具有定义尺寸的文本框。
在开发日期选择器功能时,我们发现实现中存在一些问题
- 文本框用于输入日期,因此当需要时,文本框绘制器会为日期字段创建文本框
- 根据业务逻辑,日期字段的格式通过运行时在文本框上应用的格式化程序实现
- 有许多屏幕(具有定义的布局)包含日期字段
现在,根据以上观察(是的,这是一个庞大的代码库!),我们讨论了实现日期选择器新功能的可能选项。由于该字段出现在多个屏幕上,很明显我们需要在一处/一次进行更改,以便在所有地方自动反映。我们本可以创建一个新的CalendarPainter之类的类来处理它,但我们希望将更改最小化。因此,我们决定为文本框添加一个新的行为来实现它 - 它比其他选项有相当多的优势
- 质量分析时间将仅限于验证新行为并对现有格式的维护进行冒烟测试
- 任何屏幕都不需要布局更改,现有控件将处理新的职责
- 避免在核心级别进行更改(通过创建日历绘制器),这让从开发人员到经理的所有人都充满信心
- 基于XAML的模板驱动更改,以及最少的后台代码更改,将变得简单、灵活且可维护
因此,我们通过模板驱动的附加行为扩展了现有的文本框。本文将分享此扩展实现。
使用代码
以下是我为了实现目标功能所遵循的各种步骤和方向。
探索标准文本框的内部XAML
我遇到的第一个障碍兼学习是文本框如何扩展。通过一些研究和阅读,我发现了一个标准文本框在XAML内部是如何呈现的。这为我们如何扩展它提供了线索。细节没有很好地记录,但通过网上分享的零散信息和一些快速实验,我弄清楚了如何使用文本框的内部控件模板。
一个名为PART_ContentHost
的ScrollViewer
控件对我来说是一个不错的发现,它构成了TextBox的主要部分。TextBox
使用它来允许在多行文本框的情况下使用垂直和水平滚动条。我发现命名约定是固定的,我们必须遵循它——看起来原始的内部模板期望这个名称,我们必须遵循它以保持正常工作。总的来说,TextBox
的默认呈现如下
<Style x:Key="CustomTextBoxStyle" TargetType="{x:Type TextBox}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TextBox}">
<Border BorderThickness="1" BorderBrush="Black">
<ScrollViewer x:Name="PART_ContentHost" />
<!-- -->
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
定义一个ToggleButton用于打开和关闭选择器下拉弹出窗口
为了拥有一个选择器,我们需要一个ToggleButton
来打开和关闭它。除了OnClick
,这个切换按钮将根据需要由各种事件触发,例如OnFocus
、OnMouseLeave
等。由于它将成为TextBox控件模板的一部分,我们将其内容定义为一个图像,该图像将用作用户切换的视觉呈现。
以下是ToggleButton
的默认呈现,我们将在自定义文本框模板中使用它
<ControlTemplate x:Key="IconButton" TargetType="{x:Type ToggleButton}">
<Border>
<ContentPresenter />
</Border>
</ControlTemplate>
<ToggleButton Template="{StaticResource IconButton}"
MaxHeight="21"
Margin="-1,0,0,0"
Name="PopUpImageButton"
Focusable="False"
IsChecked="False">
<Image Source="Images\Expand_Collapse_Icon.png" Stretch="None" Visibility="Hidden" HorizontalAlignment="Right" Name="imgPicker" >
</Image>
</ToggleButton>
定义切换按钮的触发器
正如我之前提到的,ToggleButton
可以有各种Triggers
。默认情况下,对于选择器行为,我们大多数人更喜欢将MouseOver
和Focus
事件定义为Triggers
。
在我们的示例中,就在ToggleButton
呈现器上方,我们默认将图像的Visibility
设置为Hidden。现在,通过各种Trigger
,我们将使ToggleButton
可见——这保持了UI/视图的整洁外观和感觉,并且只在用户触发定义的事件时显示额外/附加行为。这为我们节省了空间问题,允许控件在与普通文本框相同的空间中安装。
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="true">
<Setter Property="Visibility" TargetName="imgPicker" Value="Visible" />
</Trigger>
<Trigger Property="IsFocused" Value="true">
<Setter Property="Visibility" TargetName="imgPicker" Value="Visible" />
</Trigger>
</ControlTemplate.Triggers>
日历弹出窗口
以下是用于日期选择器的选择器弹出窗口示例设计。使用了标准WPF日历控件。日历控件的SelectedDate
和DisplayDate
通过CalendarConverter
(示例在本文的后半部分详细介绍)与主文本框绑定。在这里,SelectedDatesChanged
事件已作为附加Trigger
添加,用于切换ToggleButton
。
<Calendar Margin="0,-1,0,0" x:Name="CalDisplay"
SelectedDate="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=Text, Mode=TwoWay, Converter={StaticResource calendarConverter}}"
Focusable="False"
DisplayDate="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=Text, Mode=OneWay, Converter={StaticResource calendarConverter}}" >
<Control.Triggers>
<EventTrigger RoutedEvent="Calendar.SelectedDatesChanged">
<BeginStoryboard>
<Storyboard>
<BooleanAnimationUsingKeyFrames Storyboard.TargetName="PopUpImageButton" Storyboard.TargetProperty="IsChecked">
<DiscreteBooleanKeyFrame KeyTime="00:00:00" Value="False"></DiscreteBooleanKeyFrame>
</BooleanAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Control.Triggers>
</Calendar>
计算器弹出窗口
以下是用于计算器选择器的选择器弹出窗口示例设计。使用了基于WPF Toolkit的计算器控件(工具包程序集已附在示例下载中)。计算器控件的DisplayText
通过DoubleStringConverter
与主文本框绑定。这里,LostFocus
事件已作为附加Trigger
添加,用于切换ToggleButton
。
<extToolkit:Calculator Margin="0,0,0,0" x:Name="CalDisplay"
DisplayText="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=Text, Mode=TwoWay, Converter={StaticResource doubleStringConverter}}"
Focusable="False" Precision="2">
<Control.Triggers>
<EventTrigger RoutedEvent="extToolkit:Calculator.LostFocus">
<BeginStoryboard>
<Storyboard>
<BooleanAnimationUsingKeyFrames Storyboard.TargetName="PopUpImageButton" Storyboard.TargetProperty="IsChecked">
<DiscreteBooleanKeyFrame KeyTime="00:00:00" Value="False"></DiscreteBooleanKeyFrame>
</BooleanAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="extToolkit:Calculator.MouseLeave">
<BeginStoryboard>
<Storyboard>
<BooleanAnimationUsingKeyFrames Storyboard.TargetName="PopUpImageButton" Storyboard.TargetProperty="IsChecked">
<DiscreteBooleanKeyFrame KeyTime="00:00:00" Value="False"></DiscreteBooleanKeyFrame>
</BooleanAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Control.Triggers>
</extToolkit:Calculator>
列表视图弹出窗口
以下是用于列表选择器的选择器弹出窗口示例设计。使用了标准WPF ListView控件。ListView控件的SelectedItem通过ListboxConverter
与主文本框绑定。这里,视图中添加了一个额外的关闭按钮。此按钮的Click
事件已作为附加Trigger
添加,用于切换ToggleButton
。
<Grid>
<ListView Name="ListView1" HorizontalAlignment="Left" VerticalAlignment="Top" Width="170" Height="85"
SelectedItem="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=Text, Mode=TwoWay, Converter={StaticResource listboxConveter}}">
<ListViewItem Content="One"></ListViewItem>
<ListViewItem Content="Two"></ListViewItem>
<ListViewItem Content="Three"></ListViewItem>
<ListViewItem Content="Four"></ListViewItem>
<ListViewItem Content="Five"></ListViewItem>
</ListView>
<Button Width="20" Height="20" HorizontalAlignment="Right" VerticalAlignment="Bottom" Name="btn">X
<Control.Triggers>
<EventTrigger RoutedEvent="Button.Click">
<BeginStoryboard>
<Storyboard>
<BooleanAnimationUsingKeyFrames Storyboard.TargetName="PopUpImageButton" Storyboard.TargetProperty="IsChecked">
<DiscreteBooleanKeyFrame KeyTime="00:00:00" Value="False"></DiscreteBooleanKeyFrame>
</BooleanAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Control.Triggers>
</Button>
</Grid>
定义日期、字符串等的转换器
根据我们的需求,我们可以使用现有的标准WPF转换器,也可以拥有我们自己的自定义转换器。转换器是帮助绑定两种不兼容类型的属性的类——它们将值从源转换为目标,然后再转换回来。这些类被称为ValueConverter
,它实现了一个简单的接口IValueConverter
,带有两个方法object Convert(object value, Type targetType, object parameter, CultureInfo culture)
和object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
。
要在 XAML 中使用转换器,我们将其实例添加到资源中,并使用 Key 进行引用。以下是其中一个示例转换器代码片段:CalendarConverter
/// <summary>
/// Value converter to convert a datetime object to the specified string format.
/// If the format is not specified, it will be converted to short date string "12/31/2011"
/// </summary>
[ValueConversion(typeof(DateTime), typeof(string))]
public class CalendarConverter : IValueConverter
{
#region IValueConverter Members
/// <summary>
/// Implement the ConvertBack method of IValueConverter. Converts DateTime object to specified format
/// </summary>
/// <param name="value">The DateTime value we're converting
/// <param name="targetType">Not used
/// <param name="parameter">String format to convert to (optional)
/// <param name="culture">Not used
/// <returns>Collapsed if value is true, else Visible</returns>
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
DateTime date = (DateTime)value;
string param = parameter as string;
return string.IsNullOrEmpty(param) ? date.ToShortDateString() : date.ToString(param);
}
/// <summary>
/// Implement the Convert method of IValueConverter. Converts a string representation of a date to DateTime
/// </summary>
/// <param name="value">The visibility value to convert to a boolean.
/// <param name="targetType">Not used
/// <param name="parameter">Not used
/// <param name="culture">Not used
/// <returns>false if Visible, else true</returns>
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
string dateString = (string)value;
DateTime resultDateTime;
if (DateTime.TryParse(dateString, out resultDateTime))
return resultDateTime;
return DateTime.Now;
}
#endregion
}
应用新行为
现在,一旦我们有了包含所有绑定和触发器的XAML样式,我们所要做的就是简单地在运行时应用新的控件样式
TextBox tb = new TextBox();
// Calendar DatePicker
Style sCalendar = (Style)tb.TryFindResource("tbCalendarStyle");
if (sCalendar != null)
textBox1.Style = sCalendar;
// Calculator NumberPicker
Style sCalculator = (Style)tb.TryFindResource("tbCalculatorStyle");
if (sCalculator != null)
textBox2.Style = sCalculator;
// List StringPicker
Style sList = (Style)tb.TryFindResource("tbListStyle");
if (sList != null)
textBox3.Style = sList;
控件模板中定义的新行为将应用于文本框,现在可以将其用作选择器。
目前,文本框标签旁指定的格式(例如“mm/dd/yyyy”、“$”)仅用于指示文本框中预期的数据类型。目前,如果有人尝试输入其他内容,它们不会在文本框上强制执行。本文旨在展示如何将文本框扩展为选择器,而不是展示可以直接插入项目中的控件。
根据项目结构和便捷性,我们也可以使用DataTemplate
和DataTemplateSelector
实现相同的功能。通过它们,就不需要像这里一样显式应用样式了。
关注点
在进行这项功能增强时,我了解了标准控件的内部工作原理。起初对我来说并不直接,但最终得到预期的结果证明了时间的投入是值得的。将这项功能实现在一个将帮助许多客户的产品中是令人满意的。
历史
版本2:2012年11月25日(小幅更新)[感谢Pete O'Hanlon]
版本1:2012年11月1日(小幅更新)[感谢Paulo Zemek]