Animation

Drop-down controls are most commonly employed when the user is required to make a single choice from a discrete list of items. It does not matter whether the items are static or dynamic, however good UI design dictates that the list of choices should not be too long. Requiring users to scroll through large drop-down lists is slow and inefficient, so a search facility is often provided in this scenario. However, search functionality can add complexity to the UI and disrupt its flow (especially if the search opens in a separate window).

A good compromise is an inline search, contained entirely within the drop-down control. The Windows Forms ComboBox control achieves this to some extent through AutoComplete, however it has some drawbacks:

  • It is designed to allow arbitrary text input (i.e. DropDown style vs DropDownList style)
  • Matches against the start of strings only
  • If using a custom source, all strings must be loaded into memory first

In order to develop a better solution for long lists of choices where a discrete selection must be made, I decided to extend my earlier ComboTreeBox control to support searching.

Enhancements to ComboTreeBox and DropDownControlBase

In order to support the search functionality and differences in control behaviour, some enhancements to the base classes were required. These included:

  • Being able to paint the control in the style of an editable combo box
  • Controlling whether the ToolStripDropDown handles keyboard events
  • Implementing ICloneable on ComboTreeNode to allow nodes to be duplicated
  • Excluding certain nodes from selection by the user

These changes were, by and large, trivial. All were implemented with the aim of leaving the default behaviour of the ComboTreeBox control unchanged.

One important note, however: When the control is painted in the style of an editable combo box, the drop-down glyph is reproduced manually and is therefore dependant on the operating system version. In order to accurately detect Windows 10, the application must include a manifest file with the appropriate supportedOS element, e.g:

<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
   <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
      <application>
         <!-- Windows 10 -->
         <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
      </application>
   </compatibility>
</assembly>

Implementing text input

Since the DropDownControlBase class directly derives from the Windows Forms Control class (not TextBoxBase or ListControl), it does not have access to any built-in support for text input. Since we only intend to capture a short search term entered by the user, our requirements for text input are very basic.

I created a reusable TextServices class to allow this basic functionality to be added to any control, regardless of its base class. It handles mouse and keyboard events in a pass-through fashion, manipulating an internal StringBuilder instance that represents the single line of text in the control. It is initialised with a reference to the control upon which it operates and a callback method which provides the bounds of the text box (in the case of our control, this is the bounds of the control excluding the drop-down button).

It supports:

  • Character input
  • Control keys (caret movement, selection, clipboard commands)
  • Mouse events (caret position, selection, context menu)
  • Clipboard commands (cut, copy, paste)
  • Painting the text and highlight within the text box bounds

Text input can be toggled using the Begin() and End() methods.

Creation and manipulation of the caret is performed using P/Invoke. The relevant Win32 functions (all from User32.dll) are:

  • bool CreateCaret(IntPtr hWnd, IntPtr hBitmap, int nWidth, int nHeight)
  • bool DestroyCaret()
  • bool HideCaret(IntPtr hWnd)
  • bool SetCaretPos(int x, int y)
  • bool ShowCaret(IntPtr hWnd)

DropDownSearchBox

The basic operation of the searchable drop-down control is as follows:

  1. The control initially contains some nodes. This may only be a subset of the total number of choices (e.g. the most recently/frequently used).
  2. Opening the drop-down, clicking within the bounds of the text box or typing into the control while it has input focus will replace the caption on the control with a blinking caret, and start accepting text input.
  3. If no text has been entered, the initial drop-down items will continue to be displayed.
  4. If the length of the string is between 1 and (MinSearchTermLength - 1) characters, the drop-down will display a message inviting the user to complete their search term.
  5. Once the minimum length has been satisfied – and for each subsequent change to the string – the control will invoke the PerformSearch event in a separate thread. If a search is already in progress, it will first be cancelled through the CancellationToken.
  6. If the search runs to completion, nodes representing the results are added to drop-down. The drop-down now contains only the items that matched the search term.
  7. Selecting one of the search result nodes will invoke the CommitSearch event. If the node was part of the initial collection (or if it is equivalent to one such node) then the existing node will become the selected node. Otherwise, a copy of the node will be added to the collection. (Alternatively, the user can cancel out of the search by clearing the text, closing the drop-down or causing the control to lose input focus.)
  8. The control will now behave as normal, until the user re-enters “search mode”.

