创建可为空的 WPF ComboBox






4.90/5 (7投票s)
本文展示了如何通过继承标准 WPF ComboBox 来提供自定义功能。
引言
试想一下,我们有一个标准的 WPF ComboBox
,其中包含一个客户列表,允许用户选择一个客户。选择客户不是强制性的,因此可以将其留空,并且仍然可以通过用户界面可能强制执行的任何验证。所以,用户会选择一个客户,然后稍后决定最初应该将该字段留空。
问题是:我们如何将 ComboBox
重置为其初始状态?
背景
最近,我接到一项任务,将一组继承的 Winforms 控件转换为其 WPF 对应项。这些控件可以包含我们应用程序中使用的标准快捷方式以及行为和外观等自定义设置。
例如,我们有一个继承的 TextBox
(TextBoxEx
),它确保当用户单击 TextBox
内时文本会被选中。
我逐步处理了各种控件,但当我处理到 ComboBox
时,我需要仔细研究。该控件的要求是它应该显示一个代表“无值”的行。这样,用户可以通过选择“无值”行来选择撤销先前的选择。
这一切对我来说都很有意义,但如何在 WPF 中实现呢?
我开始在线搜索解决方案,很快发现通用的方法是在源列表中插入一个代表 NULL
值的虚拟对象。
当然,有各种各样的实现,从值转换器到使用 CompositeCollection
,但基本思想保持不变。
为了取悦用户界面而修改底层列表似乎是个坏主意,所以我开始寻找替代方案。
现在的问题变成了
是否可以重新设计 ComboBox
的样式,使其能够拥有一个可选择的 NULL
项?
重新设计 WPF ComboBox 的样式
我们很清楚需要了解 ComboBox
的视觉树外观,为此,我们需要查看 ComboBox
的默认模板。
获取默认模板可以通过使用 Expression Blend 来完成,也可以直接从此处下载所有标准控件的所有默认模板。
为了方便起见,我在演示项目中包含了一个这些模板的副本。
无论如何,我们首先创建一个 ComboBox
的子类,称之为 ComboBoxEx
,并为其应用一个从下载的 Aero.NormalColor
主题中获取的默认样式。
拥有员工列表的 listbox
现在看起来像这样
我们需要在这里做的是在列表的第一个项之前放置一些内容。
需要注意的是,ComboBox
中显示的每个对象都包装在 ComboBoxItem
中,而 ComboBoxItem
又有自己的控件模板。
ComboBox
的下拉部分包含一个 ScrollViewer
,如下所示
<ScrollViewer Name="DropDownScrollViewer">
<Grid RenderOptions.ClearTypeHint="Enabled">
<Canvas Height="0" Width="0" HorizontalAlignment="Left" VerticalAlignment="Top">
<Rectangle
Name="OpaqueRect"
Height="{Binding ElementName=DropDownBorder,Path=ActualHeight}"
Width="{Binding ElementName=DropDownBorder,Path=ActualWidth}"
Fill="{Binding ElementName=DropDownBorder,Path=Background}" />
</Canvas>
<ItemsPresenter Name="ItemsPresenter"
KeyboardNavigation.DirectionalNavigation="Contained"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
</Grid>
</ScrollViewer>
我们需要在 ItemsPresenter
之上堆叠一些内容,我们可以使用 StackPanel
来做到这一点。
第一行(第 0 行)是我们放置代表 NULL
值的内容的地方。问题是那里的内容应该是什么?嗯,既然其他所有内容都包装在 ComboBoxItem
中,也许我们应该也从那里开始。像这样
<ScrollViewer CanContentScroll="False" Name="DropDownScrollViewer">
<Grid RenderOptions.ClearTypeHint="Enabled">
<Canvas Height="0" Width="0" HorizontalAlignment="Left" VerticalAlignment="Top">
<Rectangle
Name="OpaqueRect"
Height="{Binding ElementName=DropDownBorder,Path=ActualHeight}"
Width="{Binding ElementName=DropDownBorder,Path=ActualWidth}"
Fill="{Binding ElementName=DropDownBorder,Path=Background}" />
</Canvas>
<StackPanel>
<ComboBoxItem Content="This is a null value"></ComboBoxItem>
<ItemsPresenter Name="ItemsPresenter"
KeyboardNavigation.DirectionalNavigation="Contained"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
</StackPanel>
</Grid>
</ScrollViewer>
这将导致一个看起来像这样的 ComboBox

