Silverlight 的自动完成组合框






4.93/5 (41投票s)
如何自定义 AutoCompleteBox,使其成为面向业务线应用的类型提前(type-ahead) ComboBox。
目录
引言
Silverlight 中内置的 ComboBox
不够强大,无法满足企业应用的需求。在 LOB(面向业务线)应用程序中,有时我们需要为组合框提供类型提前行为,以便在用户开始键入时过滤可用选项。AutoCompleteBox
(以前包含在 Silverlight Toolkit 中,现在是 Silverlight 3 )$.NET 控件的一部分)是一个非常强大且灵活的控件,在本文中,我们将探讨如何自定义 AutoCompleteBox
以用作 ComboBox/下拉列表的替代品。
我们将涵盖什么?
为了开始本文,让我们首先定义我们需要为 AutoCompleteBox
添加哪些功能,使其行为像一个可自定义的类型提前下拉组合框。以下是我们的需求:
- 应该有一个类似向下箭头的按钮,弹出显示可用选项的下拉列表。目前,
AutoCompleteBox
的原始形状只是一个简单的文本框。 - 选中一项时,下拉列表的弹出应显示**所有**可用选项。目前,它只显示选中的那一个。
- 如果选中了一项并且下拉列表已打开,则应高亮显示并聚焦(滚动到视图中)选中的项。
- 自定义
AutoCompleteBox
应可用于具体的对象到对象关系(例如,包含对Project
对象引用的SalesOrderDetail
对象,也称为关联或导航属性)。 - 自定义实现应可用于外键关系(例如,
SalesOrderDetail
对象包含一个名为ProductID
的字段)。
为了解决所有这些问题,我们将创建一个 AutoCompleteComboBox
控件,它继承自 AutoCompleteBox
,并开始向其中添加上述功能。
要求 1
应该有一个类似向下箭头的按钮,弹出显示可用选项的下拉列表。
感谢 Windows Presentation Framework,这可以通过样式来实现。Tim Hieuer 在这里对此进行了说明,并在 Silverlight 3 toolkit 演示的此页面上使用了。因此,我们将以下样式复制到 Themes\Generic.xaml 中。
<!-- Custom toggle button template -->
<Style x:Name="ComboToggleButton" TargetType="ToggleButton">
<Setter Property="Foreground" Value="#FF333333"/>
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Background" Value="#FF1F3B53"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Grid>
<Rectangle Fill="Transparent" />
<ContentPresenter
x:Name="contentPresenter"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Margin="{TemplateBinding Padding}"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Custom control template used for the IntelliSense sample -->
<Style TargetType="myCtrls:AutoCompleteComboBox">
<Setter Property="MinimumPopulateDelay" Value="1" />
<!-- ComboBox should not perform text completion by default -->
<Setter Property="IsTextCompletionEnabled" Value="False" />
<!-- The minimum prefix length should be 0 for combo box scenarios -->
<Setter Property="MinimumPrefixLength" Value="0" />
<!-- Regular template values -->
<Setter Property="Background" Value="#FF1F3B53"/>
<Setter Property="IsTabStop" Value="False" />
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="BorderBrush">
<Setter.Value>
<LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
<GradientStop Color="#FFA3AEB9" Offset="0"/>
<GradientStop Color="#FF8399A9" Offset="0.375"/>
<GradientStop Color="#FF718597" Offset="0.375"/>
<GradientStop Color="#FF617584" Offset="1"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="myCtrls:AutoCompleteComboBox">
<Grid Margin="{TemplateBinding Padding}">
<TextBox IsTabStop="True" x:Name="Text"
Style="{TemplateBinding TextBoxStyle}" Margin="0" />
<ToggleButton
x:Name="DropDownToggle"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Style="{StaticResource ComboToggleButton}"
Margin="0"
HorizontalContentAlignment="Center"
Background="{TemplateBinding Background}"
BorderThickness="0"
Height="16" Width="16"
>
<ToggleButton.Content>
<Path x:Name="BtnArrow" Height="4" Width="8"
Stretch="Uniform"
Data="F1 M 301.14,-189.041L 311.57,-189.041L 306.355,
-182.942L 301.14,-189.041 Z "
Margin="0,0,6,0" HorizontalAlignment="Right">
<Path.Fill>
<SolidColorBrush x:Name="BtnArrowColor"
Color="#FF333333"/>
</Path.Fill>
</Path>
</ToggleButton.Content>
</ToggleButton>
<Popup x:Name="Popup">
<Border x:Name="PopupBorder" HorizontalAlignment="Stretch"
Opacity="1.0" BorderThickness="0"
CornerRadius="3">
<Border.RenderTransform>
<TranslateTransform X="2" Y="2" />
</Border.RenderTransform>
<Border.Background>
<SolidColorBrush Color="#11000000" />
</Border.Background>
<Border HorizontalAlignment="Stretch" BorderThickness="0"
CornerRadius="3">
<Border.Background>
<SolidColorBrush Color="#11000000" />
</Border.Background>
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform />
<SkewTransform />
<RotateTransform />
<TranslateTransform X="-1" Y="-1" />
</TransformGroup>
</Border.RenderTransform>
<Border HorizontalAlignment="Stretch"
Opacity="1.0" Padding="2"
BorderThickness="2"
BorderBrush="{TemplateBinding BorderBrush}"
CornerRadius="3">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform />
<SkewTransform />
<RotateTransform />
<TranslateTransform X="-2" Y="-2" />
</TransformGroup>
</Border.RenderTransform>
<Border.Background>
<LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
<GradientStop Color="#FFDDDDDD" Offset="0"/>
<GradientStop Color="#AADDDDDD" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<ListBox x:Name="Selector"
Height="200"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ItemTemplate="{TemplateBinding ItemTemplate}" />
</Border>
</Border>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
在此之后,我们需要将控件的默认样式设置为上述样式,并为下拉按钮的 Click
事件进行绑定。
public AutoCompleteComboBox() : base()
{
SetCustomFilter();
this.DefaultStyleKey = typeof(AutoCompleteComboBox);
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
ToggleButton toggle = (ToggleButton)GetTemplateChild("DropDownToggle");
if (toggle != null)
{
toggle.Click += DropDownToggle_Click;
}
}
private void DropDownToggle_Click(object sender, RoutedEventArgs e)
{
FrameworkElement fe = sender as FrameworkElement;
AutoCompleteBox acb = null;
while (fe != null && acb == null)
{
fe = VisualTreeHelper.GetParent(fe) as FrameworkElement;
acb = fe as AutoCompleteBox;
}
if (acb != null)
{
acb.IsDropDownOpen = !acb.IsDropDownOpen;
}
}
要求 2
选中一项时,下拉列表的弹出应显示所有可用选项。
此时,我们的自定义 AutoCompleteBox
已经获得了类似典型组合框的视觉效果,但如果我们选择一项然后弹出下拉列表,它将仅显示选中的项。要查看所有项,我们需要设置一个自定义的 ItemFilter
predicate,如下所示:
protected virtual void SetCustomFilter()
{
//custom logic: how to autocomplete
this.ItemFilter = (prefix, item) =>
{
//return all items for empty prefix
if (string.IsNullOrEmpty(prefix))
return true;
//return all items if a record is already selected
if (this.SelectedItem != null)
if (this.SelectedItem.ToString() == prefix)
return true;
//else return items that contains prefix
return item.ToString().ToLower().Contains(prefix.ToLower());
};
}
请注意,上面的代码假定您已重写对象的 ToString()
方法。也就是说,AutoCompleteComboBox
会根据查找对象的 ToString()
值进行过滤。另外请注意,我使用的是 StartsWith
过滤器,但您可以根据业务需求更改其行为。
要求 3
如果选中了一项并且下拉列表已打开,则应高亮显示并聚焦(滚动到视图中)选中的项。
我们现在能够弹出正确的项目,但请注意,当列表弹出时,选中的项目未高亮显示。此外,为了更好的用户体验,我们需要将列表滚动到选中的项目。为此,我们需要重写 OnPopulated
事件,并从底层 ListBox
中选择项目。感谢 nangua 在此帖子中发现 ListBox.ScrollIntoView()
方法需要在 UI 线程上运行才能正常工作。
//highlighting logic
protected override void OnPopulated(PopulatedEventArgs e)
{
base.OnPopulated(e);
ListBox listBox = GetTemplateChild("Selector") as ListBox;
if (listBox != null)
{
//highlight the selected item, if any
if (this.ItemsSource != null && this.SelectedItem != null)
{
listBox.SelectedItem = this.SelectedItem;
//now scroll the selected item into view
listBox.Dispatcher.BeginInvoke(delegate
{
listBox.UpdateLayout();
listBox.ScrollIntoView(listBox.SelectedItem);
});
}
}
}
要求 4
自定义 AutoCompleteBox 应可用于具体的对象到对象关系。
到目前为止,我们的 AutoCompleteComboBox
看起来和行为都符合我们对类型提前组合框的预期。但是,如果我们使用数据绑定,并且遵循 MVVM(模型视图 ViewModel)模式(我们为什么不这样做呢?),那么我们需要一个依赖项属性,当选择新项时该属性会自动更新。最明显的选择是使用 SelectedItem
依赖项属性,但存在一个小问题。如果我们使用向下箭头循环选择选项,SelectedItem
依赖项属性(以及与之绑定的属性)会发生变化。这可能不是我们想要的(因为这可能会在不必要地调用代码的情况下,如果我们已经挂接了 PropertyChanged
事件),而且我们通常希望在用户完成决定**之后**才更新我们的数据对象。为此,我们将添加一个自定义依赖项属性(我称之为 SeletedItemBinding
),该属性将在用户**完成**选择条目后(下拉列表关闭时)更新。因此,我们声明一个新的依赖项属性,并在更改该依赖项属性时设置选中的项。
#region SelectedItemBinding
public static readonly DependencyProperty SelectedItemBindingProperty =
DependencyProperty.Register("SelectedItemBinding",
typeof(object),
typeof(AutoCompleteComboBox),
new PropertyMetadata
(new PropertyChangedCallback(OnSelectedItemBindingChanged))
);
public object SelectedItemBinding
{
get { return GetValue(SelectedItemBindingProperty); }
set { SetValue(SelectedItemBindingProperty, value); }
}
private static void OnSelectedItemBindingChanged
(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((AutoCompleteComboBox)d).OnSelectedItemBindingChanged(e);
}
protected virtual void OnSelectedItemBindingChanged(
DependencyPropertyChangedEventArgs e)
{
SetSelectemItemUsingSelectedItemBindingDP();
}
public void SetSelectemItemUsingSelectedItemBindingDP()
{
if (!this.isUpdatingDPs)
SetValue(SelectedItemProperty, GetValue(SelectedItemBindingProperty));
}
#endregion
一旦用户完成,我们还需要将更改反映回 DataContext
。为此,我们可以重写 OnDropClosed
或 OnLostFocus
事件。让我们像这样重写 DropDownClosed
事件:
protected override void OnDropDownClosed(RoutedPropertyChangedEventArgs<bool> e)
{
base.OnDropDownClosed(e);
UpdateCustomDPs();
}
private void UpdateCustomDPs()
{
//flag to ensure that we don't reselect the selected item
this.isUpdatingDPs = true;
//if a new item is selected or the user blanked out the selection, update the DP
if (this.SelectedItem != null || this.Text == string.Empty)
{
//update the SelectedItemBinding DP
SetValue(SelectedItemBindingProperty, GetValue(SelectedItemProperty));
}
else
{
//revert to the originally selected one
if (this.GetBindingExpression(SelectedItemBindingProperty) != null)
{
SetSelectemItemUsingSelectedItemBindingDP();
}
}
this.isUpdatingDPs = false;
}
请注意,上述解决方案将适用于对象到对象关联;例如,考虑到 SalesOrderDetail 和 Product 表之间的典型关系,我们在 SalesOrderDetail
对象中有一个 Product
引用,如下所示:
public class SalesOrderDetail
{
Product product;
public Product Product
{
get { return product; }
set { product = value; }
}
需求 5
自定义实现应可用于外键关系。
上述实现将适用于大多数业务应用程序,但在我们的一款应用程序中,我们没有具体的对象到对象关系。相反,我们有一个数据库风格的外键关系,并在 SalesOrderDetail 表中有一个 ProductID
属性,如下所示:
public class SalesOrderDetail
{
int productID;
public int ProductID
{
get { return productID; }
set { productID = value; }
}
如果您的应用程序中没有此类场景,您可以跳过此部分。此需求需要更多工作,我们需要创建两个依赖项属性:SelectedValuePath
:一个 string
依赖项属性,用于确定要复制查找对象的哪个属性,以及 SelectedValue
:一个 object
依赖项属性,用于确定要更新 DataContext
的哪个属性。通常,SelectedValue
将引用查找业务对象的主键(例如,Product 表的 ProductID
),而 SelectedValuePath
将引用当前 DataContext
中的外键(例如,SalesOrderDetail 表中的 ProductID
)。请注意,在以下代码中使用反射来确定要选择的项:
#region SelectedValue
public static readonly DependencyProperty SelectedValueProperty =
DependencyProperty.Register(
"SelectedValue",
typeof(object),
typeof(AutoCompleteComboBox),
new PropertyMetadata(
new PropertyChangedCallback(OnSelectedValueChanged))
);
public object SelectedValue
{
get { return GetValue(SelectedValueProperty); }
set { SetValue(SelectedValueProperty, value); }
}
private static void OnSelectedValueChanged
(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((AutoCompleteComboBox)d).OnSelectedValueChanged(e);
}
protected virtual void OnSelectedValueChanged(
DependencyPropertyChangedEventArgs e)
{
if (!this.isUpdatingDPs)
SetSelectemItemUsingSelectedValueDP();
}
//selects the item whose value is given in SelectedValueDP
public void SetSelectemItemUsingSelectedValueDP()
{
if (this.ItemsSource != null)
{
/// if selectedValue is empty, remove the current selection
if (this.SelectedValue == null)
{
this.SelectedItem = null;
}
/// if there is no selected item,
/// select the one given by SelectedValueProperty
else if (this.SelectedItem == null)
{
object selectedValue = GetValue(SelectedValueProperty);
string propertyPath = this.SelectedValuePath;
if (selectedValue != null && !(string.IsNullOrEmpty(propertyPath)))
{
/// loop through each item in the item source
/// and see if its 'SelectedValuePathProperty' == SelectedValue
foreach (object item in this.ItemsSource)
{
PropertyInfo propertyInfo = item.GetType().GetProperty(propertyPath);
if (propertyInfo.GetValue(item, null).Equals(selectedValue))
this.SelectedItem = item;
}
}
}
}
}
#endregion
#region SelectedValuePath
public static readonly DependencyProperty SelectedValuePathProperty =
DependencyProperty.Register(
"SelectedValuePath",
typeof(string),
typeof(AutoCompleteComboBox),
null
);
public string SelectedValuePath
{
get { return GetValue(SelectedValuePathProperty) as string; }
set { SetValue(SelectedValuePathProperty, value); }
}
#endregion
我们还需要更新 SelectedValue
属性所引用的 DataContext
的属性;这可以通过修改我们(已创建)的 UpdateCustomDPs()
方法来实现,如下所示:
private void UpdateCustomDPs()
{
//flag to ensure that we don't reselect the selected item
this.isUpdatingDPs = true;
//if a new item is selected or the user
//blanked out the selection, update the DP
if (this.SelectedItem != null || this.Text == string.Empty)
{
//update the SelectedItemBinding DP
SetValue(SelectedItemBindingProperty,
GetValue(SelectedItemProperty));
//update the SelectedValue DP
string propertyPath = this.SelectedValuePath;
if (!string.IsNullOrEmpty(propertyPath))
{
if (this.SelectedItem != null)
{
PropertyInfo propertyInfo =
this.SelectedItem.GetType().GetProperty(propertyPath);
//get property from selected item
object propertyValue =
propertyInfo.GetValue(this.SelectedItem, null);
//update the datacontext
this.SelectedValue = propertyValue;
}
else //user blanked out the selection,
// so we need to set the default value
{
//get the binding for selectedvalue property
BindingExpression bindingExpression =
this.GetBindingExpression(SelectedValueProperty);
Binding dataBinding = bindingExpression.ParentBinding;
//get the dataitem (typically the datacontext)
object dataItem = bindingExpression.DataItem;
//get the property of that dataitem
//that's bound to selectedValue property
string propertyPathForSelectedValue = dataBinding.Path.Path;
//get the default value for that property
Type propertyTypeForSelectedValue =
dataItem.GetType().GetProperty(
propertyPathForSelectedValue).PropertyType;
object defaultObj = null;
//use activator for getting the defaults for value types
if (propertyTypeForSelectedValue.IsValueType)
defaultObj =
Activator.CreateInstance(propertyTypeForSelectedValue);
//update the Selected Value property
this.SelectedValue = defaultObj;
}
}
}
else
{
//revert to the originally selected one
if (this.GetBindingExpression(SelectedItemBindingProperty) != null)
{
SetSelectemItemUsingSelectedItemBindingDP();
}
else if (this.GetBindingExpression(SelectedValueProperty) != null)
{
SetSelectemItemUsingSelectedValueDP();
}
}
this.isUpdatingDPs = false;
}
请注意,在上面的代码中,我们使用 Activator.CreateInstance()
来在未选择任何项时获取默认值(对于 SelectedValue
依赖项属性)。这样做是为了使控件更加灵活,以便它可以与任何数据类型的外键一起使用(通常,这些是整数或字符串)。
Using the Code
大功告成,我们的 MVVM 兼容 AutoCompleteComboBox
控件已准备好使用。以下是如何使用该控件:
- 对象到对象关联(Entity Framework 3.5 中常见的关联)
- 外键关联(Entity Framework 4 中引入的新关联类型)
<custom:AutoCompleteComboBox
SelectedItemBinding="{Binding Product, Mode=TwoWay}"
ItemsSource="{Binding Path=Products, Source={StaticResource ViewModel}}"
/>
<custom:AutoCompleteComboBox
SelectedValue="{Binding ProductID, Mode=TwoWay}"
SelectedValuePath="ProductID"
ItemsSource="{Binding Path=Products, Source={StaticResource ViewModel}}"
/>
演示应用程序遵循 Model View ViewModel 模式,并演示了 AutoCompleteComboBox
在三种场景下的使用:
- 对象到对象关联
- 带整数外键的外键关联
- 带字符串外键的外键关联
结论
就这样。本文演示了如何自定义 AutoCompleteBox
以用作 LOB 应用程序中 ComboBox
的替代品。还有许多其他自定义选项,如果您想进一步增强此控件,我强烈建议阅读 Jeff Wilcox 的博客文章。他对 AutoCompleteBox
控件及其自定义编写了许多出色的文章。希望您喜欢阅读本文。
历史
- 版本 1.0
- 初始版本
- 版本 1.1
- 修复了 UI 虚拟化,使控件能够高效地处理数千条记录
- 打开弹出窗口时,将选中的项滚动到视图中