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

可重用的 WPF 自动完成文本框

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.79/5 (37投票s)

2009年11月25日

CPOL

8分钟阅读

viewsIcon

357469

downloadIcon

17390

基于 TextBox 的自定义控件,允许根据任何项目源的自定义过滤器进行自动完成。

引言

WPF 控件库中一个备受期待的功能是自动完成文本框。在本文中,我将创建一个自动完成文本框,它类似于你在网上找到的功能,例如在 Google 搜索框中键入时出现的。

我将此文本框创建为 WPF 自定义控件,从而保留了以最大灵活性进行控件样式设置和模板设置的能力。此外,我将此控件设计为可重用,并利用 WPF 在依赖属性和数据绑定方面的全部功能。该控件非常易于使用,只需设置 ItemsSource 属性,并设置一个 Binding 属性以指示要用作完成源的数据字段。实际的过滤器是在代码隐藏中设置为委托的。

在我前进的道路上遇到了几个障碍,我将在下一节中详细介绍,但在此处是它们的摘要:

  • 公开列表框的重要依赖属性
  • 焦点问题,以及它们如何影响弹出窗口的自动关闭行为和完成列表键盘导航
  • 动态评估数据项上的绑定对象(从代码中)
  • 限制显示的完成项数量

在接下来的讨论中,我不会用代码充斥文章。我将在必要时提供简短的示例,并用英文描述逻辑。原因有两个,首先,代码量很大;其次,我希望能够纠正和更改代码风格,而无需过多地更新文章。

Using the Code

我将从演示一个简单的控件用例开始,以展示它的易用性。这里有一个极简的 XAML 代码,它使用了美国国家气象局观测站的 RSS 提要的自动完成文本框。

这是键入时它的样子,带有键盘导航到完成列表

这是选择后(按 Enter)的样子

以及代码

<Window x:Class="Test.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:actb="clr-namespace:Aviad.WPF.Controls;assembly=Aviad.WPF.Controls"
    Title="Window1" Height="300" Width="600">
    <Window.Resources>
        <XmlDataProvider x:Key="xml" 
	Source=http://www.nws.noaa.gov/xml/current_obs/index.xml 
	DataChanged="XmlDataProvider_DataChanged"/>
        <DataTemplate x:Key="TheItemTemplate">
            <Border BorderBrush="Salmon" BorderThickness="2" CornerRadius="5">
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto"/>
                        <ColumnDefinition/>
                    </Grid.ColumnDefinitions>
                    <Grid.RowDefinitions>
                        <RowDefinition/>
                        <RowDefinition/>
                        <RowDefinition/>
                    </Grid.RowDefinitions>
                    <TextBlock Text="ID:  "/>
                    <TextBlock Grid.Column="1" Text="{Binding XPath=station_id}"/>
                    <TextBlock Grid.Row="1" Text="Name:  "/>
                    <TextBlock Grid.Column="1" Grid.Row="1" 
			Text="{Binding XPath=station_name}"/>
                    <TextBlock Grid.Row="2" Text="RSS URL:  "/>
                    <TextBlock Grid.Column="1" Grid.Row="2" 
			Text="{Binding XPath=rss_url}"/>
                </Grid>
            </Border>
        </DataTemplate>
    </Window.Resources>
    <Grid>
        <TextBlock x:Name="StatusLabel" Text="Loading Data..." Margin="20,20,0,0"/>
        <actb:AutoCompleteTextBox 
            x:Name="actb" 
            Margin="20,40,20,0"
            VerticalAlignment="Top" 
            ItemsSource="{Binding Source={StaticResource xml}, XPath=//station}" 
            ItemTemplate="{StaticResource TheItemTemplate}"
            Binding="{Binding XPath=station_id}" 
            MaxCompletions="10"/>
    </Grid>
</Window>

这是设置过滤器的代码隐藏

public partial class Window1 : Window
{
    public Window1()
    {
        InitializeComponent();
        actb.Filter = Filter;
    }
 
