-1

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?

Sinatr
  • 20,892
  • 15
  • 90
  • 319
  • Related question: [Extended selection mode, virtualization and IsSelected binding](https://stackoverflow.com/questions/37541929/extended-selection-mode-virtualization-and-isselected-binding) – stuartd Jan 31 '18 at 16:13
  • @stuartd, funny, so I've asked this already and completely forgot for a year to face the issue again. This time it's a little bit, but different (as well as effort put into this one), so I'll keep the question. – Sinatr Feb 01 '18 at 09:45
  • @stuartd, solution in another question won't apply here, because the event to synchronize is not rised. My question is why and what to do? I don't want to disable virtualization. – Sinatr Feb 01 '18 at 10:02

1 Answers1

3

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).

Mike Strobel
  • 25,075
  • 57
  • 69
  • I know why it doesn't work in "Standard" mode, was intentionally omitting explanation for woah-effect and to keep question short, sorry about that. But why it doesn't work in "Recycling" mode (which re-use containers) exactly? Idea of moving logic into viewmodel is valid, though I would rather do it directly in `IsSelected` property setter. `SelectionTracker` abstraction is interesting approach, thanks. But the idea was to create bunch of reusable behaviors for the view, they are easier to reuse than some code in viewmodels. And then `SelectionChanged` suddenly become a problem. – Sinatr Feb 01 '18 at 10:00
  • I missed the part where the event doesn't trigger in `Recycling` mode. That is indeed strange. I would expect the behavior to be the same for any `VirtualizationMode`. If you want to create a behavior for the view, you could still use the basic approach of my `SelectionTracker`. Just have it update the source collection directly from the attached control's `ItemsSource`, and have the behavior fire its own `SelectionChanged` event. – Mike Strobel Feb 01 '18 at 13:16
  • I added a couple paragraphs. The last one is, I think, the most pertinent. While you can rely on `SelectionChanged` to report item selection, you cannot reliably _control_ item selection with `IsSelected`. – Mike Strobel Feb 01 '18 at 13:36
  • *"Possibly a bug or unintended consequence of how recycling is implemented"* - the reason of asking the question here. As a workaround I am rather switching back to "Standard". Then view-specific logic (data triggers bound to view properties) is working. Like if "Recycling" is never existed ;) – Sinatr Feb 01 '18 at 14:23
  • Maybe, as a quirk of how recycling is implemented, the `ListBoxItem` is propagating `IsSelected = false` from the _previous_ binding back to the newly assigned item. Put a `DataGrid` next to the `ListBox` and watch the `IsSelected` column to see if it goes from `true` to `false`. – Mike Strobel Feb 01 '18 at 16:08