Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / desktop / WPF

AutoCompleteTextBox for comma-seperated terms in C# and WPF following the MVVM approach

0.00/5 (No votes)
9 Sep 2024CPOL7 min read 5.1K   36  
A walkthrough and source code for an auto-complete TextBox for comma-seperated terms in C# and WPF following the MVVM approach.
In this article we will develop an auto-complete TextBox for comma-seperated terms. The suggestions are overlays that are intended to support rather than disturb you when writing. Suggestions that have already been entered are not displayed again and the capitalization is corrected automatically. With two bindings for the input and a list of possible suggestions we follow the MVVM approach. A suggestion is never sent to the ViewModel until it has been accepted.

Download AutoCompleteTextBox.zip

Introduction

I wanted an auto-complete TextBox for entering actor names. Existing solutions either put the suggestion directly into the TextBox, which is essentially no longer a suggestion and is immediately sent to a ViewModel. Or a ComboBox is displayed for selection, which I find annoying when entering names. Here I show my solution, where the suggestions are displayed to support but do not interfere with input. Suggestions are only sent to a ViewModel when they have been accepted. In addition, suggestions that have already been entered are not displayed again and the capitalization is automatically corrected.

Image 1

Using the code

In the SourceCode folder there is the folder AutoCompleteTextBoxDemo with the demo source code and the folder WPFControls with the AutoCompleteTextBox control. There are three ways to use it in your project:

  1. Add the WPFControls project to your solution and add a reference to your project
  2. Use the DLL WPFControls.dll and add a reference to your project
  3. Copy the AutoCompleteTextBox.xaml and AutoCompleteTextBox.xaml.cs to your project folder and change the namespace of both files to your namespace

After that you can use the AutoCompleteTextBox as follows:

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

With this namespace (if you use option 1 or 2 described above):

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

DogBreeds is a (comma-seperated) string property and DogBreedSuggestions is a list of all possible suggestions, both have to be part of your ViewModel. Name them as you like. For more information look at the demo source code.

Using the demo

Run AutoCompleteTextBoxDemo.exe in the Demo folder and type in your favorite cat breeds in a NoWrap-TextBox and your favorite dog breeds in a Wrap-TextBox.

Walkthrough

UI/XAML

In the following XAML code we use a normal TextBox and change the template. The Border element and the ScrollViewer "PART_ContentHost" are the default parts of a TextBox template. We are adding a Grid and a TextBlock for the overlaying suggestion that is displayed in gray. The TextBlock also needs a ScrollViewer, so that the overlay can scroll equal to the TextBox.

XML
<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>

The TextBlock consists of two text parts (Inlines). First, the text of our TextBox. And second the visible part of the found suggestion.

Additionally we need some more adjustments:

  1. We have to click through the TextBlock to enter text, so we set IsHitTestVisible to False on its ScrollViewer.
  2. The TextBlock needs bindings to the Padding and TextWrapping properties of the TextBox.
  3. For text wrapping to work properly we have to change the margin and the visibility of the horizontal scroll bar for the ScrollViewer of the TextBlock. Otherwise the wrapping of the TextBlock is not always equal to the wrapping of the TextBox.
  • "Wrap" and "WrapWithOverflow": the horizontal scroll bar is disabled and the margin is set to 2 on the left side and -2 on the right side
  • "NoWrap": the horizontal scroll bar is enabled but hidden, and the margin is 2 on both sides

Presentation logic

Next we have to implement some presentation logic. We override the OnApplyTemplate method to get references to both ScrollViewers and the suggestion text. We will need them later to set the suggestion and to control the scrolling behavior. At last we set the foreground of the overlay to the foreground of the TextBox and then set the foreground of the TextBox to Transparent. We just need the visible overlay with the two colors. More than one color is not possible on a TextBox, so we use it for the input only.

C#
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;
}

The ScrollViewer of the TextBlock have to scroll equal to the ScrollViewer of the TextBox. We register to the ScrollChanged event in XAML...

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

...and synchronize the offsets of the two ScrollViewers in the code.

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

We also ensure that no one can select or enter the suggestion part of the TextBox. For that we register to the PreviewKeyDown and the SelectionChanged events of the TextBox in XAML.

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

In the code we limit the CaretIndex and a possible selection to the end of the Input property (which is the part without the suggestion).

C#
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);
    }
}

The last step is to scroll the text to the right end when no text wrapping is set and the CaretIndex is set to the end of the input. Because of the previous step we can't access the suggestion anymore, so the scrolling won't work automatically and otherwise we won't see the whole suggestion.

C#
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

With the wish to support MVVM, we need two DepencyProperties to bind a list of strings, which represents the possible suggestions, and the actual input (without the suggestion).

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

Why don't we use the Text property instead of a new Input property? We just want to bind the actual input, but we have to put the whole text (with the suggestion) into the TextBox. This is because of the text wrapping that won't work correctly when the suggestion is missing. Also, in a single-line TextBox, we can't scroll to the end of the suggestion when it is not present. We will synchronize the Text and the Input property later in the code.

C#
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);
}

The suggestion list can be a List, an ObservableCollection, a ReadOnlyCollection, or anything that can list strings.

When the Input property is changed by the ViewModel we need to update the TextBox and reset the suggestion. To do this, we use the PropertyChangedCallback. However, if the Input value is the same as the text without the suggestion, the changes were made by our code. In this case, we need to exit the method to avoid resetting a suggestion.

C#
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;
}

Main logic

The main part of the logic is handled when the text changed. So we register to the TextChanged event of the TextBox in XAML:

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

In the TextChanged event handler, before we start with the suggestion logic, we check if the text is equal to the overlaying text including the suggestion. We need to set the text later in this method, so this event handler is triggered again. We just need to react to user input, so if we have set it ourselves it is equal and we exit the method.

Next we set the Input property to the text part without the suggestion.

We will give only suggestions for the last term. Therefore we check if the user changed the last term. If not, we exit the method.

C#
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();
        }
    }
}

After that we start with the main logic. We get all used suggestion by splitting the input. The last of this suggestions is our search term. If it is not empty, we look for a term in the suggestion list that begins with the search term. With the Except method we omit all suggestions already entered so that only new suggestions are made.

If the search term is equal to the found suggestion, that is when the user entered the whole suggestion himself, we accept it (as described below). Else we set the suggestion overlay text to complete the entered term. We also have to set the text of the TextBox. It must be identical to the overlay, otherwise neither scrolling nor wrapping will work correctly. We also have to save and restore the index of the caret because it would be set to 0 after changing the text.

At last again we scroll to the right as described above in the PreviewKeyDown event handler so that we can see the whole suggestion when there is no wrapping set.

To accept a suggestion, we set the text of the TextBox and the Input property to the whole text including the suggestion. We also reset the (current) suggestion. Because the CaretIndex is set to 0 when we change the text, we have to reset it to the end of the text.

C#
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;
}

One trick: We remove the suggestion and then add it again. This corrects the capitalization so that the user can type faster and the entered suggestion is always equal to the suggestion from the list.

Of course, the user can also accept or reject the suggestion himself. We add a switch statement to the PreviewKeyDown event handler.

C#
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;
    }
}

On enter we accept the suggestion, on escape we reject it by resetting the suggestion and setting the text of the TextBox to the value of the Input property which is the text without the suggestion.

History

  • September 9, 2024 - version 1.0
    • Initial version.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)