    private bool Filter(object obj, string text)
    {
        XmlElement element = (XmlElement)obj;
        string StationName = 
		element.SelectSingleNode("station_name").InnerText.ToLower();
        if (StationName.Contains(text.ToLower())) return true;
        return false;
    }
 
    private void XmlDataProvider_DataChanged(object sender, EventArgs e)
    {
        StatusLabel.Text = "Xml Data Loaded.";
    }
}

请注意过滤器如何使用 station_name 元素来过滤自动完成列表,而 XAML 指定了 station_id 是从选定的条目中提取的。运行示例并尝试一下,请记住等待 XML 数据加载,它并不小。

在本文的最后一节,我将展示此文本框的高级用例,使用 Google Suggest API。

实现

概念上,自动完成文本框是一个文本框和一个列表框的组合,但由于 .NET 不允许多重继承,我不得不选择哪个部分更重要。我选择了 TextBox 作为我的基类,并将 ListBox 控件作为控件模板的一部分添加。此外,还有一个功能可以使完成列表在适当的时候弹出和消失,为此我使用了 Popup 控件。

公开依赖属性

将列表框放入模板后,几个重要的属性变得无法访问,即:

  • ItemsSource
  • ItemTemplate
  • ItemContainerStyle
  • ItemTemplateSelector

因此,首先,我在我的控件中公开了这些属性。我使用了 ItemsControl 类中 DependencyProperty 对象的 AddOwner 方法。下面是其中一个属性的实现方式,其他两个类似。

public static readonly DependencyProperty ItemsSourceProperty =
    ItemsControl.ItemsSourceProperty.AddOwner(
        typeof(AutoCompleteTextBox), 
        new UIPropertyMetadata(null, OnItemsSourceChanged));

请注意,我为每个属性分配了一个回调方法,在回调方法中,我将属性“转发”给内部列表框(并附加一些额外的逻辑)。

接下来,我提供了另外三个属性:

  • Binding - 一个依赖属性,用于保存用于提取用于自动完成的文本的绑定。
  • MaxCompletions - 一个依赖属性,用于限制显示的完成结果的数量。
  • Filter - 一个基本属性,用于保存过滤项集合的回调委托,该过滤基于输入的文本。

自动完成逻辑如下:对于键入的每个字符,列表框的集合视图都会被重新过滤;如果找到至少一个匹配项,则会打开弹出窗口并显示列表。可以通过单击或使用键盘选择完成项。一旦选择了完成项,将使用选定项的 Binding 属性获得的文本放入文本框内容中,然后关闭弹出窗口。

自动完成在以下任一情况下成功完成:

  • 用户单击列表中的一个项。
  • 用户使用箭头键导航列表,并在项上按下 Return/Enter 键或 Tab 键。

如果用户通过键盘按下 Tab 选择了完成项,则焦点也会移至窗口制表符顺序中的下一个控件。

在以下任一情况下,自动完成将被中止:

  • 用户单击控件区域外部的任何位置。
  • 用户按下 Escape 键。
  • 焦点位于文本框本身,并且用户按下 Tab 键。

评估绑定

熟悉我文章前一版本的读者知道,我曾使用一种“脏”技巧来将文本框的 Binding 属性中的绑定传输到从完成列表中检索的数据对象。我最近发现我当时的做法是错误的,试图将绑定设置在一个假的 DependencyObject 上。通过使用 FrameworkElement 替代,我得以利用现有绑定,并利用默认源为 DataContext 属性的这种行为。

// Retrieve the Binding object from the control.
var originalBinding = BindingOperations.GetBinding(this, BindingProperty);
if (originalBinding == null) return;
 
// Set the dummy's DataContext to our selected object.
dummy.DataContext = obj;
 
// Apply the binding to the dummy FrameworkElement.
BindingOperations.SetBinding(dummy, TextProperty, originalBinding);
 
// Get the binding's resulting value.
Text = dummy.GetValue(TextProperty).ToString();

“黑客”版本仍然保留在源代码下载的注释中,供感兴趣的读者参考。

限制完成列表

