0
votes

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! :)

2

2 Answers

0
votes

Setting the combobox style to ComboBoxStyle.DropDownList always returns "" (empty string) as SelectedText (reference source)

public string SelectedText 
{
    get 
    {
        if (DropDownStyle == ComboBoxStyle.DropDownList) 
            return "";
        return Text.Substring(SelectionStart, SelectionLength);
    }
    {
        // see link
    }
}

SelectedValue is a member inherited from ListControl and requires the data to be managed (reference source).

public object SelectedValue {
get 
{
    if (SelectedIndex != -1 && dataManager != null ) 
    {
        object currentItem = dataManager[SelectedIndex];
        object filteredItem = FilterItemOnProperty(currentItem, valueMember.BindingField);
        return filteredItem;
    }
    return null;
}
set 
{
    // see link
}
0
votes

I managed to get it working with using extension methods and reflection. It's working well, although I'm still hoping to find better solution. I've created extension class:

using System.Linq.Dynamic;

namespace JDE_Scanner_Desktop.Static
{
    static class Extensions
    {
        public static int GetSelectedValue<T>(this ComboBox combobox)
        {
            return (int)typeof(T).GetProperty(combobox.ValueMember).GetValue(combobox.SelectedItem);
        }

        public static void SetSelectedValue<T>(this ComboBox combobox, int? selectedValue)
        {
            if(selectedValue != null)
            {
                combobox.SelectedItem = combobox.Items.Cast<T>().Where(combobox.ValueMember + $"={selectedValue}").FirstOrDefault();
            }
        }
    }
}

Then I'm setting item to be selected with cmbPart.SetSelectedValue<Part>(this.PartId); and I'm getting selcted item's SelectedValue with cmbPart.GetSelectedValue<Part>();.

Of course I'm open for other solutions!