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





0/5 (0投票)
本文将演示一个 C# 和 WPF 中支持逗号分隔项的自动完成文本框的实现方法,并提供源代码,该实现遵循 MVVM 模式。
下载 AutoCompleteTextBox.zip
引言
我想要一个用于输入演员名字的自动完成文本框。现有的解决方案要么直接将建议项放入文本框中(这实际上已经不是建议,而是直接发送到 ViewModel),要么显示一个 ComboBox 供用户选择(这在我输入名字时会觉得很烦人)。这里我展示了我的解决方案,它显示建议项来辅助输入,但不会干扰输入。建议项仅在被接受后才发送到 ViewModel。此外,已输入的建议项不会再次显示,并且会自动纠正大小写。
使用代码
在 SourceCode 文件夹中,有一个名为 AutoCompleteTextBoxDemo 的文件夹,其中包含演示源代码;另一个名为 WPFControls 的文件夹,其中包含 AutoCompleteTextBox 控件。在你的项目中可以使用以下三种方式来使用它:
- 将 WPFControls 项目添加到你的解决方案中,并添加对你项目的引用。
- 使用 WPFControls.dll 文件,并添加对你项目的引用。
- 将 AutoCompleteTextBox.xaml 和 AutoCompleteTextBox.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 的文本;第二部分是可见的建议项部分。
此外,我们还需要一些额外的调整:
- 我们需要通过点击 TextBlock 来输入文本,因此我们将 ScrollViewer 上的 IsHitTestVisible 设置为 False。
- TextBlock 需要绑定到 TextBox 的 Padding 和 TextWrapping 属性。
- 为了使文本换行正常工作,我们需要更改 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 注册 PreviewKeyDown 和 SelectionChanged 事件。
<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 中,当建议项不存在时,我们无法滚动到建议项的末尾。稍后我们将在代码中同步 Text 和 Input 属性。
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
- 初始版本。