有时用作完成源的项集合非常大,包含数千个条目。如果我们不以某种方式限制完成列表,在键入前几个字符时,我们会遇到严重的性能损失,因为 WPF 框架需要花费大量时间来生成填充列表的所有视觉元素。我们可以手动遍历集合视图并生成一个用于显示的列表,但这会导致为每次按键生成一个新列表。更好的方法是设法让集合视图只公开其前 (n) 个元素。

这时我不得不发挥创意。为了实现这一点,我创建了一个名为 LimitedListCollectionView 的类,它继承自 ListCollectionView,并根据某个参数调整其公开的集合大小。然后我重写了 Count 属性以及 MoveCurrentToNext/Last/Previous/Position 方法。

public override int Count { get { return Math.Min(base.Count, Limit); } }
 
public override bool MoveCurrentToLast()
{
    return base.MoveCurrentToPosition(Count - 1);
}
 
public override bool MoveCurrentToNext()
{
    if (base.CurrentPosition == Count - 1)
        return base.MoveCurrentToPosition(base.Count);
    else 
        return base.MoveCurrentToNext();
}
 
public override bool MoveCurrentToPrevious()
{
    if (base.IsCurrentAfterLast)
        return base.MoveCurrentToPosition(Count - 1);
    else
        return base.MoveCurrentToPrevious();
}
 
public override bool MoveCurrentToPosition(int position)
{
    if (position < Count)
        return base.MoveCurrentToPosition(position);
    else
        return base.MoveCurrentToPosition(base.Count);
}
 
#region IEnumerable Members
 
IEnumerator IEnumerable.GetEnumerator()
{
    do
    {
        yield return CurrentItem;
    } while (MoveCurrentToNext());
}
 
#endregion

请注意其中的复杂性,我允许调用者迭代前 Limit 个元素,一旦他试图移过它们,他就会被传送到集合的末尾。

其他技巧

我还使用了一些其他技巧,以使此控件真正符合其应有的感觉,而不是像一个丑陋的“黑客”。其中一个技巧是让列表选择指示器跟随鼠标移动。另一个技巧是拦截向下箭头键按下并将焦点转移到列表框以启用键盘导航。此外,我还挂接了许多输入事件,以便在失去焦点、按键等情况下正确处理完成列表的自动关闭。我不会在此处复制代码。如果您有兴趣,可以查看源代码文件。

高级用例 - Google 搜索框

在本节中,我将逐步演示如何构建一个 Google 搜索框,并在键入时显示搜索建议。我将首先定义一个“视图模型”。该视图模型将作为此示例中所有数据绑定的后备存储。

视图模型定义了三个属性:

  • QueryText - 用户文本的后备存储。
  • QueryCollection - 完成列表的后备存储。
  • WaitMessage - 用于告知用户查询正在后台运行。

QueryCollection 属性执行一个阻塞的 Web 请求到 Google 服务器,并且可能需要很长时间才能完成,我们以异步方式绑定到它,使用 PriorityBindingWaitMessage 属性在该 PriorityBinding 中用作“快速”属性,用于通知用户待处理的查询。ItemTemplateSelector 用于选择等待消息情况下的适当模板。

public class ViewModel : INotifyPropertyChanged
{
    private List<string> _WaitMessage = new List<string>() { "Please Wait..." };
    public IEnumerable WaitMessage { get { return _WaitMessage; } }
 
    private string _QueryText;
    public string QueryText
    {
        get { return _QueryText; }
        set
        {
            if (_QueryText != value)
            {
                _QueryText = value;
                OnPropertyChanged("QueryText");
                _QueryCollection = null;
                OnPropertyChanged("QueryCollection");
            }
        }
    }
 
    public IEnumerable _QueryCollection = null;
    public IEnumerable QueryCollection
    {
        get
        {
            QueryGoogle(QueryText);
            return _QueryCollection;
        }
    }
 
    private void QueryGoogle(string SearchTerm)
    {
        string sanitized = HttpUtility.HtmlEncode(SearchTerm);
        string url = @"http://google.com/complete/search?output=toolbar&q=" + sanitized;
        WebRequest httpWebRequest = HttpWebRequest.Create(url);
        var webResponse = httpWebRequest.GetResponse();
        XmlDocument xmlDoc = new XmlDocument();
        xmlDoc.Load(webResponse.GetResponseStream());
        var result = xmlDoc.SelectNodes("//CompleteSuggestion");
        _QueryCollection = result;
    }
 
