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 vsDropDownList
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
onComboTreeNode
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:
- 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).
- 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.
- If no text has been entered, the initial drop-down items will continue to be displayed.
- 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. - 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 theCancellationToken
. - 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.
- 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.) - 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.