Why does the SelectedIndexChanged event fire in a ListBox when the selected item is modified?

Question

We are given a Windows Form Application created from Microsoft Visual Studio's template (designer code on PasteBin 1 2 3 4) with a default ListBox exampleListBox and Button exampleButton.

We populate the ListBox with numbers from 1 to 10.

for (int i = 0; i < 10; ++i)
{
    exampleListBox.Items.Add(i);
}

We then add two event handlers.

exampleListBox_SelectedIndexChanged will simply write to the console the currently selected index.

private void exampleListBox_SelectedIndexChanged(object sender, EventArgs e)
{
    Console.WriteLine(exampleListBox.SelectedIndex);
}

exampleButton_Click will set the item at the currently selected index to be itself. So effectively, this should change absolutely nothing.

private void exampleButton_Click(object sender, EventArgs e)
{
    exampleListBox.Items[exampleListBox.SelectedIndex] = exampleListBox.Items[exampleListBox.SelectedIndex];
}

When the button is clicked, I expect that nothing will happen. However, this is not the case. Clicking the button fires the exampleListBox_SelectedIndexChanged event even though the SelectedIndex has not changed.

For example, if I click the item at index 2 in exampleListBox, then exampleListBox.SelectedIndex will become 2. If I press exampleButton, then exampleListBox.SelectedIndex is still 2. Yet, then exampleListBox_SelectedIndexChanged event fires.

Why does the event fire even though the selected index wasn't changed?

Furthermore, is there anyway to prevent this behavior from occurring?


Show source
| c#   | listbox   | winforms   | selectedindexchanged   | .net-4.5.2   2017-01-03 19:01 1 Answers

Answers ( 1 )

  1. 2017-01-04 13:01

    When you modify an item in the ListBox (or, actually, an item in the ListBox's associated ObjectCollection), the underlying code actually deletes and recreates the item. It then selects this newly-added item. Therefore, the selected index has been changed, and the corresponding event is raised.

    I have no particularly compelling explanation for why the control behaves this way. It was either done for programming convenience or was simply a bug in the original version of WinForms, and subsequent versions have had to maintain the behavior for backwards-compatibility reasons. Furthermore, subsequent versions have had to maintain the same behavior even if the item was not modified. This is the counter-intuitive behavior that you're observing.

    And, regrettably, it is not documented—unless you understand why it is happening, and then you know that the SelectedIndex property actually is getting changed behind the scenes, without your knowledge.

    Quantic left a comment pointing to the relevant portion of the code in the Reference Source:

    internal void SetItemInternal(int index, object value) {
        if (value == null) {
            throw new ArgumentNullException("value");
        }
    
        if (index < 0 || index >= InnerArray.GetCount(0)) {
            throw new ArgumentOutOfRangeException("index", SR.GetString(SR.InvalidArgument, "index", (index).ToString(CultureInfo.CurrentCulture)));
        }
    
        owner.UpdateMaxItemWidth(InnerArray.GetItem(index, 0), true);
        InnerArray.SetItem(index, value);
    
        // If the native control has been created, and the display text of the new list item object
        // is different to the current text in the native list item, recreate the native list item...
        if (owner.IsHandleCreated) {
            bool selected = (owner.SelectedIndex == index);
            if (String.Compare(this.owner.GetItemText(value), this.owner.NativeGetItemText(index), true, CultureInfo.CurrentCulture) != 0) {
                owner.NativeRemoveAt(index);
                owner.SelectedItems.SetSelected(index, false);
                owner.NativeInsert(index, value);
                owner.UpdateMaxItemWidth(value, false);
                if (selected) {
                    owner.SelectedIndex = index;
                }
            }
            else {
                // NEW - FOR COMPATIBILITY REASONS
                // Minimum compatibility fix for VSWhidbey 377287
                if (selected) {
                    owner.OnSelectedIndexChanged(EventArgs.Empty); //will fire selectedvaluechanged
                }
            }
        }
        owner.UpdateHorizontalExtent();
    }
    

    Here, you can see that, after the initial run-time error checks, it updates the ListBox's max item width, sets the specified item in the inner array, and then checks to see if the native ListBox control has been created. Virtually all WinForms controls are wrappers around native Win32 controls, and ListBox is no exception. In your example, the native controls has definitely been created, since it is visible on the form, so the if (owner.IsHandleCreated) test evaluates to true. It then compares the text of the items to see if they are the same:

    • If they are different, it removes the original item, removes the selection, adds a new item, and selects it if the original item was selected. This causes the SelectedIndexChanged event to be raised.

    • If they are the same and the item is currently selected, then as the comment indicates, "for compatibility reasons", the SelectedIndexChanged event is manually raised.

    This SetItemInternal method we just analyzed gets called from the setter for the ListBox.ObjectCollection object's default property:

    public virtual object this[int index] {
        get {
            if (index < 0 || index >= InnerArray.GetCount(0)) {
                throw new ArgumentOutOfRangeException("index", SR.GetString(SR.InvalidArgument, "index", (index).ToString(CultureInfo.CurrentCulture)));
            }
    
            return InnerArray.GetItem(index, 0);
        }
        set {
            owner.CheckNoDataSource();
            SetItemInternal(index, value);
        }
    }
    

    which is what gets invoked by your code in the exampleButton_Click event handler.

    There is no way to prevent this behavior from occurring. You will have to find a way to work around it by writing your own code inside of the SelectedIndexChanged event handler method. You might consider deriving a custom control class from the built-in ListBox class, overriding the OnSelectedIndexChanged method, and putting your workaround here. This derived class will give you a convenient place to store state-tracking information (as member variables), and it will allow you to use your modified ListBox control as a drop-in replacement throughout your project, without having to modify the SelectedIndexChanged event handlers everywhere.

    But honestly, this should not be a big problem or anything that you even need to work around. Your handling of the SelectedIndexChanged event should be trivial—just updating some state on your form, like dependent controls. If no externally-visible change took place, the changes that it triggers will basically be no-ops themselves.

◀ Go back