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

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.