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

遵循 MVVM 方法的 C# 和 WPF 的逗号分隔术语自动完成文本框。

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2024 年 9 月 9 日

CPOL

7分钟阅读

viewsIcon

5623

downloadIcon

57

本文将演示一个 C# 和 WPF 中支持逗号分隔项的自动完成文本框的实现方法,并提供源代码,该实现遵循 MVVM 模式。

下载 AutoCompleteTextBox.zip

引言

我想要一个用于输入演员名字的自动完成文本框。现有的解决方案要么直接将建议项放入文本框中(这实际上已经不是建议,而是直接发送到 ViewModel),要么显示一个 ComboBox 供用户选择(这在我输入名字时会觉得很烦人)。这里我展示了我的解决方案,它显示建议项来辅助输入,但不会干扰输入。建议项仅在被接受后才发送到 ViewModel。此外,已输入的建议项不会再次显示,并且会自动纠正大小写。

使用代码

SourceCode 文件夹中,有一个名为 AutoCompleteTextBoxDemo 的文件夹,其中包含演示源代码;另一个名为 WPFControls 的文件夹,其中包含 AutoCompleteTextBox 控件。在你的项目中可以使用以下三种方式来使用它:

  1. WPFControls 项目添加到你的解决方案中,并添加对你项目的引用。
  2. 使用 WPFControls.dll 文件,并添加对你项目的引用。
  3. AutoCompleteTextBox.xamlAutoCompleteTextBox.xaml.cs 文件复制到你的项目文件夹中,并将这两个文件的命名空间更改为你自己的命名空间。

之后,你就可以像下面这样使用 AutoCompleteTextBox 了:

<wpfcontrols:AutoCompleteTextBox Input="{Binding DogBreeds, UpdateSourceTrigger=PropertyChanged}" Suggestions="{Binding DogBreedSuggestions}" />

使用以下命名空间(如果你选择了上面描述的选项 1 或 2):

xmlns:wpfcontrols="clr-namespace:WPFControls;assembly=WPFControls"

DogBreeds 是一个(逗号分隔的)字符串属性,DogBreedSuggestions 是一个包含所有可能建议项的列表。它们都必须是你的 ViewModel 的一部分。你可以根据喜好命名它们。有关更多信息,请参阅演示源代码。

使用演示

运行 Demo 文件夹中的 AutoCompleteTextBoxDemo.exe,在 NoWrap-TextBox 中输入你喜欢的猫咪品种,在 Wrap-TextBox 中输入你喜欢的狗狗品种。

演练

UI/XAML

在下面的 XAML 代码中,我们使用一个普通的 TextBox 并修改其模板。Border 元素和 ScrollViewer "PART_ContentHost" 是 TextBox 模板的默认部分。我们添加了一个 Grid 和一个 TextBlock 来实现叠加显示的灰色建议文本。TextBlock 也需要一个 ScrollViewer,以便建议文本可以与 TextBox 同步滚动。

<TextBox x:Class="WPFControls.AutoCompleteTextBox"
         xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         x:Name="TextBox">
    <TextBox.Template>
        <ControlTemplate TargetType="{x:Type TextBoxBase}">

            <Border BorderBrush="{TemplateBinding BorderBrush}"
                    SnapsToDevicePixels="True"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    Background="{TemplateBinding Background}">

                <Grid>
                    <ScrollViewer x:Name="PART_ContentHost"
                                  Focusable="False" />

                    <ScrollViewer x:Name="OverlayScrollViewer"
                                  Focusable="False"
                                  IsHitTestVisible="False">
                        <ScrollViewer.Style>
                            <Style TargetType="ScrollViewer">
                                <Setter Property="HorizontalScrollBarVisibility" Value="Disabled" />
                                <Setter Property="Margin" Value="2,0,-2,0" />
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding ElementName=TextBox, Path=TextWrapping}"
                                                 Value="NoWrap">
                                        <Setter Property="HorizontalScrollBarVisibility" Value="Hidden" />
                                        <Setter Property="Margin" Value="2,0,2,0" />
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </ScrollViewer.Style>
                        <TextBlock Padding="{TemplateBinding Padding}"
                                   TextWrapping="{Binding ElementName=TextBox, Path=TextWrapping}">
                            <Run x:Name="Input" Text="{Binding Input, RelativeSource={RelativeSource TemplatedParent}, UpdateSourceTrigger=PropertyChanged}"/><Run Foreground="#aaa" x:Name="Suggestion" />
                        </TextBlock>
                    </ScrollViewer>
                </Grid>

            </Border>

        </ControlTemplate>
    </TextBox.Template>
