I'm trying to add a contains-like autocomplete to winforms combobox. I've started with Hovhannes Hakobyan's idea from this thread. I had to adjust it a bit because autocomplete didn't know where to search. Let me start by describing my setup:
I have a 'Part' class and the combobox is to display its 'Name' property (DisplayMember). 'Name' is also where autocomplete should search for items containing given string:
public class Part
{
public int PartId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
In form's code-behind, I'm creating new AutoCompleteBehavior object, that will handle all events for me, and I'm passing the combobox and the list of objects. Although I'm refering to 'Part' class here, I'm trying to build general soltution so I'm using generics where possible:
new AutoCompleteBehavior<Part>(this.cmbPart, parts.Items);
cmbPart.DisplayMember = "Name";
cmbPart.ValueMember = "PartId";
Below is complete AutoCompleteBehavior class:
public class AutoCompleteBehavior<T>
{
private readonly ComboBox comboBox;
private string previousSearchterm;
private T[] originalList;
public AutoCompleteBehavior(ComboBox comboBox, List<T>Items)
{
this.comboBox = comboBox;
this.comboBox.AutoCompleteMode = AutoCompleteMode.Suggest; // crucial otherwise exceptions occur when the user types in text which is not found in the autocompletion list
this.comboBox.TextChanged += this.OnTextChanged;
this.comboBox.KeyPress += this.OnKeyPress;
this.comboBox.SelectionChangeCommitted += this.OnSelectionChangeCommitted;
object[] items = Items.Cast<object>().ToArray();
this.comboBox.DataSource = null;
this.comboBox.Items.AddRange(items);
}
private void OnSelectionChangeCommitted(object sender, EventArgs e)
{
if (this.comboBox.SelectedItem == null)
{
return;
}
var sel = this.comboBox.SelectedItem;
this.ResetCompletionList();
comboBox.SelectedItem = sel;
}
private void OnTextChanged(object sender, EventArgs e)
{
if (!string.IsNullOrEmpty(this.comboBox.Text) || !this.comboBox.Visible || !this.comboBox.Enabled)
{
return;
}
this.ResetCompletionList();
}
private void OnKeyPress(object sender, KeyPressEventArgs e)
{
if (e.KeyChar == '\r' || e.KeyChar == '\n')
{
e.Handled = true;
if (this.comboBox.SelectedIndex == -1 && this.comboBox.Items.Count > 0
&& this.comboBox.Items[0].ToString().ToLowerInvariant().StartsWith(this.comboBox.Text.ToLowerInvariant()))
{
this.comboBox.Text = this.comboBox.Items[0].ToString();
}
this.comboBox.DroppedDown = false;
// Guardclause when detecting any enter keypresses to avoid a glitch which was selecting an item by means of down arrow key followed by enter to wipe out the text within
return;
}
// Its crucial that we use begininvoke because we need the changes to sink into the textfield Omitting begininvoke would cause the searchterm to lag behind by one character That is the last character that got typed in
this.comboBox.BeginInvoke(new Action(this.ReevaluateCompletionList));
}
private void ResetCompletionList()
{
this.previousSearchterm = null;
try
{
this.comboBox.SuspendLayout();
if (this.originalList == null)
{
this.originalList = this.comboBox.Items.Cast<T>().ToArray();
}
if (this.comboBox.Items.Count == this.originalList.Length)
{
return;
}
while (this.comboBox.Items.Count > 0)
{
this.comboBox.Items.RemoveAt(0);
}
this.comboBox.Items.AddRange(this.originalList.Cast<object>().ToArray());
}
finally
{
this.comboBox.ResumeLayout(true);
}
}
private void ReevaluateCompletionList()
{
var currentSearchterm = this.comboBox.Text.ToLowerInvariant();
if (currentSearchterm == this.previousSearchterm)
{
return;
}
this.previousSearchterm = currentSearchterm;
try
{
this.comboBox.SuspendLayout();
if (this.originalList == null)
{
this.originalList = this.comboBox.Items.Cast<T>().ToArray(); // backup original list
}
T[] newList;
if (string.IsNullOrEmpty(currentSearchterm))
{
if (this.comboBox.Items.Count == this.originalList.Length)
{
return;
}
newList = this.originalList;
}
else
{
newList = this.originalList.Where($"{comboBox.DisplayMember}.Contains(@0)", currentSearchterm).ToArray();
//newList = this.originalList.Where(x => x.ToString().ToLowerInvariant().Contains(currentSearchterm)).ToArray();
}
try
{
// clear list by loop through it otherwise the cursor would move to the beginning of the textbox
while (this.comboBox.Items.Count > 0)
{
this.comboBox.Items.RemoveAt(0);
}
}
catch
{
try
{
this.comboBox.Items.Clear();
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
}
}
this.comboBox.Items.AddRange(newList.Cast<object>().ToArray()); // reset list
}
finally
{
if (currentSearchterm.Length >= 1 && !this.comboBox.DroppedDown)
{
this.comboBox.DroppedDown = true; // if the current searchterm is empty we leave the dropdown list to whatever state it already had
Cursor.Current = Cursors.Default; // workaround for the fact the cursor disappears due to droppeddown=true This is a known bu.g plaguing combobox which microsoft denies to fix for years now
this.comboBox.Text = currentSearchterm; // Another workaround for a glitch which causes all text to be selected when there is a matching entry which starts with the exact text being typed in
this.comboBox.Select(currentSearchterm.Length, 0);
}
this.comboBox.ResumeLayout(true);
}
}
}
Now, the autocomplete is KIND OF working - it seeks for items containing given string and does it well. The problem is, though, that for some reason combobox's SelectedValue==null
and SelectedText=""
after an item has been selected in combobox. At the same time SelectedItem
contains proper 'Part' object and SelectedIndex
also has proper value...
Unfortunately when I'm setting combobox.SelectedValue to some value when I'm filling in the form, none item is selected in the combobox. Also, when I'm trying to get combobox.SelectedValue, it also says null (even though an item is selected). I even tried to manually set SelectedValue based on SelectedItem, but I can't set it (it's still null):
private void OnSelectionChangeCommitted(object sender, EventArgs e)
{
if (this.comboBox.SelectedItem == null)
{
return;
}
var sel = this.comboBox.SelectedItem;
this.ResetCompletionList();
comboBox.SelectedItem = sel;
string valueName = comboBox.ValueMember;
comboBox.ValueMember = "";
comboBox.SelectedValue = typeof(T).GetProperty(valueName).GetValue(sel);
}
I think that maybe it's because I'm not using combobox.DataSource property that I can't set/get SelectedValue/SelectedText, but I might be wrong here. Any ideas are welcome! :)