Ioan Lazarciuc's Weblog I program, therefore I exist.

1Jun/0823

Auto Complete for Textboxes in WPF

Introduction

It is very common when entering a search string in a textbox to get a small list of valid search results that match the search string entered so far. This feature is called auto complete and it has been widely used in web pages and desktop applications alike.

From the developer's point of view, the auto complete feature has been implemented as part of the TextBox control in Windows Forms. That being said, I had a bit of a surprise when I could not find the functionality in the Windows Presentation Foundation TextBox control. Upon further online searches on the matter, I discovered that, in fact, auto complete has not been implemented for textboxes in WPF. The purpose of this post is to suggest (no pun intended) an implementation for the missing feature.

Functional Description

The entity we wish to create is a WPF user control that can be instantiated both through Xaml and code (C# or VB). We can either inherit from TextBox and add properties and methods required for auto complete, or we can create a control that can "wire up" the auto complete feature for any targeted TextBox control. The first approach not recommended due to the fact that a ListBox must be added to a TextBox. I chose to follow the second approach, perhaps being inspired by the AutoCompleteExtender in the ASP.NET AJAX Control Toolkit and also because it requires minimum intervention on existing WPF code (no need to change the type of many TextBox controls). Also, I did not include the targeted TextBox inside the custom control, in order to be able to apply this functionality on already existing code. The variant with the TextBox included can be found here.

A finite number of suggestions should be displayed as soon as a minimum number of  characters are typed, and then the suggestion list should be updated with each new modification to the search string. The maximum number of suggestions displayed should be configurable by means of a property. The user should be able to highlight a suggestion using the up/down keys, and be able to select a suggestion by pressing Tab or Enter in order to set the text of the target TextBox to that value. The means by which the suggestions are returned (from a web service, or simple method, etc.) should be as configurable as possible.

Implementation

The starting point for the TextBoxAutoCompleteProvider is a UserControl. This control will contain a Popup (named pop) and a ListBox (named lstBox) inside the Popup. the Popup is used to hide and show the suggestions list next to the targeted TextBox.

The control will use dependency properties instead of classic properties in order to facilitate data binding. It will also implement the INotifyPropertyChanged interface in order to automatically update data bindings. A routed event named SelectionChanged is defined in order to be raised every time the selected suggestion changes.

The DisplayMemberPath and SelectedValuePath are strings representing properties available in the objects returned as suggestions. If the strings are empty or null, then the ToString method will be used. The SelectedItem and SelectedValue properties connect directly to their namesakes from the ListBox. The MovesFocus property determines whether the focus changes to the next control after selecting a suggestion. The MinTypedCharacters property sets the minimum length of the search string in order to start displaying suggestions. MaxResults controls the maximum number of suggestions to be displayed and AvailableSuggestions shows the number of suggestions currently displayed in the ListBox (it might be smaller than MaxResults).

SearchMethod is a delegate of type AutoCompleteEventHandler, used to store the method that will be called in order to obtain suggestions. The delegate signature takes the search string and the maximum number of results as parameters and returns a collection of objects. In order to be as least restrictive as possible, the type of collection returned is IEnumerable. By using a delegate, the source of the data behind the suggestions can be arbitrary (database, web service, memory objects, etc.).

The main property of the control is TargetControl, of type TextBox. Whenever the value of the property changes, the event handlers are removed from the old TextBox and added to the new one. The code for those event handlers, together with the code that makes the changes is displayed below.

   1: private static void TargetControl_Changed(DependencyObject d, DependencyPropertyChangedEventArgs e)
   2:         {
   3:             if (e.OldValue != e.NewValue)
   4:             {
   5:                 TextBoxAutoCompleteProvider me = d as TextBoxAutoCompleteProvider;
   6:                 TextBox oldv = e.OldValue as TextBox;
   7:                 TextBox newv = e.NewValue as TextBox;
   8:                 if (oldv != null)
   9:                 {
  10:                     oldv.LostFocus -= new RoutedEventHandler(me.TargetControl_LostFocus);
  11:                     oldv.GotFocus -= new RoutedEventHandler(me.TargetControl_GotFocus);
  12:                     oldv.KeyUp -= new KeyEventHandler(me.TargetControl_KeyUp);
  13:                     oldv.PreviewKeyUp -= new KeyEventHandler(me.TargetControl_PreviewKeyUp);
  14:                     oldv.PreviewKeyDown -= new KeyEventHandler(me.TargetControl_PreviewKeyDown);
  15:                     
  16:                 }
  17:                 if (newv != null)
  18:                 {
  19:                     me.pop.PlacementTarget = newv;
  20:                     newv.LostFocus += new RoutedEventHandler(me.TargetControl_LostFocus);
  21:                     newv.GotFocus += new RoutedEventHandler(me.TargetControl_GotFocus);
  22:                     newv.KeyUp += new KeyEventHandler(me.TargetControl_KeyUp);
  23:                     newv.PreviewKeyUp += new KeyEventHandler(me.TargetControl_PreviewKeyUp);
  24:                     newv.PreviewKeyDown += new KeyEventHandler(me.TargetControl_PreviewKeyDown);
  25:                 }
  26:             }
  27:         }
  28:  
  29:         private void TargetControl_PreviewKeyDown(object sender, KeyEventArgs e)
  30:         {
  31:             if (e.Key == Key.Tab && lstBox.SelectedItem != null)
  32:             {
  33:                 pop.IsOpen = false;
  34:                 TargetControl.Text = String.IsNullOrEmpty(DisplayMemberPath) ?
  35:                                      lstBox.SelectedItem.ToString() :
  36:                                      lstBox.SelectedItem.GetType().GetProperty(
  37:                                      DisplayMemberPath).GetValue(lstBox.SelectedItem, null).ToString();
  38:                 if (MovesFocus)
  39:                     TargetControl.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
  40:                 e.Handled = true;
  41:             }
  42:         }
  43:  
  44:         private void TargetControl_PreviewKeyUp(object sender, KeyEventArgs e)
  45:         {
  46:             if (e.Key == Key.Escape)
  47:             {
  48:                 pop.IsOpen = false;
  49:                 lstBox.SelectedItem = null;
  50:                 e.Handled = true;
  51:             }
  52:             if (IsTextChangingKey(e.Key))
  53:             {
  54:                 Suggest();
  55:             }
  56:         }
  57:  
  58:         private bool IsTextChangingKey(Key key)
  59:         {
  60:             if (key == Key.Back || key == Key.Delete)
  61:                 return true;
  62:             else
  63:             {
  64:                 KeyConverter conv = new KeyConverter();
  65:                 string keyString = (string)conv.ConvertTo(key, typeof(string));
  66:  
  67:                 return keyString.Length == 1;
  68:             }
  69:         }
  70:  
  71:         private void TargetControl_KeyUp(object sender, KeyEventArgs e)
  72:         {
  73:             if (e.Key == Key.Up)
  74:             {
  75:                 if (lstBox.SelectedIndex > 0)
  76:                 {
  77:                     lstBox.SelectedIndex--;
  78:                 }
  79:             }
  80:             if (e.Key == Key.Down)
  81:             {
  82:                 if (lstBox.SelectedIndex < lstBox.Items.Count - 1)
  83:                 {
  84:                     lstBox.SelectedIndex++;
  85:                 }
  86:             }
  87:             if (e.Key == Key.Enter)
  88:             {
  89:                 SetTextAndHide();
  90:             }
  91:         }
  92:  
  93:         private void SetTextAndHide()
  94:         {
  95:             pop.IsOpen = false;
  96:             if (lstBox.SelectedItem != null)
  97:             {
  98:                 TargetControl.Text = String.IsNullOrEmpty(DisplayMemberPath) ?
  99:                                      lstBox.SelectedItem.ToString() :
 100:                                      lstBox.SelectedItem.GetType().GetProperty(
 101:                                         DisplayMemberPath).GetValue(lstBox.SelectedItem, null).ToString();
 102:                 itemsSelected = true;
 103:                 if (MovesFocus)
 104:                     TargetControl.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
 105:             }
 106:         }
 107:  
 108:         private void TargetControl_GotFocus(object sender, RoutedEventArgs e)
 109:         {
 110:             if (itemsSelected)
 111:                 itemsSelected = false;
 112:             else Suggest();
 113:         }
 114:  
 115:         private void TargetControl_LostFocus(object sender, RoutedEventArgs e)
 116:         {
 117:             if (!pop.IsKeyboardFocusWithin)
 118:                 pop.IsOpen = false;
 119:         }
 120:  
 121:         private void Suggest()
 122:         {
 123:             // Skip if TargetControl are not defined or if not enough characters typed
 124:             if (TargetControl == null) return;
 125:             if (TargetControl.Text.Length < MinTypedCharacters)
 126:             {
 127:                 pop.IsOpen = false;
 128:                 lstBox.ItemsSource = null;
 129:                 return;
 130:             }
 131:             if (SearchMethod == null) throw new NullReferenceException("SeachMethod cannot be null.");
 132:  
 133:             IEnumerable res = SearchMethod(TargetControl.Text, MaxResults);
 134:  
 135:             lstBox.ItemsSource = res;
 136:             if (lstBox.Items.Count > 0)
 137:             {
 138:                 lstBox.SelectedIndex = 0;
 139:             }
 140:  
 141:             pop.VerticalOffset = TargetControl.ActualHeight;
 142:             pop.IsOpen = true;
 143:         }

The private Suggest method gets called whenever the targeted TextBox receives the input focus, or the search string has changed as a result of user input. This method is responsible for invoking the method stored in the SearchMethod delegate if some conditions are met (minimum number of characters, Search method and TargetControl specified).

When a suggestion is selected (either by pressing Enter, Tab or by using the mouse), reflection is used to obtain the value of the property specified by DisplayMemberPath within the SelectedItem and to set the Text of the TextBox to this value. If DisplayMemberPath is empty or null, then the ToString method is used to fill in the TextBox.

The other properties defined by TextBoxAutoCompleteProvider offer customization options for the list of suggestions (ItemTemplate, ItemTemplateSelector, ItemsPanel).

Source code for the whole project, as well as a sample application can be found here.

Conclusion

The auto complete functionality is not difficult to implement, and by not extending the functionality of TextBox, applying auto complete to new and existing projects is easy. The only restriction is that one cannot specify the method to store in the SeachMethod delegate from Xaml code, only from C#(or VB.NET). Further development can be targeted at providing more customization properties for the suggestion list and at creating a converter from string to a delegate, in order to be able to specify the search method from Xaml code.

Update

After the feeback provided, I changed the control to allow selecting an option in the ListBox through mouse clicks. The new source code is available here. Tracking the position of the target control is not supported for the Popup control. An adorner would have to be used for this. If I find the time to redesign the control to use adorners, then I will publish the update.

Update 2

After Harry’s comment, I fixed a bug that would reset the SelectedIndex property on the list box. Also I exposed the SelectedIndex property. The new source code is available here.

Comments (23) Trackbacks (0)
  1. Thanks for this – I never would have thought to use a popup to achieve this (I tried all sorts of things before reading this, such as binding the position of the textbox to a list control with little success). What I really like about your solution is that it is elegant – it was simple to plug into my existing code.

    The only shortcoming is asynchronous handling of calls to the search method – as I’m reluctant to significantly change your code I’ve implemented it by making the Suggest method in your control public and abstracting the asynchronous call handling to a separate suggestion provider class.

    Once again, thank you – this has been a really useful push in the right direction for me (and saved several days of head scratching too!)

  2. Thank you very much. You’ve saved me a lot of time.
    Just one question – why haven’t you used TextChanged event as a trigger for Suggest?

    Thanks again.
    Igor.

  3. I already needed to handle key events for the textbox, so i figured there is no need to create another event handler for the TextChanged event.

  4. Hey!

    I was wondering how to implement a mouse click event that will do the same as key up/key down + enter? Basically, the list drops down, i see my desired selection, i click on it and the text appears in the textbox. I’m trying to make this work with listbox.LeftMouseButtonDown event but it only fires when i click at the top border of the list…which seems strange, as the mouseover event fires properly :/ thanks in advance for the help!

    regards, tadej

  5. Hi,
    Nice control! Just 2 remarks:
    1- Can’t seem to select using the mouse.
    2- When moving the window the popup stays in the same place
    Also couldn’t be used with a combobox?

    Cheers,
    Joao

  6. To tadj and Jo:
    I will try to fix the mouse selection issue. Already have ideas how to fix it, just need some time to do it :)
    To Jo:
    The one with the position, i need to have a look over the cause, but it sould be no big deal to fix that as well.
    Conclusion:
    Thanks a lot for the feedback, i’m trying to find time and fix the issues.

  7. What are the ItemTemplate/ItemTemplateSelector properties for? How would the TextBox know how to display the proper text from a data template defined for the items?

  8. You would use ItemTemplate and ItemTemplateSelector in order to display whatever WPF content for each item in the suggestion box that pops up. They are in fact the same as the corresponding properties defined in the ListBox class. You can find more details on how those are used on MSDN.
    The TextBox has no idea it has been decorated with autocomplete functionality. The TextBoxAutoCompleteProvider is responsible with showing the supplementary UI. It uses a LisBox control internally. You can see this if you look at the XAML code for the TextBoxAutoCompleteProvider.

  9. Excellent, thanks v. much :)

  10. I hooked the WPFAutoComplete class into my Visual Basic .NET project and it works fine.
    Thanks for providing it.
    I have several other data elements that are related to the item the user selects in the auto-complete textbox so I wanted to get the SelectedIndex back and use it to pickup theses items from my array.

    I added the following code:

    public object SelectedIndex
    {
    get { return lstBox.SelectedIndex; }
    set { lstBox.SelectedIndex = Convert.ToInt32(value); NotifyPropertyChanged(“SelectedIndex”); }

    }

    This works fine when the selection is made with the arrow keys, but not when an item is selected with the mouse. The mouse selection always returns zero.
    Any ideas.

  11. Thanks Harry. I had a bug that restarted the suggestion logic upon closing the popup. I updated the source code and posted it online.

  12. This is beautifully done. I did get the latest source code but still can’t do a mouse selection, and I don’t see any mouse-handling code in there . . .

  13. Excellent component, just what I was looking for !

    Just one question, is it free to use?

  14. Happy to help. The code is free to use and modify.

  15. I completely agree with the last post: Excellent component, just what I was looking for and much better than anything else I could find — except…

    If the search routine takes a while (say, searching a large database), then it can get bogged down running on every keystroke. I added a DelayMilliseconds property and if set to something greater than zero (300 – 500ms works well), then the search won’t occur until the user pauses their typing. This is certainly not my original idea, but I thought it would work well combined with your code. Here’s the modified code:

    //I split up the Suggest method as follows:

    private delegate void Suggest2Del();
    private System.Timers.Timer delayTimer;

    private int delayMilliseconds;
    public int DelayMilliseconds
    {
    get { return delayMilliseconds; }
    set { delayMilliseconds = value; }
    }

    private void DelayTimerElapsed(object source, System.Timers.ElapsedEventArgs e)
    {
    delayTimer.Stop();
    Dispatcher.BeginInvoke(new Suggest2Del(this.Suggest2));
    }

    private void Suggest()
    {
    // Skip if TargetControl are not defined or if not enough characters typed
    if (TargetControl == null) return;
    if (TargetControl.Text.Length < MinTypedCharacters)
    {
    pop.IsOpen = false;
    lstBox.ItemsSource = null;
    return;
    }
    if (SearchMethod == null) throw new NullReferenceException(”SeachMethod cannot be null.”);

    if (delayMilliseconds > 0)
    {
    delayTimer.Interval = delayMilliseconds;
    delayTimer.Start();
    }
    else
    Suggest2();
    }

    private void Suggest2()
    {
    IEnumerable res = SearchMethod(TargetControl.Text, MaxResults);

    lstBox.ItemsSource = res;
    if (lstBox.Items.Count > 0)
    {
    lstBox.SelectedIndex = 0;
    }

    pop.VerticalOffset = TargetControl.ActualHeight;
    pop.IsOpen = true;
    }

    //And added the last 2 lines to the constructor

    public TextBoxAutoCompleteProvider()
    {
    InitializeComponent();
    itemsSelected = false;
    delayTimer = new System.Timers.Timer();
    delayTimer.Elapsed += new System.Timers.ElapsedEventHandler(DelayTimerElapsed);
    }

  16. One thing I thought this lacked (which I added), was an ItemsSource property.

    Also, I found the popup menu to be a tad buggy and would sometimes be shown even though there were no matches. In the suggest method I fixed it (though it may have been because of my Style for popup.), by changing checking the count:
    if(lstBox.Items.Count > 0) {
    lstBox.SelectedIndex = 0;
    pop.VerticalOffset = TargetControl.ActualHeight;
    pop.IsOpen = true;
    }

  17. One thing I’ve noticed is that the list does not close if the window is moved while the list is open. Is there any way to get it to closed, I’ve looked at a few of the focus events but none seem to be fired if you click the title bar or elsewhere outside of the textbox.

  18. The Popup control used internally does not do automatic tracking of the target (because it was not designed to). To detect changes in the parent’s position, you would have to respond to the TargetControl’s LayoutChanged event and close-open the popup.
    This would however seriously damage the responsiveness of the control. A delay timer should be used for the actual close,open action, and repositioning should only occur once every X milliseconds (X needs to be determined by trial and error).
    Another thing worth mentioning is that the LayoutChanged event occurs even if the resulting position of the target remains unchanged. This allows for further improvement by keeping the screen coordinates of the target control (obtained using the PointToScreen method) and only resetting the popup when those coordinates change.

  19. For some reason I am not able to navigate the the list using up, down arrow keys (I tried all three versions). This is what I tried, I run AutoCompleteTest and then enter val, which opens up the popup and then shows 10 entries but I cannot navigate through them using the keys

  20. If you add StaysOpen="False" to the popup, it will automatically close if the user clicks elsewhere.

  21. Sorry people, my blog engine failed to report the new comments that were awaiting moderation. I will try to answer each of you in the coming days.

  22. Hi

    I’ve a question, I want to use your autocompleter with a webservice, i fetch the result in the textBox_TextChanged method. But it’s a kind of slow, is there a way to split input textbox an result list in two different threads, or some similar solution?

    ty stefan

  23. Hi

    I have one question. Is it possible to change the behavior? If so, I want open the listbox when user got focus on the textbox.

    Thanks,
    NS


Leave a comment

No trackbacks yet.