</TextBox>

TextBlock 由两部分文本(Inlines)组成:第一部分是我们 TextBox 的文本;第二部分是可见的建议项部分。

此外,我们还需要一些额外的调整:

  1. 我们需要通过点击 TextBlock 来输入文本,因此我们将 ScrollViewer 上的 IsHitTestVisible 设置为 False。
  2. TextBlock 需要绑定到 TextBox 的 PaddingTextWrapping 属性。
  3. 为了使文本换行正常工作,我们需要更改 TextBlock 的 ScrollViewer 的边距和水平滚动条的可见性。否则,TextBlock 的换行可能与 TextBox 的换行不完全匹配。
  • "Wrap" 和 "WrapWithOverflow":水平滚动条被禁用,左边距设置为 2,右边距设置为 -2。
  • "NoWrap":水平滚动条被启用但隐藏,两边边距均为 2。

表示逻辑

接下来,我们需要实现一些表示逻辑。我们重写 OnApplyTemplate 方法以获取对两个 ScrollViewers 和建议文本的引用。稍后我们将需要它们来设置建议项并控制滚动行为。最后,我们将叠加层的 Foreground 设置为 TextBox 的 Foreground,然后将 TextBox 的 Foreground 设置为 Transparent。我们只需要可见的叠加层(具有两种颜色)。TextBox 无法显示超过一种颜色,因此我们仅将其用于输入。

private Run suggestion;
private ScrollViewer textBoxScrollViewer,
                     overlayScrollViewer;

public override void OnApplyTemplate()

{
    base.OnApplyTemplate();

    suggestion = (Run)this.GetTemplateChild("Suggestion");
    overlayScrollViewer = (ScrollViewer)this.GetTemplateChild("OverlayScrollViewer");
    textBoxScrollViewer = (ScrollViewer)this.GetTemplateChild("PART_ContentHost");

    ((Run)this.GetTemplateChild("Input")).Foreground = this.Foreground;
    this.Foreground = Brushes.Transparent;
}

TextBlock 的 ScrollViewer 必须与 TextBox 的 ScrollViewer 同步滚动。我们在 XAML 中注册 ScrollChanged 事件...

<ScrollViewer x:Name="PART_ContentHost"
              Focusable="False"
              ScrollChanged="PART_ContentHost_ScrollChanged" />

...并在代码中同步两个 ScrollViewers 的偏移量。

private void PART_ContentHost_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
    overlayScrollViewer.ScrollToHorizontalOffset(this.HorizontalOffset);
    overlayScrollViewer.ScrollToVerticalOffset(this.VerticalOffset);
}

我们还确保没有人可以选中或输入文本框的建议部分。为此,我们在 XAML 中向 TextBox 注册 PreviewKeyDownSelectionChanged 事件。

<TextBox x:Class="WPFControls.AutoCompleteTextBox"
         ...
         PreviewKeyDown="TextBox_PreviewKeyDown"
         SelectionChanged="TextBox_SelectionChanged"
         ...>

在代码中,我们将 CaretIndex 和可能的选择限制在 Input 属性(即不包含建议项的部分)的末尾。