现在,这看起来正是我们想要的。
接下来我们需要处理的是高亮显示。虽然所有员工都按预期高亮显示,但我们发现将鼠标悬停在 NULL
项上不会执行任何操作。
所以我们需要这样做
当鼠标进入代表 NULL
值的 ComboBoxItem
时,我们需要从当前高亮显示的任何项中删除高亮。虽然这似乎是一项微不足道的任务,但鉴于 ComboBoxItem.IsHighlighted
属性定义为只读,这实际上并非易事。
好吧,让我们继续解决这个问题。
public class ComboBoxItemEx : ComboBoxItem
{
/// <summary>
/// Gets or sets a <see cref="bool"/> value that indicates if this item is highlighted.
/// </summary>
public new bool IsHighlighted
{
get { return base.IsHighlighted; }
set { base.IsHighlighted = value; }
}
}
那么,我们如何确保使用这个类而不是 ComboBoxItem
类作为项容器呢?
ComboBox
继承自 ItemsControl
,其中包含一个为此目的而设计的方法。
我们将此代码添加到我们的新 ComboBoxEx
类中。
protected override DependencyObject GetContainerForItemOverride()
{
var comboBoxItem = new ComboBoxItemEx();
RegisterEventHandlerForWhenIsHighlightedChanges(comboBoxItem);
return comboBoxItem;
}
我们只需创建我们的 ComboBoxItemEx
实例,并将其替换 ComboBoxItem
实例返回。
此外,我们还有机会挂钩项高亮显示时。这本身就是一个很好的特性,所以让我们继续创建一个依赖属性。这允许其他控件绑定到此属性以支持高亮显示项的实时预览。
private static readonly DependencyPropertyKey HighlightedItemPropertyKey =
DependencyProperty.RegisterReadOnly("HighlightedItemProperty",
typeof(object), typeof(ComboBox),
new FrameworkPropertyMetadata(null));
/// <summary>
/// Identifies the <see cref="HighlightedItem"/> dependency property.
/// </summary>
public static readonly DependencyProperty HighlightedItemProperty =
HighlightedItemPropertyKey.DependencyProperty;
/// <summary>
/// Gets a <see cref="bool"/> value that indicates if the null item is highlighted.
/// </summary>
[Browsable(false)]
public object HighlightedItem
{
get { return GetValue(HighlightedItemProperty); }
private set { SetValue(HighlightedItemPropertyKey, value); }
}
现在,我们需要确保当鼠标进入 NULL
项时,我们必须从任何其他高亮显示的项中删除高亮,同时确保 NULL
项被高亮显示。
由于我们必须挂钩 NULL ComboBoxItem
的鼠标事件,我们需要给它一个名称,以便我们可以在代码隐藏中获取它的引用。
<local:ComboBoxItemEx
x:Name="PART_NullValue"
Style="{StaticResource ResourceKey=ComboBoxNullItem}"
Content="This is a null value" >
</local:ComboBoxItemEx>
从代码中,我们可以看到我们还设置了 Style
属性为一个仅适用于代表 NULL
值的 ComboBoxItems
的自定义样式。目前,该样式只是默认 ComboBoxItem
样式的副本,但这将来可能会派上用场,如果我们想修改用于可视化 NULL
项的 ContentTemplate
。
请记住,修改 ComboBox
的 ItemsTemplate
不会影响 NULL
项的 ContentTemplate
,因为它不是由 ItemsPresenter
呈现的。稍后将详细介绍。
现在我们有了它的名称,我们也可以从代码隐藏中获取它的引用。
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
GetComboBoxNullItemFromTemplate();
RegisterEventHandlersForComboBoxNullItem();
}
private void RegisterEventHandlersForComboBoxNullItem()
{
_comboBoxNullItem.AddHandler(MouseEnterEvent,
new MouseEventHandler((o, e) => OnComboBoxNullItemMouseEnter()),
handledEventsToo: true);
}
private void GetComboBoxNullItemFromTemplate()
{
_comboBoxNullItem = GetTemplateChild("PART_NullValue") as ComboBoxItemEx;
}
private void OnComboBoxNullItemMouseEnter()
{
RemoveHighlightFromCurrentlyHighlightedItem();
HighlightNullItem();
}
此代码负责高亮显示 NULL
项,并从当前高亮显示的项中删除高亮。如果高亮显示的是另一个项,我们只需要从 NULL
项中删除高亮。
private void OnComboBoxItemHighlighted(ComboBoxItemEx comboBoxItem)
{
HighlightedItem = comboBoxItem.DataContext;
RemoveHighlightFromComboBoxNullItem();
}
现在如果我们启动这个东西,我们可以看到一切都按预期高亮显示。

