Search code examples
c#wpfvirtualizationselectionchanged

Virtualization and SelectionChanged event


I am using SelectionChanged event of ListBox, but it "doesn't work".

Here is repro:

public partial class MainWindow : Window
{
    readonly List<Item> _items = new List<Item>
    {
        new Item(),
        ... // add 20 more, to have selected item outside of visible region
        new Item(),
        new Item { IsSelected = true },
        new Item(),
    };

    void listBox_SelectionChanged(object sender, SelectionChangedEventArgs e) =>
        Debug.WriteLine($"Changed {e.AddedItems.Count}/{e.RemovedItems.Count}");

    void button_Click(object sender, RoutedEventArgs e) =>
        listBox.ItemsSource = listBox.ItemsSource == null ? _items : null;
}

public class Item
{
    public bool IsSelected { get; set; }
}

and xaml:

<Grid>
    <ListBox x:Name="listBox" SelectionChanged="listBox_SelectionChanged">
        <ListBox.ItemContainerStyle>
            <Style TargetType="ListBoxItem">
                <Setter Property="IsSelected" Value="{Binding IsSelected}" />
            </Style>
        </ListBox.ItemContainerStyle>
    </ListBox>
    <Button Content="Test" HorizontalAlignment="Right" VerticalAlignment="Bottom"
            Margin="10" Click="button_Click" />
</Grid>

1. Disable virtualization

Add to list:

VirtualizingPanel.IsVirtualizing="False"

Clicking button will produce the output. Cool.


2. VirtualizationMode="Standard"

Remove that line, by default ListBox will use "Standard" virtualization:

Event is not triggered. I need to scroll to selected item to have event triggered.


3. VirtualizationMode="Recycling"

Change virtualization to:

VirtualizingPanel.VirtualizationMode="Recycling"

WTF? Even scrolling doesn't trigger event.


Question: How to get SelectionChanged event to work properly in the most performant mode without need to scroll as in "Standard" mode?


Solution

  • With virtualization, if an item doesn't have a container (ListBoxItem) associated with it, then there's no container to which that ItemContainerStyle is applied. That means your IsSelected binding won't be applied until the item is scrolled into view. Until that property is set, no selection change occurs, and SelectionChanged is not raised.

    How to get SelectionChanged event to work properly in the most performant mode without need to scroll as in "Standard" mode?

    It arguably *is* working properly. If you approach this from an MVVM angle, then you need not rely on events from UI elements. Track the item selection yourself, in your model. You could use a utility class like this:

    public interface ISelectable
    {
        bool IsSelected { get; set; }
    }
    
    public class ItemEventArgs<T> : EventArgs
    {
        public T Item { get; }
        public ItemEventArgs(T item) => this.Item = item;
    }
    
    public class SelectionTracker<T> where T : ISelectable
    {
        private readonly ObservableCollection<T> _items;
        private readonly ObservableCollection<T> _selectedItems;
        private readonly ReadOnlyObservableCollection<T> _selectedItemsView;
        private readonly HashSet<T> _trackedItems;
        private readonly HashSet<T> _fastSelectedItems;
    
        public SelectionTracker(ObservableCollection<T> items)
        {
            _items = items;
            _selectedItems = new ObservableCollection<T>();
            _selectedItemsView = new ReadOnlyObservableCollection<T>(_selectedItems);
            _trackedItems = new HashSet<T>();
            _fastSelectedItems = new HashSet<T>();
            _items.CollectionChanged += OnCollectionChanged;
        }
    
        public event EventHandler<ItemEventArgs<T>> ItemSelected; 
        public event EventHandler<ItemEventArgs<T>> ItemUnselected; 
    
        public ReadOnlyObservableCollection<T> SelectedItems => _selectedItemsView;
    
        private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
        {
            switch (e.Action)
            {
                case NotifyCollectionChangedAction.Add:
                    if (e.NewItems == null)
                        goto default;
                    AddItems(e.NewItems.OfType<T>());
                    break;
    
                case NotifyCollectionChangedAction.Remove:
                    if (e.OldItems == null)
                        goto default;
                    RemoveItems(e.OldItems.OfType<T>());
                    break;
    
                case NotifyCollectionChangedAction.Replace:
                    if (e.OldItems == null || e.NewItems == null)
                        goto default;
                    RemoveItems(e.OldItems.OfType<T>());
                    AddItems(e.NewItems.OfType<T>());
                    break;
    
                case NotifyCollectionChangedAction.Move:
                    break;
    
                default:
                    Refresh();
                    break;
            }
        }
    
        public void Refresh()
        {
            RemoveItems(_trackedItems);
            AddItems(_items);
        }
    
        private void AddItems(IEnumerable<T> items)
        {
            foreach (var item in items)
            {
                var observableItem = item as INotifyPropertyChanged;
                if (observableItem != null)
                    observableItem.PropertyChanged += OnItemPropertyChanged;
    
                _trackedItems.Add(item);
    
                UpdateItem(item);
            }
        }
    
        private void RemoveItems(IEnumerable<T> items)
        {
            foreach (var item in items)
            {
                var observableItem = item as INotifyPropertyChanged;
                if (observableItem != null)
                    observableItem.PropertyChanged -= OnItemPropertyChanged;
    
                _trackedItems.Remove(item);
    
                UpdateItem(item);
            }
        }
    
        private void OnItemPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (sender is T item)
                UpdateItem(item);
        }
    
        private void UpdateItem(T item)
        {
            if (item?.IsSelected == true && _trackedItems.Contains(item))
            {
                if (_fastSelectedItems.Add(item))
                {
                    _selectedItems.Add(item);
                    this.ItemSelected?.Invoke(this, new ItemEventArgs<T>(item));
                }
            }
            else
            {
                if (_fastSelectedItems.Remove(item))
                {
                    _selectedItems.Remove(item);
                    this.ItemUnselected?.Invoke(this, new ItemEventArgs<T>(item));
                }
            }
        }
    }
    

    When you create your ObservableCollection of items, instantiate a SelectionTracker for that collection. Then subscribe to ItemSelected and ItemUnselected to handle individual selection changes, or alternatively subscribe to SelectedItems.CollectionChanged. If you don't care about being able to access SelectedItems as a collection, then you can get rid of _selectedItems and _selectedItemsView and avoid some list removal overhead.

    [With VirtualizationMode="Recycling"] WTF? Even scrolling doesn't trigger event.

    Well, that's a strange one. I see no reason why that should not work in this case, but I can perhaps see why it might not always work. In theory, as soon as the container is 'recycled' and its DataContext is assigned a new item, the IsSelected binding should update. If the container's previously assigned item had also been selected, that might not trigger a property change, and thus the event might not fire. But that doesn't seem to be the case in your example. Possibly a bug or unintended consequence of how recycling is implemented.

    Don’t use IsSelected to manage selection.

    I think the big takeaway here is that using ListBoxItem.IsSelected to *set* the selection is unreliable; it should only be trusted to reflect whether a given container is selected. It’s really intended for style and template triggers, so that they may know whether to render a container as selected or not. It was never meant to manage selection, and it’s a mistake to use it that way, because it represents the selection state of the container and not its associated data item. Thus, it only works in the most naïve and least performant scenario where every item is always associated with its own container (no virtualization).