private void TextBox_SelectionChanged(object sender, RoutedEventArgs e)
{
    if (this.CaretIndex > this.Input.Length)
        this.CaretIndex = this.Input.Length;

    if (!String.IsNullOrWhiteSpace(this.SelectedText)
        &&
        this.SelectionStart + this.SelectionLength > this.Input.Length)
    {
        this.Select(this.SelectionStart, this.Input.Length - this.SelectionStart);
    }
}

最后一步是当没有设置文本换行且 CaretIndex 设置到输入末尾时,将文本滚动到右侧。由于上一步,我们无法再访问建议项,因此滚动不会自动工作,否则我们将看不到完整的建议项。

private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
    if (this.TextWrapping != TextWrapping.NoWrap)
        return;

    if (e.Key == Key.End
        ||
        (e.Key == Key.Right
         &&
         this.CaretIndex >= this.Input.Length - 1))
    {
        textBoxScrollViewer.ScrollToRightEnd();
    }
}

MVVM

为了支持 MVVM,我们需要两个 DependencyProperty 来绑定一个表示可能建议项的字符串列表,以及当前输入(不含建议项)。

<AutoCompleteTextBox Input="{Binding Actors, UpdateSourceTrigger=PropertyChanged}" Suggestions="{Binding ListOfActors}" />

为什么我们不使用 Text 属性而不是新的 Input 属性?我们只想绑定实际的输入,但需要将整个文本(含建议项)放入 TextBox。这是因为文本换行在缺少建议项时无法正常工作。此外,在单行 TextBox 中,当建议项不存在时,我们无法滚动到建议项的末尾。稍后我们将在代码中同步 TextInput 属性。

public static readonly DependencyProperty SuggestionsProperty =
    DependencyProperty.Register("Suggestions",
                                typeof(IEnumerable<string>),
                                typeof(AutoCompleteTextBox));

public IEnumerable<string> Suggestions
{
    get => (IEnumerable<string>)GetValue(SuggestionsProperty);
    set => SetValue(SuggestionsProperty, value);
}

public static readonly DependencyProperty InputProperty =
    DependencyProperty.Register("Input",
                                typeof(string),
                                typeof(AutoCompleteTextBox),
                                new PropertyMetadata("",
                                                     new PropertyChangedCallback(InputProperty_PropertyChanged)));

public string Input
{
    get => (string)GetValue(InputProperty);
    set => SetValue(InputProperty, value);
}

建议列表可以是 List、ObservableCollection、ReadOnlyCollection 或任何可以列出字符串的集合。

当 ViewModel 更改 Input 属性时,我们需要更新 TextBox 并重置建议项。为此,我们使用 PropertyChangedCallback。但是,如果 Input 值与不包含建议项的文本相同,则更改是由我们的代码引起的。在这种情况下,我们需要退出该方法以避免重置建议项。

private static void InputProperty_PropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
    AutoCompleteTextBox autoCompleteTextBox = (AutoCompleteTextBox)obj;

    if (autoCompleteTextBox.Input.Equals(autoCompleteTextBox.Text[..^autoCompleteTextBox.suggestion.Text.Length]))
        return;

    autoCompleteTextBox.Text = autoCompleteTextBox.Input;

    autoCompleteTextBox.suggestion.Text = "";
    autoCompleteTextBox.currentSuggestion = null;
}

主要逻辑

逻辑的主要部分在文本更改时处理。因此,我们在 XAML 中向 TextBox 注册 TextChanged 事件。

<TextBox x:Class="WPFControls.AutoCompleteTextBox"
         ...
         TextChanged="TextBox_TextChanged"
         ...>

在 TextChanged 事件处理程序中,在开始建议逻辑之前,我们检查文本是否等于包含建议项的叠加文本。我们需要在此方法中稍后设置文本,因此此事件处理程序会再次触发。我们只需要响应用户输入,所以如果这是我们自己设置的,那么它就是相等的,我们就退出该方法。

接下来,我们将 Input 属性设置为不包含建议项的文本部分。

我们只为最后一个词提供建议。因此,我们检查用户是否更改了最后一个词。如果不是,则退出该方法。