Since the drop-down displays different sets of nodes depending on the state of the control, the searchable drop-down control adds NormalNodes and AllNormalNodes properties to keep track of the nodes which are initially displayed (mirroring Nodes and AllNodes on the base class).

If the PerformSearch event is not handled or overridden, the default logic is to perform a linear search on the initial nodes in the drop-down, using case-insensitive matching on the nodes’ Text property. Normally, however, you would handle this event and substitute your own search logic (e.g. querying an SQL database, calling a web service, reading from a stream, etc).

protected virtual void OnPerformSearch(PerformSearchEventArgs e) {
   if (PerformSearch != null) PerformSearch(this, e);

   if (!e.Handled) {
      // default search logic
      foreach (ComboTreeNode node in AllNormalNodes) {
         e.CancellationToken.ThrowIfCancellationRequested();

         if (DefaultSearchPredicate(node, e.SearchTerm)) {
            e.Results.Add(node.Clone());
         }
      }
   }
}

protected virtual bool DefaultSearchPredicate(ComboTreeNode node, string searchTerm) {
   return node.Selectable 
      && (node.Text.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase) >= 0);
}

It is not necessary to handle or override the CommitSearch event unless you need to differentiate between nodes with identical values for the Name or Text properties, although it may be more elegant to test for equality using a unique key of some sort.

protected virtual void OnCommitSearch(CommitSearchEventArgs e) {
   if (CommitSearch != null) CommitSearch(this, e);

   if (!e.Handled) {
      ComboTreeNode match = null;

      // try to find an equivalent match in the normal list
      if (e.Result != null) {
         if (OwnsNode(e.Result)) {
            match = e.Result;
         }
         else if ((match = AllNormalNodes.FirstOrDefault(
            x => DefaultEquivalencePredicate(e.Result, x))) == null) {
            // search result not in original collection; add
            match = e.Result.Clone();
            Nodes.Add(match);
         }
      }

      SelectedNode = match;
   }
}

protected virtual bool DefaultEquivalencePredicate(ComboTreeNode result, ComboTreeNode test) {
   if (!String.IsNullOrEmpty(result.Name)) {
      if (String.Equals(result.Name, test.Name, StringComparison.OrdinalIgnoreCase)) return true;
   }

   if (!String.IsNullOrEmpty(result.Text)) {
      if (String.Equals(result.Text, test.Text, StringComparison.OrdinalIgnoreCase)) return true;
   }

   return false;
}

Being derived from ComboTreeBox, the searchable drop-down control supports both flat and hierarchical data, however for simplicity’s sake it is recommended that search results do not contain nested nodes. You can exclude nodes from the search by setting their Selectable property to false; likewise, you can include informational nodes in the search results that cannot be selected.

Example usage

You can use the built-in search functionality of the DropDownSearchBox control simply by adding it to a form and populating it with nodes (as you would a ComboTreeBox). Try typing into the editable part of the control; the nodes will be filtered according to the search term you enter.

Below is a less trivial example where the search runs against an external data source. The following example assumes the existence of a DropDownSearchBox (dsbExternal) and an ADO.NET DataTable (_table) containing a list of dictionary words:

protected override void OnShown(EventArgs e) {
   dsbExternal.BeginUpdate();
   dsbExternal.Nodes.Add("example");
   dsbExternal.Nodes.Add("nodes");
   dsbExternal.Nodes.Add("already");
   dsbExternal.Nodes.Add("in");
   dsbExternal.Nodes.Add("list");
   dsbExternal.EndUpdate();
   dsbExternal.PerformSearch += dsbExternal_PerformSearch;
}

void dsbExternal_PerformSearch(object sender, PerformSearchEventArgs e) {
   // filter the DataTable using the LIKE operator
   string adoFilter = String.Format("Word LIKE '{0}%'", e.SearchTerm.Replace("'", "''"));

   // create a node for each matching DataRow
   foreach (DataRow dr in _table.Select(adoFilter)) {
      e.CancellationToken.ThrowIfCancellationRequested();
      e.Results.Add(new ComboTreeNode(dr.Field(0)));
   }
}

Download

Visit my Drop-Down Controls project page to download the source and binaries for the DropDownSearchBox control.

Leave a reply

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong> 

required