Home > Net >  Why are listbox items not being unselected if they are not in view, after the listbox itself comes b
Why are listbox items not being unselected if they are not in view, after the listbox itself comes b

Time:10-15

This is a weird issue that I found in an MVVM project. I bound the IsSelected property of a ListBoxItem to an IsSelected property in the underlying model. If the collection holding the models bound to the list is too big, when you select a different user control and the focus is taken off of the ListBox; when you select an item in the list it will unselect every item EXCEPT the ones that are off-screen. The following gif shows this issue in a test project I made specifically for this issue;

Issue in Practice

MainView.xaml

<UserControl x:Class="ListBox_Selection_Issue.Views.MainView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:ListBox_Selection_Issue.Views"
             xmlns:vms="clr-namespace:ListBox_Selection_Issue.ViewModels"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.DataContext>
        <vms:MainViewModel/>
    </UserControl.DataContext>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <ListBox Grid.Row="0" SelectionMode="Extended" ItemsSource="{Binding FirstCollection}">
            <ListBox.ItemContainerStyle>
                <Style TargetType="{x:Type ListBoxItem}">
                    <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
                </Style>
            </ListBox.ItemContainerStyle>
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Name}" />
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>

        <Button Grid.Row="1"/>
    </Grid>
</UserControl>

MainViewModel.cs

using System.Collections.ObjectModel;

namespace ListBox_Selection_Issue.ViewModels
{
    class MainViewModel : ObservableObject
    {
        private ObservableCollection<CustomClass> _firstCollection;
        public ObservableCollection<CustomClass> FirstCollection
        {
            get { return _firstCollection; }
            set
            {
                _firstCollection = value;
                OnPropertyChanged("FirstCollection");
            }
        }

        public MainViewModel()
        {
            ObservableCollection<CustomClass> first = new ObservableCollection<CustomClass>();

            for (int i = 1; i <= 300; i  )
            {
                first.Add(new CustomClass($"{i}"));
            }

            FirstCollection = first;
        }
    }

    public class CustomClass
    {
        public string Name { get; set; }
        public bool IsSelected { get; set; }

        public CustomClass(string name)
        {
            Name = name;
            IsSelected = false;
        }
    }
}

CodePudding user response:

This is not how it works. If you understand UI virtualization, you should understand that virtualized containers (in your case ListBoxItem) are not part of the visual tree as they are removed as part of the virtualization process.

Because the WPF rendering engine has now far less containers to render, the performance is significantly improved. The effect becomes more relevant the more items the ItemsControl holds.
This is why you would never want to disable UI virtualization. This is why your posted solution can be qualified as a bad solution one should avoid.

ListBox is a Selector control. To allow it to work with any data, it must not be aware of the actual data models it renders. That's what the containers are for: anonymous wrappers that allow rendering and interaction logic without having the host to know the wrapped data object.

In your case, when ListBox.SelectionMode is set to SelectionMode.Extended or SelectionMode.Multiple, the ListBox will have to unselect all previously selected item in case the selection changes. Since it doesn't care about your data models, it only handles their associated wrappers: it will iterate over all ListBoxItem instances to change their state to e.g. unselected.

But the selection state will only be forwarded to the binding source for those Binding objects that are actually active (because the binding target is currently realized/visible).
Although all containers, virtualized and realized, will be unselected, the Binding of those virtualized containers won't update (because the corresponding container is not active and removed - removing includes clearing their ListBoxItem.Content property too).
As a matter of fact, unless container recycling is explicitly enabled by setting VirtualizingPanel.VirtualizationMode to VirtualizationMode.Recycling, virtualized container instances are removed by simply making them eligible for garbage collection. They are just dropped and gone without any further modification e.g., of the ListBoxItem.IsSelected property and new container instances are created for newly realized items.

Now when you scroll the virtualized items into the view, the ListBox will generate the containers and will set the ListBoxItem.Content property to the wrapped data item. Since in your case the data model, the CustomClass, still holds the previous and now outdated selection state (which is still "selected"), the realized containers will change their state back to selected (via the reactivated data binding).

That's why in your case virtualized items remain selected. And this is because you bind the container's state to your data model in a multiselect scenario with UI virtualization engaged.

ListBox selection states are meant to be handled via the Selector.SelectedItem and ListBox.SelectedItems properties or the Selector.SelectionChanged event. This is not important in single select mode but essential in multiselect mode.

Since ListBox.SelectedItems is a read-only property, you can't set a binding on it (like you would usually do with the SelectedItem property). There are many ways you can get the SeletedItems value to your DataContext. The most straight forward would be to send it from the Selector.SelectionChanged event.