private string? currentSuggestion = null;

private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
    if (this.Text.Equals(this.Input + suggestion.Text, StringComparison.OrdinalIgnoreCase))
        return;

    this.Input = this.Text[..^suggestion.Text.Length];

    if (this.CaretIndex <= this.Input.LastIndexOf(","))
        return;

    List<string> usedSuggestions = this.Input
                                       .Split(',')
                                       .ToList();

    string searchTerm = usedSuggestions.Last().TrimStart();

    currentSuggestion = String.IsNullOrWhiteSpace(searchTerm)
                        ? null
                        : this.Suggestions?.Except(usedSuggestions.Select(s => s.Trim())
                                                                  .Take(usedSuggestions.Count - 1))
                                           .FirstOrDefault(x => x.StartsWith(searchTerm, StringComparison.OrdinalIgnoreCase));

    if (searchTerm.Equals(currentSuggestion, StringComparison.OrdinalIgnoreCase))
    {
        this.AcceptSuggestion(this.Input);
    }
    else
    {
        int suggestionLength = suggestion.Text.Length;
        suggestion.Text = currentSuggestion?[searchTerm.Length..] ?? "";

        int saveCaretIndex = this.CaretIndex;
        this.Text = this.Input + suggestion.Text;
        this.CaretIndex = saveCaretIndex;

        // Scroll to the right end if no text wrapping is set
        // and a suggestion is added, so that we can see it
        if (this.TextWrapping == TextWrapping.NoWrap
            &&
            suggestionLength < suggestion.Text.Length)
        {
            textBoxScrollViewer.ScrollToRightEnd();
        }
    }
}

之后,我们开始主要逻辑。通过分割输入来获取所有已使用的建议项。最后一个建议项是我们的搜索词。如果它不为空,我们会在建议列表中查找以搜索词开头的词。使用 Except 方法,我们省略所有已输入的建议项,以便只提供新的建议。

如果搜索词等于找到的建议项,即用户自己输入了整个建议项,我们就接受它(如下所述)。否则,我们将建议叠加文本设置为完成输入的词。我们还必须设置 TextBox 的文本。它必须与叠加层完全相同,否则滚动和换行将无法正常工作。我们还必须保存和恢复插入符索引,因为它在更改文本后将被设置为 0。

最后,我们再次向右滚动,如上面的 PreviewKeyDown 事件处理程序中所述,以便在没有换行设置时可以看到整个建议项。

要接受建议,我们将 TextBox 的文本和 Input 属性设置为包含建议项的完整文本。我们还重置(当前)建议项。因为更改文本时 CaretIndex 被设置为 0,所以我们必须将其重置到文本的末尾。

private void AcceptSuggestion()
{
    if (String.IsNullOrWhiteSpace(currentSuggestion))
    return;

    this.Text = text[..^currentSuggestion.Length] + currentSuggestion;
    this.Input = this.Text;

    suggestion.Text = "";
    currentSuggestion = null;
    this.CaretIndex = this.Text.Length;
}

一个技巧:我们删除建议项,然后再次添加它。这会纠正大小写,以便用户可以更快地输入,并且输入的建议项始终与列表中的建议项相等。

当然,用户也可以自己接受或拒绝建议。我们在 PreviewKeyDown 事件处理程序中添加了一个 switch 语句。

private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
    ...

    switch (e.Key)
    {
        case Key.Enter:
            this.AcceptSuggestion(this.Text);
            break;

        case Key.Escape:
            currentSuggestion = null;
            suggestion.Text = "";

            int saveCaretIndex = this.CaretIndex;
            this.Text = this.Input;
            this.CaretIndex = saveCaretIndex;
            break;
    }
}

按 Enter 键接受建议,按 Esc 键通过重置建议项并将其设置到 TextBox 文本(即不含建议项的文本)来拒绝建议。

历史

  • 2024 年 9 月 9 日 - 版本 1.0
    • 初始版本。
© . All rights reserved.