下一个挑战是键盘处理。如果我们尝试使用箭头键在项之间导航,我们很快就会发现无法导航到 NULL
项或从中导航。
逻辑应该是这样的
- 如果
NULL
项当前被高亮显示,并且我们按下向下箭头键,我们应该高亮显示列表中的第一个项。 - 如果列表中的第一个项被高亮显示,并且我们按下向上箭头键,我们应该高亮显示
NULL
项。
private void OnScrollViewerKeyDown(KeyEventArgs keyEventArgs)
{
if (ArrowKeyDownWasPressed(keyEventArgs))
HandleScrollViewerArrowKeyDown(keyEventArgs);
if (ArrowKeyUpWasPressed(keyEventArgs))
HandleScrollViewerArrowKeyUp(keyEventArgs);
}
private void HandleScrollViewerArrowKeyDown(KeyEventArgs keyEventArgs)
{
if (IsComboBoxNullItemHighlighted && HasItems)
{
RemoveHighlightFromComboBoxNullItem();
HighlightTheFirstComboBoxItem();
IndicateThatTheKeyEventHasBeenHandled(keyEventArgs);
}
}
private void HandleScrollViewerArrowKeyUp(KeyEventArgs keyEventArgs)
{
if (IsFirstComboBoxItemIsHighLighted)
{
RemoveHighlightFromCurrentlyHighlightedItem();
HighlightNullItem();
IndicateThatTheKeyEventHasBeenHandled(keyEventArgs);
}
}
这对于向上和向下箭头键来说差不多了,但还有更多的键盘处理需要处理。
如果 ComboBox
关闭,我们可以使用向上/向下/向左/向右箭头键在项之间导航。NULL
项目前被忽略,所以我们也需要修复这个问题。
protected override void OnKeyDown(KeyEventArgs keyEventArgs)
{
if (!IsDropDownOpen)
{
if (ArrowKeyDownWasPressed(keyEventArgs) || ArrowKeyRightWasPressed(keyEventArgs))
HandleArrowKeyDownOrRight(keyEventArgs);
if (ArrowKeyUpWasPressed(keyEventArgs) || ArrowKeyLeftWasPressed(keyEventArgs))
HandleArrowKeyUpOrLeft(keyEventArgs);
}
if (!keyEventArgs.Handled)
base.OnKeyDown(keyEventArgs);
}
private void HandleArrowKeyUpOrLeft(KeyEventArgs keyEventArgs)
{
if (IsFirstItemSelected)
{
ClearSelectedItem();
}
}
private void HandleArrowKeyDownOrRight(KeyEventArgs keyEventArgs)
{
if (IsNothingSelected && HasItems)
{
SelectFirstItem();
IndicateThatTheKeyEventHasBeenHandled(keyEventArgs);
}
}
这应该可以处理大多数必需的键盘处理,我们就可以进行下一项任务了。
NULL 项的视觉外观
如前所述,我们可能应用于 ComboBox
的 ItemsTemplate
不会影响 NULL
项在下拉列表中如何被视觉化。这很有意义,因为这样的模板(DataTemplate
)很可能包含对底层对象的绑定。由于值为 NULL
,我们也无法绑定它。
ComboBoxItem
模板内的 ContentPresenter
已经知道如何显示 string
,这就是为什么我们看到“The value is null
”文本。
首先,“The value is null
”是硬编码在模板本身中的,所以我们需要解决这个问题。
另一件事是,能够自定义 NULL
项可能是一个很好的特性,因为它可能以某种方式呈现。这样,我们可以显示任何我们想要可视化 NULL
项的内容,例如位图。
由于 NULL
项的表示在大多数情况下很可能是 string
,我们在 ComboBoxEx
类中添加了一个新的依赖属性,让开发者指定这一点。
/// <summary>
/// Identifies the <see cref="NullValueText"/> dependency property.
/// </summary>
public static readonly DependencyProperty NullValueTextProperty =
DependencyProperty.Register("NullValueText", typeof (string), typeof (ComboBoxEx),
new FrameworkPropertyMetadata("None"));
/// <summary>
/// Gets or sets the text that is used to represent a null value
/// in the dropdown portion of the combobox.
/// This is a dependency property.
/// </summary>
[Category("Common")]
public string NullValueText
{
get { return (string)GetValue(NullValueTextProperty); }
set { SetValue(NullValueTextProperty, value); }
}
/// <summary>
/// Identifies the <see cref="SelectionBoxNullValueText"/> dependency property.
/// </summary>
public static readonly DependencyProperty SelectionBoxNullValueTextProperty =
DependencyProperty.Register("SelectionBoxNullValueText",
typeof(string), typeof(ComboBoxEx),
new FrameworkPropertyMetadata("The value is null"));
/// <summary>
/// Gets or sets the text that is used to represent
/// a null value in selectionbox of the combobox.
/// This is a dependency property.
/// </summary>
[Category("Common")]
public string SelectionBoxNullValueText
{
get { return (string)GetValue(SelectionBoxNullValueTextProperty); }
set { SetValue(SelectionBoxNullValueTextProperty, value); }
}
除了指定下拉列表中用于标识 null
值的文本的 NullValueText
之外,我们还添加了一个 SelectionBoxNullValueText
属性,让我们指定当值为 null
时应在选择框中显示的内容。例如,“选择一个员工”。
让我们先定义一个 NullItemTemplate
属性,它允许自定义下拉列表中的 NULL
项。
/// <summary>
/// Identifies the <see cref="NullItemTemplate"/> dependency property.
/// </summary>
public static readonly DependencyProperty NullItemTemplateProperty =
DependencyProperty.Register("NullItemTemplate",
typeof (DataTemplate), typeof (ComboBoxEx));
/// <summary>
/// Gets or sets the <see cref="DataTemplate"/> that is used to
/// visualize a null value in the dropdown.
/// This is a dependency property.
/// </summary>
[Category("Common")]
public DataTemplate NullItemTemplate
{
get { return (DataTemplate)GetValue(NullItemTemplateProperty); }
set { SetValue(NullItemTemplateProperty, value); }
}
接下来,我们需要修改控件模板,以便使用此模板。
<local:ComboBoxItemEx
x:Name="PART_NullValue"
Style="{StaticResource ResourceKey=ComboBoxNullItem}"
Content="{TemplateBinding NullValueText}"
ContentTemplate="{TemplateBinding NullItemTemplate}">
</local:ComboBoxItemEx>
让我们通过在演示项目中应用自定义模板来尝试一下
<ExtendedControls:ComboBoxEx.NullItemTemplate>
<DataTemplate>
<Image Source="/System.Windows.ExtendedControls.Demo;
component/NoUser.png"></Image>
</DataTemplate>
</ExtendedControls:ComboBoxEx.NullItemTemplate>
正如我们在下面看到的,NULL
值现在由图像表示。
这应该可以涵盖下拉列表中的大多数场景,但选择框呢?
很多人都在问如何为选择框应用自定义模板。答案是,我们不能。至少不是以一种直接的方式。
出于某种奇怪的原因,WPF 团队将 SelectionBoxItemTemplate
设置为只读,它总是使用 ItemsTemplate
中定义的模板。这不一定总是理想的行为。
但由于我们在这里处理的是 ComboBox
的一个完整的控件模板,我们可以解决这个限制。
例如,我们应该能够使用斜体字体来显示员工。
由于我们无法覆盖元数据使只读依赖属性可写,我们必须创建一个类似的属性。让我们将属性命名为 SelectionBoxTemplate
。
/// <summary>
/// Identifies the <see cref="SelectionBoxTemplate"/> dependency property.
/// </summary>
public static readonly DependencyProperty SelectionBoxTemplateProperty =
DependencyProperty.Register("SelectionBoxTemplate",
typeof(DataTemplate), typeof(ComboBoxEx));
/// <summary>
/// Gets or sets the <see cref="DataTemplate"/> that is used to
/// visualize a item in the selection box
/// This is a dependency property.
/// </summary>
[Category("Common")]
public DataTemplate SelectionBoxTemplate
{
get { return (DataTemplate)GetValue(SelectionBoxTemplateProperty); }
set { SetValue(SelectionBoxTemplateProperty, value); }
}
现在我们需要确保,如果此模板为 null
,我们就回退到 SelectionBoxItemTemplate
。
这是通过对控件模板使用触发器来实现的。
<Trigger Property="SelectionBoxTemplate" Value="{x:Null}">
<Setter TargetName="selectionBoxContentPresenter"
Property="ContentTemplate"
Value="{Binding RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType={x:Type local:ComboBoxEx}},Path=SelectionBoxItemTemplate}">
</Setter>
</Trigger>
为了实现这一点,我们必须给 ContentPresenter
一个名称,并将 ContentTemplate
默认设置为我们新的 SelectionBoxTemplate
属性。
<ContentPresenter x:Name="selectionBoxContentPresenter" IsHitTestVisible="false"
Margin="{TemplateBinding Padding}"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}">
</ContentPresenter>
现在我们可以继续创建一个像这样的选择框自定义模板
<ExtendedControls:ComboBoxEx.SelectionBoxTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock FontStyle="Italic" Text="{Binding FirstName}"></TextBlock>
<TextBlock Margin="5,0,0,0" FontStyle="Italic"
Text="{Binding LastName}"></TextBlock>
</StackPanel>
</DataTemplate>
</ExtendedControls:ComboBoxEx.SelectionBoxTemplate>
下图显示了此自定义的结果
事情开始就绪了。接下来是如何可视化选择框中的 NULL
项。
我认为我们应该尽量使其简单,同时提供适当的灵活性。
如前所述,能够自定义 NULL
项在选择框中的表示方式将会很好。
让我们创建一个 DataTemplate
属性和一个 SelectionBoxNullValueText
属性
/// <summary>
/// Identifies the <see cref="SelectionBoxNullItemTemplate"/> dependency property.
/// </summary>
public static readonly DependencyProperty SelectionBoxNullItemTemplateProperty =
DependencyProperty.Register
("SelectionBoxNullItemTemplate", typeof(DataTemplate), typeof(ComboBoxEx));
/// <summary>
/// Gets or sets the <see cref="DataTemplate"/> that is used to
/// visualize a item in the selection box
/// This is a dependency property.
/// </summary>
[Category("Common")]
public DataTemplate SelectionBoxNullItemTemplate
{
get { return (DataTemplate)GetValue(SelectionBoxNullItemTemplateProperty); }
set { SetValue(SelectionBoxNullItemTemplateProperty, value); }
}
/// <summary>
/// Identifies the <see cref="SelectionBoxNullValueText"/> dependency property.
/// </summary>
public static readonly DependencyProperty SelectionBoxNullValueTextProperty =
DependencyProperty.Register("SelectionBoxNullValueText",
typeof(string), typeof(ComboBoxEx),
new FrameworkPropertyMetadata("The value is null"));
/// <summary>
/// Gets or sets the text that is used to represent
/// a null value in selectionbox of the combobox.
/// This is a dependency property.
/// </summary>
[Category("Common")]
public string SelectionBoxNullValueText
{
get { return (string)GetValue(SelectionBoxNullValueTextProperty); }
set { SetValue(SelectionBoxNullValueTextProperty, value); }
}
接下来,我们提供一个默认模板,它只是显示一个绑定到 SelectionBoxNullValueText
属性的 TextBlock
。
<Style x:Key="{x:Type local:ComboBoxEx}"
TargetType="{x:Type local:ComboBoxEx}">
<Setter Property="SelectionBoxNullItemTemplate">
<Setter.Value>
<DataTemplate>
<TextBlock Text="{Binding}"></TextBlock>
</DataTemplate>
</Setter.Value>
</Setter>
.......
现在,我们需要确保选择框中使用的 ContentPresenter
在 SelectedItem
属性为 NULL
时会获取此模板。
<Trigger Property="SelectedItem" Value="{x:Null}">
<Setter TargetName="selectionBoxContentPresenter"
Property="ContentTemplate"
Value="{Binding RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType={x:Type local:ComboBoxEx}},
Path=SelectionBoxNullItemTemplate}">
</Setter>
<Setter TargetName="selectionBoxContentPresenter"
Property="Content"
Value="{Binding RelativeSource={RelativeSource Mode=FindAncestor,
AncestorType={x:Type local:ComboBoxEx}},Path=SelectionBoxNullValueText}">
</Setter>
</Trigger>
下图显示了我们最新的自定义
或者,我们可以选择将其设置为与下拉列表中的模板相同的模板。
Using the Code
我们可以像使用常规 combobox
一样使用这个 combobox
。
为了总结我们在这里所做的工作,下面列出了添加的属性及其用途
NullItemTemplate |
用于在下拉列表中可视化 NULL 值的模板 |
NullValueText |
用于在下拉列表中标识 null 值的文本 |
SelectionBoxNullItemTemplate |
用于在选择框中可视化 NULL 值的模板。 |
SelectionBoxNullValueText |
用于在选择框中标识 null 值的文本。 |
HighlightedItem |
ComboBox 中当前高亮显示的项 |
好了,就到这里。这是我的第一篇 WPF 文章,所以请对评分温柔一些。:)
历史
- 2011 年 4 月 8 日:初始版本