From a design perspective, you should generally never set the DataContext of a UserControl, or Control in general, explicitly. The DataContext should be inherited from the parent element that hosts your custom control.
A View-Model-per-View approach is always to avoid. It will make your control very specific to a particular data type. And vice versa, it will make your View Model to specific for a particular element of the View. Additionally, it will introduce other design problems that may even lead to break MVVM.
The DataContext must always be set outside the control (from the client context), so that the control doesn't know the concrete DataContext class.

The improved solution could look as follows:

MainWindow.xaml

<Window>
  <Window.DataContext>
    <local:MainViewModel />
  </Window.DataContext>

  <Grid>    
    <local:MainView ItemsSource="{Binding FirstCollection}"
                    SelectedItems="{Binding SelectedCustomClassModels}">
      <local:MainView.ItemTemplate>
        <DataTemplate>
          <TextBlock Text="{Binding Name}" />
        </DataTemplate>
      </local:MainView.ItemTemplate>
    </local:MainView>
  </Grid>
</Window>

MainView.xaml.cs

public partial class MainView : UserControl
{
  public IList SelectedItems
  {
    get => (IList)GetValue(SelectedItemsProperty);
    set => SetValue(SelectedItemsProperty, value);
  }

  public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
    "SelectedItems", 
    typeof(IList), 
    typeof(MainView), 
    new FrameworkPropertyMetadata(default(IList), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

  public IList ItemsSource
  {
    get => (IList)GetValue(ItemsSourceProperty);
    set => SetValue(ItemsSourceProperty, value);
  }

  public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(
    "ItemsSource", 
    typeof(IList), 
    typeof(MainView), new PropertyMetadata(default));

  public DataTemplate ItemTemplate
  {
    get { return (DataTemplate)GetValue(ItemTemplateProperty); }
    set { SetValue(ItemTemplateProperty, value); }
  }

  public static readonly DependencyProperty ItemTemplateProperty = DependencyProperty.Register(
    "ItemTemplate", 
    typeof(DataTemplate), 
    typeof(MainView), new PropertyMetadata(default));

  public MainView()
  {
    InitializeComponent();
  }

  private void ListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
  {
    // You can track particular items...
    IList newSelectedItems = e.AddedItems;
    IList newUnselectedItems = e.RemovedItems;

    // ... or the final result
    var listBox = sender as ListBox;
    this.SelectedItems = listBox.SelectedItems;
  }
}

MainView.xaml

<UserControl>
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition />
      <RowDefinition />
    </Grid.RowDefinitions>

    <ListBox Grid.Row="0"
             SelectionMode="Extended"
             SelectionChanged="ListBox_SelectionChanged"
             ItemsSource="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=ItemsSource}"
             ItemTemplate="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=ItemTemplate}">
    </ListBox>

    <Button Grid.Row="1" />
  </Grid>
</UserControl>

MainViewModel.cs

public class MainViewModel : ObservableObject
{
  public ObservableCollection<CustomClass> FirstCollection { get; private set; }

  private IList selectedCustomClassModels;
  public IList SelectedCustomClassModels
  {
    get => this.selectedCustomClassModels;
    set
    {
      this.selectedCustomClassModels = value;
      OnSelectedCustomClassModelsChanged();
    }
  }

  public MainViewModel()
  {
    this.FirstCollection = new ObservableCollection<CustomClass>();
    for (int i = 1; i <= 300; i  )
    {
      this.FirstCollection.Add(new CustomClass($"{i}"));
    }

    this.SelectedCustomClassModels = new List<object>();
  }

  private void OnSelectedCustomClassModelsChanged()
  {
    // TODO::Handle selected items
    IEnumerable<CustomClass> selectedItems = this.SelectedCustomClassModels
      .Cast<CustomClass>();
  }
}

CodePudding user response:

Finding the fix to this issue took me longer than I would have expected, so I figured I would share my knowledge.

Add VirtualizingStackPanel.IsVirtualizing="False" to the ListBox like in the following file:

<UserControl x:Class="ListBox_Selection_Issue.Views.MainView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:ListBox_Selection_Issue.Views"
             xmlns:vms="clr-namespace:ListBox_Selection_Issue.ViewModels"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.DataContext>
        <vms:MainViewModel/>
    </UserControl.DataContext>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <ListBox Grid.Row="0" SelectionMode="Extended" VirtualizingStackPanel.IsVirtualizing="False" ItemsSource="{Binding FirstCollection}">
            <ListBox.ItemContainerStyle>
                <Style TargetType="{x:Type ListBoxItem}">
                    <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
                </Style>
            </ListBox.ItemContainerStyle>
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding Name}" />
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>

        <Button Grid.Row="1"/>
    </Grid>
</UserControl>

I found out that the issue had to do with "Virtualization" from this answer (Select Show more comments). Then I started looking into Listbox Virtualization. I lost the tab where I got the answer from. So, I can't credit them. Hopefully, this will help someone in the future.

  • Related