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

Silverlight 的自动完成组合框

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (41投票s)

2010年2月2日

CPOL

7分钟阅读

viewsIcon

311005

downloadIcon

5524

如何自定义 AutoCompleteBox,使其成为面向业务线应用的类型提前(type-ahead) ComboBox。

Demo application

目录

引言

Silverlight 中内置的 ComboBox 不够强大,无法满足企业应用的需求。在 LOB(面向业务线)应用程序中,有时我们需要为组合框提供类型提前行为,以便在用户开始键入时过滤可用选项。AutoCompleteBox(以前包含在 Silverlight Toolkit 中,现在是 Silverlight 3 )$.NET 控件的一部分)是一个非常强大且灵活的控件,在本文中,我们将探讨如何自定义 AutoCompleteBox 以用作 ComboBox/下拉列表的替代品。

我们将涵盖什么?

为了开始本文,让我们首先定义我们需要为 AutoCompleteBox 添加哪些功能,使其行为像一个可自定义的类型提前下拉组合框。以下是我们的需求:

  1. 应该有一个类似向下箭头的按钮,弹出显示可用选项的下拉列表。目前,AutoCompleteBox 的原始形状只是一个简单的文本框
  2. 选中一项时,下拉列表的弹出应显示**所有**可用选项。目前,它只显示选中的那一个。
  3. 如果选中了一项并且下拉列表已打开,则应高亮显示并聚焦(滚动到视图中)选中的项。
  4. 自定义 AutoCompleteBox 应可用于具体的对象到对象关系(例如,包含对 Project 对象引用的 SalesOrderDetail 对象,也称为关联或导航属性)。
  5. 自定义实现应可用于外键关系(例如,SalesOrderDetail 对象包含一个名为 ProductID 的字段)。

为了解决所有这些问题,我们将创建一个 AutoCompleteComboBox 控件,它继承自 AutoCompleteBox,并开始向其中添加上述功能。

要求 1

应该有一个类似向下箭头的按钮,弹出显示可用选项的下拉列表。

AutoCompleteBox with drop down button

感谢 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 with all the items shown even an item is selected

此时,我们的自定义 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

如果选中了一项并且下拉列表已打开,则应高亮显示并聚焦(滚动到视图中)选中的项。

AutoCompleteBox with the selected item highlighted

我们现在能够弹出正确的项目,但请注意,当列表弹出时,选中的项目未高亮显示。此外,为了更好的用户体验,我们需要将列表滚动到选中的项目。为此,我们需要重写 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。为此,我们可以重写 OnDropClosedOnLostFocus 事件。让我们像这样重写 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;
}

请注意,上述解决方案将适用于对象到对象关联;例如,考虑到 SalesOrderDetailProduct 表之间的典型关系,我们在 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 控件已准备好使用。以下是如何使用该控件:

  1. 对象到对象关联(Entity Framework 3.5 中常见的关联)
  2. <custom:AutoCompleteComboBox
        SelectedItemBinding="{Binding Product, Mode=TwoWay}"
        ItemsSource="{Binding Path=Products, Source={StaticResource ViewModel}}"
    />
  3. 外键关联(Entity Framework 4 中引入的新关联类型)
  4. <custom:AutoCompleteComboBox
        SelectedValue="{Binding ProductID, Mode=TwoWay}"
        SelectedValuePath="ProductID"
        ItemsSource="{Binding Path=Products, Source={StaticResource ViewModel}}"
    />

演示应用程序遵循 Model View ViewModel 模式,并演示了 AutoCompleteComboBox 在三种场景下的使用:

  1. 对象到对象关联
  2. 带整数外键的外键关联
  3. 带字符串外键的外键关联

结论

就这样。本文演示了如何自定义 AutoCompleteBox 以用作 LOB 应用程序中 ComboBox 的替代品。还有许多其他自定义选项,如果您想进一步增强此控件,我强烈建议阅读 Jeff Wilcox 的博客文章。他对 AutoCompleteBox 控件及其自定义编写了许多出色的文章。希望您喜欢阅读本文。

历史

  • 版本 1.0
    • 初始版本
  • 版本 1.1
    • 修复了 UI 虚拟化,使控件能够高效地处理数千条记录
    • 打开弹出窗口时,将选中的项滚动到视图中
© . All rights reserved.