    #region INotifyPropertyChanged Members
 
    public event PropertyChangedEventHandler PropertyChanged;
 
    #endregion
 
    protected void OnPropertyChanged(string prop)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(prop));
    }
}

接下来,我们将创建一个 CollectionViewSource 来保存查询结果。由于我们希望结果是动态的,我们将 CollectionViewSoruceSource 属性绑定到视图模型的 QueryCollection 属性。这就是我们利用 PriorityBinding 绑定类的地方,因为我们知道 QueryCollection 属性是一个“慢”操作,我们将其与一个“快”属性一起放入 PriorityBinding 对象中。WPF 框架将使用“快速”绑定,直到“慢”绑定最终提供一个值,然后它将通知所有人值已更改。请注意,“慢”绑定被标记为 IsAsync="True";这使其在后台运行,并且是 PriorityBinding 正确工作的必需条件。

<CollectionViewSource x:Key="xml">
    <CollectionViewSource.Source>
        <PriorityBinding>
            <Binding Source="{StaticResource vm}"
                     Path="QueryCollection"
                     IsAsync="True"/>
            <Binding Source="{StaticResource vm}" Path="WaitMessage"/>
        </PriorityBinding>
    </CollectionViewSource.Source>
</CollectionViewSource>

接下来,我们声明我们的自动完成文本框,并对其进行正确绑定。

<actb:AutoCompleteTextBox 
    Text=
"{Binding Source={StaticResource vm}, Path=QueryText, UpdateSourceTrigger=PropertyChanged}"
    ItemsSource="{Binding Source={StaticResource xml}}" 
    ItemTemplateSelector="{StaticResource TemplateSelector}"
    Binding="{Binding XPath=suggestion/@data}" 
    MaxCompletions="5"/>

请注意,我们将 ItemsSource 属性绑定到我们的 CollectionViewSource,并将 Text 属性绑定到视图模型的 QueryText 属性。正确设置更新源触发器也很重要,以便在键入时列表会更新。

接下来是视觉模板。由于我们的完成列表可以填充两种类型的对象:XML 节点列表,或在等待消息情况下的简单字符串列表,因此我们需要准备两个模板,并使用模板选择器。以下是模板:

<DataTemplate x:Key="TheItemTemplate">
    <Border BorderBrush="Salmon" BorderThickness="2" CornerRadius="5">
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition/>
            </Grid.RowDefinitions>
            <TextBlock Text="Suggestion:  "/>
            <TextBlock Grid.Column="1" 
                       Text="{Binding XPath=suggestion/@data}"/>
            <TextBlock Grid.Row="1" Text="Results:  "/>
            <TextBlock Grid.Column="1" 
                       Grid.Row="1" 
                       Text="{Binding XPath=num_queries/@int}"/>
        </Grid>
    </Border>
</DataTemplate>
<DataTemplate x:Key="WaitTemplate">
    <TextBlock Text="{Binding}" Background="SlateBlue"/>
</DataTemplate>

这是模板选择器定义:

public class MyDataTemplateSelector : DataTemplateSelector
{
    public override DataTemplate SelectTemplate(
        object item,
        DependencyObject container)
    {
        Window wnd = Application.Current.MainWindow;
        if (item is string)
            return wnd.FindResource("WaitTemplate") as DataTemplate;
        else
            return wnd.FindResource("TheItemTemplate") as DataTemplate;
    }
}

瞧,我们完成了。窗口不需要代码隐藏,因为我们不需要为完成列表分配任何特殊的过滤逻辑(我们接受 Google 返回的所有内容)。

就这样,尽情享受吧。

历史

  • 2009 年 11 月 25 日:初始发布
  • 2009 年 11 月 27 日:添加了 Google 搜索示例
  • 2010 年 1 月 5 日:改进了关于 Binding 重新应用的 C# 代码。
© . All rights reserved.